From 2591faa0ba98d1303beeffe830ad740f6ff92b69 Mon Sep 17 00:00:00 2001 From: a Date: Fri, 12 Jun 2026 18:26:03 +0100 Subject: [PATCH] Convert proprietary module (security/JPA/oauth2/saml2) to Quarkus via workflow (WIP) --- .../proprietary/audit/AuditAspect.java | 105 ++- .../audit/AuditDashboardWebController.java | 48 +- .../audit/ControllerAuditAspect.java | 202 ++--- .../cluster/ClusterLicenseGate.java | 35 +- .../proprietary/cluster/ClusterMetrics.java | 13 +- .../cluster/ClusterNodeBootstrap.java | 97 ++- .../cluster/s3/S3FileStoreConfiguration.java | 48 +- .../valkey/ConditionalOnValkeyBackplane.java | 26 +- .../valkey/ValkeyClusterBackplane.java | 43 +- .../valkey/ValkeyConnectionConfiguration.java | 54 +- .../cluster/valkey/ValkeyDistributedLock.java | 35 +- .../valkey/ValkeyInstanceRegistry.java | 72 +- .../cluster/valkey/ValkeyJobStore.java | 211 ++--- .../cluster/valkey/ValkeyKeyValueCache.java | 48 +- .../cluster/valkey/ValkeyRateLimitStore.java | 18 +- .../proprietary/config/AsyncConfig.java | 80 +- .../config/AuditConfigurationProperties.java | 12 +- .../proprietary/config/AuditJpaConfig.java | 16 +- .../config/CustomAuditEventRepository.java | 63 +- .../ServerCertificateInitializer.java | 12 +- .../controller/api/AdminJobController.java | 54 +- .../controller/api/AiEngineController.java | 177 +++-- .../api/AuditDashboardController.java | 165 ++-- .../controller/api/AuditRestController.java | 239 +++--- .../api/CreatePdfAgentController.java | 29 +- .../api/MathAuditorAgentController.java | 62 +- .../api/PdfCommentAgentController.java | 74 +- .../api/ProprietaryUIDataController.java | 122 +-- .../controller/api/SignatureController.java | 103 +-- .../controller/api/UsageRestController.java | 28 +- .../proprietary/mcp/McpServerController.java | 106 ++- .../mcp/catalog/McpToolCatalog.java | 142 ++-- .../mcp/catalog/OperationMeta.java | 9 +- .../mcp/catalog/SimpleSchemaGenerator.java | 2 +- .../mcp/engine/EngineCapabilityClient.java | 17 +- .../mcp/security/McpApiKeyAuthFilter.java | 74 +- .../mcp/security/McpAudienceValidator.java | 64 +- .../security/McpAuthenticationEntryPoint.java | 29 +- .../mcp/security/McpRequestSizeFilter.java | 17 +- .../mcp/security/McpSecurityConfig.java | 293 +++---- .../mcp/security/McpUserBindingFilter.java | 64 +- .../mcp/tools/AbstractCategoryTool.java | 17 +- .../mcp/tools/DescribeOperationTool.java | 19 +- .../mcp/tools/McpOperationExecutor.java | 102 ++- .../proprietary/mcp/tools/StirlingAiTool.java | 27 +- .../mcp/tools/StirlingConvertTool.java | 15 +- .../mcp/tools/StirlingDownloadTool.java | 13 +- .../mcp/tools/StirlingMiscTool.java | 15 +- .../mcp/tools/StirlingPagesTool.java | 15 +- .../mcp/tools/StirlingSecurityTool.java | 17 +- .../mcp/tools/StirlingUploadTool.java | 11 +- .../model/api/ai/AiWorkflowFileInput.java | 8 +- .../api/audit/AuditDateExportRequest.java | 7 +- .../policy/config/FolderAccessGuard.java | 19 +- .../policy/controller/PolicyController.java | 262 ++++--- .../policy/engine/PolicyEngine.java | 9 +- .../policy/engine/PolicyExecutionResult.java | 2 +- .../policy/engine/PolicyExecutor.java | 54 +- .../engine/PolicyInputRequiredException.java | 2 +- .../policy/engine/PolicyRunRegistry.java | 5 +- .../policy/engine/PolicyRunner.java | 14 +- .../policy/engine/PolicyValidator.java | 21 +- .../policy/input/FolderInputSource.java | 10 +- .../policy/model/PolicyInputs.java | 2 +- .../policy/output/FolderOutputSink.java | 20 +- .../policy/output/InlineOutputSink.java | 16 +- .../policy/output/PolicyOutputSink.java | 5 +- .../policy/store/JpaPolicyStore.java | 19 +- .../policy/store/PolicyRepository.java | 13 +- .../policy/trigger/FolderWatchTrigger.java | 21 +- .../policy/trigger/PolicyTriggerManager.java | 28 +- .../policy/trigger/ScheduleTrigger.java | 4 +- .../PersistentAuditEventRepository.java | 536 ++++++++----- .../CustomAuthenticationFailureHandler.java | 91 ++- .../CustomAuthenticationSuccessHandler.java | 71 +- .../security/CustomLogoutSuccessHandler.java | 116 +-- .../security/InitialSecuritySetup.java | 10 +- .../security/JwtAuthenticationEntryPoint.java | 28 +- .../security/RateLimitResetScheduler.java | 19 +- .../config/EnterpriseEndpointAspect.java | 52 +- .../config/PremiumEndpointAspect.java | 49 +- .../security/configuration/CacheConfig.java | 47 +- .../configuration/DatabaseConfig.java | 226 ++++-- .../security/configuration/MailConfig.java | 33 +- .../configuration/PasswordEncoderConfig.java | 24 +- .../ProprietaryWebMvcConfig.java | 32 +- .../configuration/SecurityConfiguration.java | 574 +++++--------- .../ee/DynamicLicenseService.java | 4 +- .../configuration/ee/EEAppConfig.java | 71 +- .../ee/KeygenLicenseVerifier.java | 5 +- .../configuration/ee/LicenseKeyChecker.java | 24 +- .../api/AdminLicenseController.java | 195 +++-- .../api/AdminSettingsController.java | 334 ++++---- .../controller/api/AuthController.java | 474 +++++++----- .../controller/api/DatabaseController.java | 213 ++--- .../controller/api/EmailController.java | 75 +- .../controller/api/InviteLinkController.java | 253 +++--- .../api/ServerCertificateController.java | 123 +-- .../controller/api/TeamController.java | 122 +-- .../api/UIDataTessdataController.java | 57 +- .../controller/api/UserController.java | 728 ++++++++++-------- .../DatabaseControllerEnterprise.java | 48 +- .../security/database/H2SQLCondition.java | 41 +- .../security/database/ScheduledTasks.java | 27 +- .../repository/AuthorityRepository.java | 26 +- .../repository/JPATokenRepositoryImpl.java | 19 +- .../repository/PersistentLoginRepository.java | 17 +- .../repository/SessionRepository.java | 85 +- .../database/repository/UserRepository.java | 201 +++-- .../service/DatabaseNotificationService.java | 37 +- .../filter/EnterpriseEndpointFilter.java | 35 +- .../filter/JwtAuthenticationFilter.java | 59 +- .../ParticipantRateLimitInterceptor.java | 66 +- .../filter/UserAuthenticationFilter.java | 71 +- .../filter/UserBasedRateLimitingFilter.java | 74 +- .../model/ApiKeyAuthenticationToken.java | 46 +- .../proprietary/security/model/Authority.java | 9 +- .../proprietary/security/model/User.java | 12 +- .../proprietary/security/model/api/Email.java | 7 +- .../AuthenticationFailureException.java | 8 +- ...tomOAuth2AuthenticationFailureHandler.java | 128 ++- ...tomOAuth2AuthenticationSuccessHandler.java | 164 ++-- .../security/oauth2/OAuth2Configuration.java | 226 +++--- .../TauriAuthorizationRequestResolver.java | 67 +- .../repository/InviteTokenRepository.java | 42 +- .../security/repository/TeamRepository.java | 41 +- .../UserLicenseSettingsRepository.java | 13 +- .../security/saml2/CertificateUtils.java | 10 +- .../CustomSaml2AuthenticatedPrincipal.java | 19 +- ...stomSaml2AuthenticationFailureHandler.java | 73 +- ...stomSaml2AuthenticationSuccessHandler.java | 88 ++- ...mSaml2ResponseAuthenticationConverter.java | 69 +- ...tSaml2AuthenticationRequestRepository.java | 91 +-- .../security/saml2/Saml2Configuration.java | 174 ++--- .../service/AppUpdateAuthService.java | 24 +- .../service/CustomOAuth2UserService.java | 146 ++-- .../service/CustomUserDetailsService.java | 31 +- .../security/service/DatabaseService.java | 31 +- .../security/service/EmailService.java | 60 +- .../security/service/JwtService.java | 17 +- .../security/service/JwtServiceInterface.java | 9 +- .../service/KeyPairCleanupService.java | 31 +- .../service/KeyPersistenceService.java | 58 +- .../security/service/LoginAttemptService.java | 5 +- .../security/service/MfaService.java | 6 +- .../service/RefreshRateLimitService.java | 10 +- .../security/service/TeamService.java | 13 +- .../security/service/TotpService.java | 4 +- .../security/service/UserService.java | 95 ++- .../session/CustomHttpSessionListener.java | 7 +- .../session/SessionPersistentRegistry.java | 36 +- .../session/SessionRegistryConfig.java | 24 +- .../security/session/SessionScheduled.java | 12 +- .../security/supabase/SupabaseEndpoints.java | 15 +- .../supabase/SupabaseJwtDecoderFactory.java | 51 +- .../supabase/SupabaseUserLoginProperties.java | 10 +- .../proprietary/service/AiEngineClient.java | 69 +- .../service/AiEngineEndpointResolver.java | 72 +- .../service/AiToolInputValidator.java | 35 +- .../service/AiUserDataService.java | 19 +- .../service/AiWorkflowService.java | 76 +- .../service/AuditCleanupService.java | 26 +- .../proprietary/service/AuditService.java | 150 +++- .../service/ByteHashFileIdStrategy.java | 10 +- .../proprietary/service/FileIdStrategy.java | 2 +- .../ImageMagickLineArtConversionService.java | 5 +- .../service/MathAuditorOrchestrator.java | 15 +- .../service/PdfCommentAgentOrchestrator.java | 31 +- .../service/PdfContentExtractor.java | 5 +- .../service/PdfTextChunkExtractor.java | 5 +- .../service/ServerCertificateService.java | 29 +- .../proprietary/service/SignatureService.java | 4 +- .../service/UserLicenseSettingsService.java | 16 +- .../storage/config/ClusterStorageGate.java | 23 +- .../storage/config/StorageProviderConfig.java | 21 +- .../FileFolderPlacementController.java | 40 +- .../controller/FileStorageController.java | 299 ++++--- .../storage/controller/FolderController.java | 60 +- .../provider/DatabaseStorageProvider.java | 21 +- .../provider/LocalStorageProvider.java | 7 +- .../storage/provider/S3StorageProvider.java | 7 +- .../storage/provider/StorageProvider.java | 9 +- .../repository/FileShareAccessRepository.java | 61 +- .../repository/FileShareRepository.java | 65 +- .../storage/repository/FolderRepository.java | 78 +- .../StorageCleanupEntryRepository.java | 14 +- .../repository/StoredFileBlobRepository.java | 14 +- .../repository/StoredFileRepository.java | 167 ++-- .../storage/service/FileStorageService.java | 141 ++-- .../storage/service/FolderService.java | 151 ++-- .../service/StorageCleanupService.java | 21 +- .../proprietary/web/AuditWebFilter.java | 57 +- .../proprietary/web/CorrelationIdFilter.java | 24 +- .../controller/SigningSessionController.java | 425 +++++----- .../WorkflowParticipantController.java | 386 ++++++---- .../workflow/dto/SignDocumentRequest.java | 4 +- .../dto/SignatureSubmissionRequest.java | 2 +- .../UserServerCertificateRepository.java | 27 +- .../WorkflowParticipantRepository.java | 92 ++- .../repository/WorkflowSessionRepository.java | 84 +- .../CertificateSubmissionValidator.java | 55 +- .../service/MetadataEncryptionService.java | 4 +- .../service/SigningFinalizationService.java | 118 +-- .../service/UnifiedAccessControlService.java | 10 +- .../service/UserServerCertificateService.java | 32 +- .../service/WorkflowSessionService.java | 300 ++++---- 206 files changed, 8484 insertions(+), 6178 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java index f42a0f29c1..4a8ef53e8c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java @@ -4,46 +4,79 @@ import java.lang.reflect.Method; import java.util.Map; import org.apache.commons.lang3.StringUtils; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.MDC; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.service.AuditService; -/** Aspect for processing {@link Audited} annotations. */ -@Aspect -@Component +/** + * Interceptor for processing {@link Audited} annotations. + * + *

MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} {@code @Component} with + * {@code @Around("@annotation(...Audited)")} advice. Reworked into a CDI {@link Interceptor} bound + * by the {@code @Audited} annotation; {@code @Around}/{@code ProceedingJoinPoint} became + * {@code @AroundInvoke}/{@link InvocationContext}. Spring's {@code @Order(10)} (lower precedence, + * runs after {@code AutoJobAspect}) maps to {@code @Priority}: {@code AutoJobAspect} uses + * {@code @Priority(20)}, so this audit interceptor uses {@code @Priority(10)} which runs FIRST and + * populates MDC before the job interceptor - matching the original ordering intent (audit captures + * principal/origin/IP on the request thread before the job is dispatched). + * + *

TODO: Migration required - the {@code @Audited} annotation + * ({@code stirling.software.proprietary.audit.Audited}) must be made a CDI + * {@code @jakarta.interceptor.InterceptorBinding} (and its members marked + * {@code @jakarta.enterprise.util.Nonbinding}) for this {@code @Interceptor} to bind to it; see the + * already-migrated {@code AutoJobPostMapping}. That is a separate file and is intentionally left + * untouched here. + * + *

TODO: Migration required - {@code AuditService}'s helper methods + * ({@code createBaseAuditData}, {@code addFileData}, {@code addMethodArguments}, + * {@code resolveEventType}) currently accept an AspectJ {@code ProceedingJoinPoint} / + * {@code joinPoint.getTarget()} / {@code joinPoint.getArgs()}. They must be migrated to accept a CDI + * {@link InvocationContext} (use {@code ctx.getTarget()}, {@code ctx.getParameters()}, + * {@code ctx.getMethod()}). The call sites below pass {@code ctx} on that assumption. + */ +@Interceptor +@Audited +@Priority(10) @Slf4j -@RequiredArgsConstructor -@org.springframework.core.annotation.Order( - 10) // Lower precedence (higher number) - executes after AutoJobAspect public class AuditAspect { private final AuditService auditService; private final AuditConfigurationProperties auditConfig; + private final HttpServletRequest request; + private final HttpServletResponse response; - @Around("@annotation(stirling.software.proprietary.audit.Audited)") - public Object auditMethod(ProceedingJoinPoint joinPoint) throws Throwable { - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - Method method = signature.getMethod(); + @Inject + public AuditAspect( + AuditService auditService, + AuditConfigurationProperties auditConfig, + HttpServletRequest request, + HttpServletResponse response) { + this.auditService = auditService; + this.auditConfig = auditConfig; + this.request = request; + this.response = response; + } + + @AroundInvoke + public Object auditMethod(InvocationContext ctx) throws Exception { + Method method = ctx.getMethod(); Audited auditedAnnotation = method.getAnnotation(Audited.class); // Fast path: use unified check to determine if we should audit // This avoids all data collection if auditing is disabled if (!auditService.shouldAudit(method, auditConfig)) { - return joinPoint.proceed(); + return ctx.proceed(); } // EARLY CAPTURE: Try to get from MDC first (propagated from background threads) @@ -60,9 +93,12 @@ public class AuditAspect { capturedOrigin = auditService.captureCurrentOrigin(); } - ServletRequestAttributes attrs = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - HttpServletRequest req = attrs != null ? attrs.getRequest() : null; + // MIGRATION: Spring's RequestContextHolder/ServletRequestAttributes -> CDI-injected + // jakarta HttpServletRequest/HttpServletResponse (quarkus-undertow). When invoked outside an + // HTTP request scope the injected proxy resolves to null, so we treat a null request the + // same way the original treated a null ServletRequestAttributes. + HttpServletRequest req = request; + boolean isHttpRequest = req != null; String capturedIp = MDC.get("auditIp"); if (capturedIp == null) { @@ -71,15 +107,18 @@ public class AuditAspect { } // Only create the map once we know we'll use it + // TODO: Migration required - createBaseAuditData must accept InvocationContext (ctx) once + // AuditService is migrated off ProceedingJoinPoint. Map auditData = - auditService.createBaseAuditData(joinPoint, auditedAnnotation.level()); + auditService.createBaseAuditData(ctx, auditedAnnotation.level()); // Add HTTP information if we're in a web context - if (attrs != null) { + if (isHttpRequest) { String path = req.getRequestURI(); String httpMethod = req.getMethod(); auditService.addHttpData(auditData, httpMethod, path, auditedAnnotation.level()); - auditService.addFileData(auditData, joinPoint, auditedAnnotation.level()); + // TODO: Migration required - addFileData must accept InvocationContext (ctx). + auditService.addFileData(auditData, ctx, auditedAnnotation.level()); // File operation details logged at DEBUG level for verification if (auditData.containsKey("files") || auditData.containsKey("filename")) { @@ -102,7 +141,8 @@ public class AuditAspect { // Add method arguments if requested (captured at all audit levels for operational context) if (auditedAnnotation.includeArgs()) { - auditService.addMethodArguments(auditData, joinPoint, auditedAnnotation.level()); + // TODO: Migration required - addMethodArguments must accept InvocationContext (ctx). + auditService.addMethodArguments(auditData, ctx, auditedAnnotation.level()); } // Record start time for latency calculation @@ -110,7 +150,7 @@ public class AuditAspect { Object result; try { // Execute the method - result = joinPoint.proceed(); + result = ctx.proceed(); // Add success status auditData.put("status", "success"); @@ -126,7 +166,7 @@ public class AuditAspect { } return result; - } catch (Throwable ex) { + } catch (Exception ex) { // Always add failure information regardless of level auditData.put("status", "failure"); auditData.put("errorType", ex.getClass().getName()); @@ -137,23 +177,24 @@ public class AuditAspect { } finally { // Add timing information - use isHttpRequest=false to ensure we get timing for non-HTTP // methods - HttpServletResponse resp = attrs != null ? attrs.getResponse() : null; - boolean isHttpRequest = attrs != null; + HttpServletResponse resp = isHttpRequest ? response : null; auditService.addTimingData( auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest); // Resolve the event type based on annotation and context String httpMethod = null; String path = null; - if (attrs != null) { + if (isHttpRequest) { httpMethod = req.getMethod(); path = req.getRequestURI(); } + // TODO: Migration required - resolveEventType reads joinPoint.getTarget(); once + // AuditService is migrated it should use ctx.getTarget().getClass() instead. AuditEventType eventType = auditService.resolveEventType( method, - joinPoint.getTarget().getClass(), + ctx.getTarget().getClass(), path, httpMethod, auditedAnnotation); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditDashboardWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditDashboardWebController.java index b7229cc290..06c23cff7f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditDashboardWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditDashboardWebController.java @@ -1,9 +1,13 @@ package stirling.software.proprietary.audit; -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 java.util.HashMap; +import java.util.Map; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; import io.swagger.v3.oas.annotations.Hidden; @@ -12,28 +16,42 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.security.config.EnterpriseEndpoint; -@Controller -@PreAuthorize("hasRole('ADMIN')") +@Path("") +@ApplicationScoped +@RolesAllowed("ADMIN") @RequiredArgsConstructor @EnterpriseEndpoint public class AuditDashboardWebController { private final AuditConfigurationProperties auditConfig; /** Display the audit dashboard. */ - @GetMapping("/audit") + @GET + @Path("/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()); + public Response showDashboard() { + // Spring's org.springframework.ui.Model + view-name ("audit/dashboard") drove Thymeleaf + // server-side rendering. Quarkus has no Thymeleaf view resolver; the equivalent is a Qute + // TemplateInstance bound to src/main/resources/templates/audit/dashboard.html. + // TODO: Migration required - rebind this view to Qute. Inject + // @io.quarkus.qute.Location("audit/dashboard") io.quarkus.qute.Template dashboard; and return + // dashboard.data(...) as a TemplateInstance (with a Qute RestEasy extension), or render the + // page client-side. The model attributes below are preserved so they can be passed to the + // Qute template once the audit/dashboard template is ported. + Map model = new HashMap<>(); + model.put("auditEnabled", auditConfig.isEnabled()); + model.put("auditLevel", auditConfig.getAuditLevel()); + model.put("auditLevelInt", auditConfig.getLevel()); + model.put("retentionDays", auditConfig.getRetentionDays()); // Add audit level enum values for display - model.addAttribute("auditLevels", AuditLevel.values()); + model.put("auditLevels", AuditLevel.values()); // Add audit event types for the dropdown - model.addAttribute("auditEventTypes", AuditEventType.values()); + model.put("auditEventTypes", AuditEventType.values()); - return "audit/dashboard"; + // TODO: Migration required - return the rendered Qute template instead of this placeholder + // once audit/dashboard.html is migrated. The attributes in `model` map 1:1 to the former + // Spring Model attributes. + return Response.ok(model).build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java index 0d777d9481..e30f567cfe 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -5,91 +5,100 @@ import java.lang.reflect.Method; import java.util.Map; import org.apache.commons.lang3.StringUtils; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.MDC; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.service.AuditService; /** - * Aspect for automatically auditing controller methods with web mappings (GetMapping, PostMapping, - * etc.) + * Interceptor for automatically auditing controller methods with web mappings. + * + *

MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect}/{@code @Component} with + * multiple {@code @Around} advices whose pointcuts matched any method annotated with + * Spring's {@code @GetMapping}/{@code @PostMapping}/{@code @PutMapping}/{@code @DeleteMapping}/ + * {@code @PatchMapping}/{@code @AutoJobPostMapping}, plus an {@code execution(...)} expression on + * Spring's {@code ResourceHttpRequestHandler}. {@code @Around}/{@code ProceedingJoinPoint} + + * {@code MethodSignature} became {@code @AroundInvoke}/{@link InvocationContext}, and + * {@code RequestContextHolder}/{@code ServletRequestAttributes} were replaced by an injected + * {@link HttpServletRequest}/{@link HttpServletResponse} (provided by quarkus-undertow). The Spring + * {@code @Order(0)} (highest precedence, runs before {@code AutoJobAspect}) maps to + * {@code @Priority} with a value lower than {@code AutoJobAspect}'s {@code @Priority(20)} so this + * interceptor still populates MDC first. + * + *

TODO: Migration required - CDI interceptors are bound by an {@code @InterceptorBinding} + * annotation declared on the target class/method; there is NO CDI equivalent for AspectJ's broad, + * expression-based pointcuts. The original advices fired for every Spring-MVC mapping annotation and + * for the static-resource handler, none of which exist on JAX-RS controllers. To retain + * "audit every HTTP endpoint" behaviour in Quarkus, do ONE of: + *

+ * As an interim binding this interceptor is bound by the existing {@link AutoJobPostMapping} + * {@code @InterceptorBinding} (one of the six original pointcuts) so the class is valid CDI and + * still audits auto-job POST endpoints. This does NOT cover plain GET/POST/PUT/DELETE/PATCH or + * static-resource requests the way the Spring aspect did - that requires the JAX-RS filter or + * dedicated binding described above. NOTE: it must NOT be bound to {@link Audited}, because the body + * deliberately skips {@code @Audited} methods (those are handled by {@code AuditAspect}). + * The {@code auditController(...)} body below is preserved verbatim; the static-resource and + * static-GET-skip handling (originally driven by the {@code ResourceHttpRequestHandler} pointcut) + * still works via {@link AuditService#isStaticResourceRequest(HttpServletRequest)}. */ -@Aspect -@Component +@Interceptor +@AutoJobPostMapping +@Priority(0) // Highest precedence - runs BEFORE AutoJobAspect (@Priority(20)) to populate MDC @Slf4j -@RequiredArgsConstructor -@org.springframework.core.annotation.Order( - 0) // Highest precedence - runs BEFORE AutoJobAspect to populate MDC public class ControllerAuditAspect { private final AuditService auditService; private final AuditConfigurationProperties auditConfig; + private final HttpServletRequest request; + private final HttpServletResponse response; - @Around( - "execution(* org.springframework.web.servlet.resource.ResourceHttpRequestHandler.handleRequest(..))") - public Object auditStaticResource(ProceedingJoinPoint jp) throws Throwable { - return auditController(jp, "GET"); + @Inject + public ControllerAuditAspect( + AuditService auditService, + AuditConfigurationProperties auditConfig, + HttpServletRequest request, + HttpServletResponse response) { + this.auditService = auditService; + this.auditConfig = auditConfig; + this.request = request; + this.response = response; } - /** Intercept all methods with GetMapping annotation */ - @Around("@annotation(org.springframework.web.bind.annotation.GetMapping)") - public Object auditGetMethod(ProceedingJoinPoint joinPoint) throws Throwable { - return auditController(joinPoint, "GET"); + /** + * TODO: Migration required - this single {@code @AroundInvoke} replaces the five Spring + * {@code @Around} advices (GET/POST/PUT/DELETE/PATCH + AutoJobPostMapping) and the + * static-resource {@code execution(...)} advice. Because CDI cannot inspect Spring/JAX-RS mapping + * annotations to derive the HTTP verb at bind time, the verb is resolved from the live request + * ({@link HttpServletRequest#getMethod()}); if the request is unavailable (non-web invocation) it + * falls back to POST to mirror the most common audited mapping. + */ + @AroundInvoke + public Object auditEndpoint(InvocationContext ctx) throws Throwable { + String httpMethod = request != null ? request.getMethod() : "POST"; + return auditController(ctx, httpMethod != null ? httpMethod : "POST"); } - /** Intercept all methods with PostMapping annotation */ - @Around("@annotation(org.springframework.web.bind.annotation.PostMapping)") - public Object auditPostMethod(ProceedingJoinPoint joinPoint) throws Throwable { - return auditController(joinPoint, "POST"); - } - - /** Intercept all methods with PutMapping annotation */ - @Around("@annotation(org.springframework.web.bind.annotation.PutMapping)") - public Object auditPutMethod(ProceedingJoinPoint joinPoint) throws Throwable { - return auditController(joinPoint, "PUT"); - } - - /** Intercept all methods with DeleteMapping annotation */ - @Around("@annotation(org.springframework.web.bind.annotation.DeleteMapping)") - public Object auditDeleteMethod(ProceedingJoinPoint joinPoint) throws Throwable { - return auditController(joinPoint, "DELETE"); - } - - /** Intercept all methods with PatchMapping annotation */ - @Around("@annotation(org.springframework.web.bind.annotation.PatchMapping)") - public Object auditPatchMethod(ProceedingJoinPoint joinPoint) throws Throwable { - return auditController(joinPoint, "PATCH"); - } - - /** Intercept all methods with AutoJobPostMapping annotation */ - @Around("@annotation(stirling.software.common.annotations.AutoJobPostMapping)") - public Object auditAutoJobMethod(ProceedingJoinPoint joinPoint) throws Throwable { - return auditController(joinPoint, "POST"); - } - - private Object auditController(ProceedingJoinPoint joinPoint, String httpMethod) - throws Throwable { - MethodSignature sig = (MethodSignature) joinPoint.getSignature(); - Method method = sig.getMethod(); + private Object auditController(InvocationContext joinPoint, String httpMethod) throws Throwable { + Method method = joinPoint.getMethod(); // Fast path: check if auditing is enabled before doing any work // This avoids all data collection if auditing is disabled @@ -123,10 +132,8 @@ public class ControllerAuditAspect { } } - ServletRequestAttributes attrs = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - HttpServletRequest req = attrs != null ? attrs.getRequest() : null; - HttpServletResponse resp = attrs != null ? attrs.getResponse() : null; + HttpServletRequest req = request; + HttpServletResponse resp = response; String previousPrincipal = MDC.get("auditPrincipal"); String previousOrigin = MDC.get("auditOrigin"); @@ -163,6 +170,12 @@ public class ControllerAuditAspect { long start = System.currentTimeMillis(); + // TODO: Migration required (collaborator) - AuditService.createBaseAuditData/addFileData/ + // addMethodArguments/resolveEventType still take org.aspectj.lang.ProceedingJoinPoint + // (AuditService is not yet migrated). Once AuditService is converted, change those + // signatures to accept jakarta.interceptor.InvocationContext (getMethod/getParameters/ + // getTarget cover the data used). These calls pass the InvocationContext and will only + // typecheck after that collaborator change. // Use auditService to create the base audit data Map data = auditService.createBaseAuditData(joinPoint, level); @@ -255,34 +268,51 @@ public class ControllerAuditAspect { private String getRequestPath(Method method, String httpMethod) { // Prefer actual request URI over annotation patterns (which may contain regex) - ServletRequestAttributes attrs = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - if (attrs != null) { - HttpServletRequest request = attrs.getRequest(); - if (request != null) { - return request.getRequestURI(); - } + if (request != null) { + return request.getRequestURI(); } - // Fallback: reconstruct from annotations when not in web context + // Fallback: reconstruct from annotations when not in web context. + // TODO: Migration required - the Spring @RequestMapping/@GetMapping/... fallback below relies + // on Spring MVC mapping annotations that no longer exist on JAX-RS controllers. Once the + // controllers are on JAX-RS, switch this fallback to read jakarta.ws.rs.@Path / @GET / @POST + // etc. (or drop it entirely if the request URI is always available). The original Spring + // reconstruction is preserved verbatim until then. String base = ""; - RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class); + org.springframework.web.bind.annotation.RequestMapping cm = + method.getDeclaringClass() + .getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class); if (cm != null && cm.value().length > 0) base = cm.value()[0]; String mp = ""; Annotation ann = switch (httpMethod) { - case "GET" -> method.getAnnotation(GetMapping.class); - case "POST" -> method.getAnnotation(PostMapping.class); - case "PUT" -> method.getAnnotation(PutMapping.class); - case "DELETE" -> method.getAnnotation(DeleteMapping.class); - case "PATCH" -> method.getAnnotation(PatchMapping.class); + case "GET" -> + method.getAnnotation( + org.springframework.web.bind.annotation.GetMapping.class); + case "POST" -> + method.getAnnotation( + org.springframework.web.bind.annotation.PostMapping.class); + case "PUT" -> + method.getAnnotation( + org.springframework.web.bind.annotation.PutMapping.class); + case "DELETE" -> + method.getAnnotation( + org.springframework.web.bind.annotation.DeleteMapping.class); + case "PATCH" -> + method.getAnnotation( + org.springframework.web.bind.annotation.PatchMapping.class); default -> null; }; - if (ann instanceof GetMapping gm && gm.value().length > 0) mp = gm.value()[0]; - if (ann instanceof PostMapping pm && pm.value().length > 0) mp = pm.value()[0]; - if (ann instanceof PutMapping pum && pum.value().length > 0) mp = pum.value()[0]; - if (ann instanceof DeleteMapping dm && dm.value().length > 0) mp = dm.value()[0]; - if (ann instanceof PatchMapping pam && pam.value().length > 0) mp = pam.value()[0]; + if (ann instanceof org.springframework.web.bind.annotation.GetMapping gm + && gm.value().length > 0) mp = gm.value()[0]; + if (ann instanceof org.springframework.web.bind.annotation.PostMapping pm + && pm.value().length > 0) mp = pm.value()[0]; + if (ann instanceof org.springframework.web.bind.annotation.PutMapping pum + && pum.value().length > 0) mp = pum.value()[0]; + if (ann instanceof org.springframework.web.bind.annotation.DeleteMapping dm + && dm.value().length > 0) mp = dm.value()[0]; + if (ann instanceof org.springframework.web.bind.annotation.PatchMapping pam + && pam.value().length > 0) mp = pam.value()[0]; return base + mp; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterLicenseGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterLicenseGate.java index cc67354fec..6d2a52b0ee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterLicenseGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterLicenseGate.java @@ -1,11 +1,12 @@ package stirling.software.proprietary.cluster; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Configuration; +import org.eclipse.microprofile.config.inject.ConfigProperty; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Named; import lombok.extern.slf4j.Slf4j; @@ -13,22 +14,34 @@ import lombok.extern.slf4j.Slf4j; * Runtime license gate for cluster mode. Cluster mode requires a SERVER or ENTERPRISE license; the * SaaS flavor bypasses (no {@code runningProOrHigher} bean is published). The Valkey connection * config {@code @DependsOn} this bean, so it runs before any Valkey bean is constructed. + * + *

TODO: Migration required - Spring @DependsOn ordering relative to the Valkey connection config + * has no direct Quarkus equivalent. Ensure the Valkey/Redis bean either @Inject's this gate or that + * this @PostConstruct verification still runs before any Valkey bean is constructed (e.g. via a + * Startup observer ordering or an explicit dependency). */ -@Configuration -@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true") +@ApplicationScoped @Slf4j public class ClusterLicenseGate { - @Autowired(required = false) - @Qualifier("runningProOrHigher") - private Boolean runningProOrHigher; + // @ConditionalOnProperty(name = "cluster.enabled", havingValue = "true") -> runtime guard below. + @ConfigProperty(name = "cluster.enabled", defaultValue = "false") + boolean clusterEnabled; + + // @Autowired(required = false) @Qualifier("runningProOrHigher") -> optional named lookup. + @Inject + @Named("runningProOrHigher") + Instance runningProOrHigher; @PostConstruct void verifyLicense() { - if (runningProOrHigher == null) { + if (!clusterEnabled) { + return; // cluster mode disabled - gate not applicable + } + if (!runningProOrHigher.isResolvable()) { return; // saas flavor - licensed via Stripe elsewhere } - if (!runningProOrHigher) { + if (!runningProOrHigher.get()) { throw new IllegalStateException( "Cluster mode (cluster.enabled=true) requires a SERVER or" + " ENTERPRISE license. Configure stirling.premium.key with a valid" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterMetrics.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterMetrics.java index a97c3bb906..3f3e42cb45 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterMetrics.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterMetrics.java @@ -4,8 +4,8 @@ import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Gauge; @@ -19,8 +19,12 @@ import stirling.software.common.model.ApplicationProperties; * Cluster operation metrics exposed via {@code /actuator/prometheus}. Registered only when cluster * mode is on. */ -@Component -@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true") +// TODO: Migration required - original @ConditionalOnProperty(name = "cluster.enabled", +// havingValue = "true") was a runtime toggle. Quarkus @IfBuildProfile/@LookupIfProperty are +// build-time only. Either gate registration with a runtime guard on +// applicationProperties.getCluster().isEnabled() (e.g. skip meter registration when disabled), +// or use @io.quarkus.arc.lookup.LookupIfProperty if a build-time switch is acceptable. +@ApplicationScoped public class ClusterMetrics implements StickyMissRecorder { private final MeterRegistry registry; @@ -38,6 +42,7 @@ public class ClusterMetrics implements StickyMissRecorder { private final AtomicLong jobsInflight = new AtomicLong(); + @Inject public ClusterMetrics(MeterRegistry registry, ApplicationProperties applicationProperties) { this.registry = registry; this.applicationProperties = applicationProperties; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterNodeBootstrap.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterNodeBootstrap.java index ccd399410e..c70e8f7f7b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterNodeBootstrap.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/ClusterNodeBootstrap.java @@ -6,13 +6,16 @@ import java.time.Duration; import java.time.Instant; import java.util.Locale; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.SmartLifecycle; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkus.runtime.StartupEvent; +import io.quarkus.scheduler.Scheduled; import lombok.extern.slf4j.Slf4j; @@ -25,31 +28,49 @@ import stirling.software.common.model.ApplicationProperties.Cluster; * Registers the local node with {@link InstanceRegistry} on startup, refreshes the entry at 1/3 of * the TTL, and deregisters cleanly on shutdown. * - *

Implements {@link SmartLifecycle} with {@code getPhase() == Integer.MAX_VALUE} so Spring tears - * this bean down before {@code LettuceConnectionFactory} - deregister therefore runs while the - * Valkey connection is still alive. + *

Originally implemented Spring's {@code SmartLifecycle} with {@code getPhase() == + * Integer.MAX_VALUE} so Spring tore this bean down before {@code LettuceConnectionFactory} - + * deregister therefore ran while the Valkey connection was still alive. + * + *

TODO: Migration required - Quarkus has no SmartLifecycle/getPhase shutdown-ordering + * equivalent. Startup now runs via @Observes StartupEvent and shutdown via @PreDestroy. If the + * Quarkus Redis/Valkey client is torn down before this bean's @PreDestroy, the deregister call may + * fail (it already tolerates that via TTL expiry). If strict ordering is required, observe + * io.quarkus.runtime.ShutdownEvent on a bean ordered ahead of the Redis client, or rely on the + * heartbeat TTL to clean up the stale entry. */ -@Component +@ApplicationScoped @Slf4j -@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true") -public class ClusterNodeBootstrap implements SmartLifecycle { +public class ClusterNodeBootstrap { - private final Duration heartbeatTtl; + // TODO: Migration required - Spring @ConditionalOnProperty(name = "cluster.enabled", + // havingValue = "true") was a runtime toggle. Quarkus build-time conditionals + // (@IfBuildProfile / @LookupIfProperty) cannot gate a StartupEvent observer at runtime, so the + // bean is always instantiated and the toggle is enforced at runtime via clusterEnabled below. + @ConfigProperty(name = "cluster.enabled", defaultValue = "false") + boolean clusterEnabled; + + private Duration heartbeatTtl; private final ApplicationProperties applicationProperties; private final InstanceRegistry instanceRegistry; - @Value("${server.port:8080}") - private int serverPort; + @ConfigProperty(name = "server.port", defaultValue = "8080") + int serverPort; private volatile String nodeId; private volatile String internalAddress; private volatile boolean running = false; + @Inject public ClusterNodeBootstrap( ApplicationProperties applicationProperties, InstanceRegistry instanceRegistry) { this.applicationProperties = applicationProperties; this.instanceRegistry = instanceRegistry; + } + + @PostConstruct + void init() { Cluster cluster = applicationProperties.getCluster(); // Default must match the @Scheduled fallback below AND the model default // (ApplicationProperties.Cluster.Node.heartbeatIntervalMs = 5000); otherwise the TTL is @@ -60,18 +81,30 @@ public class ClusterNodeBootstrap implements SmartLifecycle { this.heartbeatTtl = Duration.ofMillis(heartbeatMs * 3); } - @EventListener(ApplicationReadyEvent.class) - public void registerOnStartup() { + void registerOnStartup(@Observes StartupEvent event) { + if (!clusterEnabled) { + return; + } nodeId = applicationProperties.getCluster().resolvedNodeId(); internalAddress = resolveInternalAddress(); + running = true; registerSelf("register"); } - @Scheduled(fixedDelayString = "${cluster.node.heartbeat-interval-ms:5000}") + // TODO: Migration required - Spring @Scheduled(fixedDelayString = + // "${cluster.node.heartbeat-interval-ms:5000}") drove the interval directly from config in + // milliseconds. Quarkus @Scheduled "every" expects a Duration string, so the config reference + // "{cluster.node.heartbeat-interval-ms}" cannot be reused as-is (it resolves to a bare number). + // Hard-coded to 5s to match the model default; if the interval is operator-tunable, expose a + // duration-formatted property (e.g. cluster.node.heartbeat-interval=5s) and reference it here. + @Scheduled(every = "5s") public void heartbeat() { - // Heartbeat-after-stop race: SmartLifecycle.stop() deregisters, but the @Scheduled - // tick keeps firing during a slow drain. Without this guard, the next tick re-registers - // the dead node and the entry resurfaces in the registry until TTL expiry. + if (!clusterEnabled) { + return; + } + // Heartbeat-after-stop race: shutdown deregisters, but the @Scheduled tick keeps firing + // during a slow drain. Without this guard, the next tick re-registers the dead node and + // the entry resurfaces in the registry until TTL expiry. if (!running) { return; } @@ -100,13 +133,8 @@ public class ClusterNodeBootstrap implements SmartLifecycle { } } - @Override - public void start() { - running = true; - } - - @Override - public void stop() { + @PreDestroy + void stop() { running = false; if (nodeId == null) { return; @@ -124,21 +152,10 @@ public class ClusterNodeBootstrap implements SmartLifecycle { } } - @Override public boolean isRunning() { return running; } - @Override - public int getPhase() { - return Integer.MAX_VALUE; - } - - @Override - public boolean isAutoStartup() { - return true; - } - /** * Resolve the address peers should hit. Order: explicit config -> {@code POD_IP} env (K8s * downward API) -> JDK hostname -> fail loud (never silently fall back to a loopback). diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java index b2a516a4e6..fca027fcf4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3FileStoreConfiguration.java @@ -1,10 +1,12 @@ package stirling.software.proprietary.cluster.s3; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Disposes; +import jakarta.enterprise.inject.Produces; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkus.arc.lookup.LookupIfProperty; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,17 +15,29 @@ import stirling.software.common.cluster.FileStore; import stirling.software.common.model.ApplicationProperties; /** Activates the S3-backed transient {@link FileStore} when {@code cluster.artifactStore=s3}. */ +// TODO: Migration required - the original Spring class was guarded by +// @ConditionalOnProperty(prefix="cluster", name="artifactStore", havingValue="s3") and +// @ConditionalOnMissingBean on the @Bean. The S3 producer below is gated with +// @io.quarkus.arc.lookup.LookupIfProperty(name="cluster.artifactStore", stringValue="s3"), which +// only contributes this FileStore when the property is "s3"; the always-on @DefaultBean producer in +// common's LocalDiskFileStoreConfiguration covers the "local"/default case, so S3 here wins (a +// non-default producer beats @DefaultBean) only when the property selects it - preserving the +// original @ConditionalOnMissingBean intent. Note: @LookupIfProperty is evaluated at build time, so +// the artifact store cannot be switched at runtime. If a true runtime toggle is required, drop the +// annotation and gate the producer body on the config value instead. @Slf4j -@Configuration +@ApplicationScoped @RequiredArgsConstructor -@ConditionalOnProperty(prefix = "cluster", name = "artifactStore", havingValue = "s3") public class S3FileStoreConfiguration { private final ApplicationProperties applicationProperties; - @Bean(destroyMethod = "close") - @ConditionalOnMissingBean - public FileStore fileStore(@Value("${cluster.s3.keyPrefix:transient/}") String keyPrefix) { + @Produces + @ApplicationScoped + @LookupIfProperty(name = "cluster.artifactStore", stringValue = "s3") + public FileStore fileStore( + @ConfigProperty(name = "cluster.s3.keyPrefix", defaultValue = "transient/") + String keyPrefix) { ApplicationProperties.Storage.S3 cfg = applicationProperties.getStorage().getS3(); S3Clients.Bundle bundle = S3Clients.build(cfg, "cluster file store"); // FileStore has no signed-URL contract; close the unused presigner immediately. @@ -34,4 +48,18 @@ public class S3FileStoreConfiguration { log.info("Cluster FileStore: s3 (bucket={}, keyPrefix={})", cfg.getBucket(), keyPrefix); return new S3FileStore(bundle.client(), cfg.getBucket(), keyPrefix, true); } + + /** + * Replaces the Spring {@code @Bean(destroyMethod = "close")} contract: CDI does not auto-invoke + * close() on producer-created beans, so this disposer closes the {@link S3FileStore} when the + * bean is destroyed. + */ + void closeFileStore(@Disposes FileStore fileStore) { + if (fileStore instanceof S3FileStore s3FileStore) { + try { + s3FileStore.close(); + } catch (Exception ignored) { + } + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ConditionalOnValkeyBackplane.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ConditionalOnValkeyBackplane.java index 4651cf6f27..1b4a5de7fa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ConditionalOnValkeyBackplane.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ConditionalOnValkeyBackplane.java @@ -5,15 +5,33 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import io.quarkus.arc.lookup.LookupIfProperty; /** * Composite condition: matches only when cluster.enabled=true AND cluster.backplane=valkey. Both * checks are required (enabled alone may select the in-process backplane, which must not load - * Valkey beans); a single {@code @ConditionalOnExpression} keeps the guard in one place. + * Valkey beans); a single guard keeps the condition in one place. + * + *

The original Spring annotation used a single + * {@code @ConditionalOnExpression("${cluster.enabled:false} and + * '${cluster.backplane:inprocess}'.equals('valkey')")} SpEL guard. Quarkus/CDI has no SpEL-based + * conditional, but the boolean AND of two simple property checks maps directly onto two stacked + * (repeatable) {@link LookupIfProperty} annotations, which are evaluated with AND semantics. The + * Valkey producer beans are looked up only when both properties hold; otherwise the + * {@code @DefaultBean} in-process implementations win. + * + *

TODO: Migration required - in Spring this was a composite meta-annotation: placing + * {@code @ConditionalOnValkeyBackplane} on a bean transitively applied the underlying + * {@code @ConditionalOnExpression}. Quarkus does NOT transitively propagate {@link LookupIfProperty} + * through a custom meta-annotation, so the two {@code @LookupIfProperty} guards below are documentary + * only - each consumer of this annotation (ValkeyClusterBackplane, ValkeyJobStore, + * ValkeyRateLimitStore, ValkeyDistributedLock, ValkeyKeyValueCache, ValkeyInstanceRegistry) must + * also carry the two {@code @LookupIfProperty} guards directly (or be produced via a producer method + * carrying them). Defaults: cluster.enabled defaults to false and cluster.backplane defaults to + * inprocess, so absent both properties the Valkey beans stay disabled. */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) -@ConditionalOnExpression( - "${cluster.enabled:false} and '${cluster.backplane:inprocess}'.equals('valkey')") +@LookupIfProperty(name = "cluster.enabled", stringValue = "true") +@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey") public @interface ConditionalOnValkeyBackplane {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyClusterBackplane.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyClusterBackplane.java index cf7bfb61c4..14e1b0724c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyClusterBackplane.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyClusterBackplane.java @@ -1,32 +1,47 @@ package stirling.software.proprietary.cluster.valkey; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.quarkus.redis.datasource.RedisDataSource; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.ClusterBackplane; import stirling.software.common.model.ApplicationProperties; @Slf4j -@Component -@RequiredArgsConstructor -@ConditionalOnValkeyBackplane +@ApplicationScoped +// TODO: Migration required - @ConditionalOnValkeyBackplane was a Spring @ConditionalOnExpression +// guard ("cluster.enabled=true AND cluster.backplane=valkey"). Quarkus has no runtime +// @Conditional for beans; this bean is now always instantiated. Gate selection at runtime +// (e.g. a ClusterBackplane producer that picks valkey vs in-process based on injected config), +// or use @io.quarkus.arc.lookup.LookupIfProperty(name="cluster.backplane", stringValue="valkey") +// (build-time/static only - does not also check cluster.enabled). The composite condition must be +// re-expressed accordingly. public class ValkeyClusterBackplane implements ClusterBackplane { - private final ApplicationProperties applicationProperties; - private final StringRedisTemplate template; + @Inject + ApplicationProperties applicationProperties; + + // TODO: Migration required - was Spring spring-data-redis StringRedisTemplate. Replaced with + // Quarkus RedisDataSource (io.quarkus.redis.datasource). Verify the redis client extension + // (quarkus-redis-client) is on the classpath and configured via quarkus.redis.* properties. + @Inject + RedisDataSource redisDataSource; @Override public boolean isHealthy() { try { - // template.execute() borrows from the pool and returns the connection in a finally - // block - critical because isHealthy() is hit on every k8s liveness/readiness probe - // tick. Calling getConnectionFactory().getConnection() directly leaks the connection - // and exhausts the pool under monitoring load. - String pong = template.execute((RedisCallback) connection -> connection.ping()); + // Original used template.execute() so the connection was borrowed from the pool and + // returned in a finally block - critical because isHealthy() is hit on every k8s + // liveness/readiness probe tick. Quarkus RedisDataSource manages connection + // pooling/return internally, so issuing a single command (PING) is the equivalent. + // TODO: Migration required - confirm command mapping. Quarkus exposes PING via the + // low-level command API: redisDataSource.execute("PING") returns a Response whose + // toString() is the simple-string reply "PONG". Validate this against the actual + // RedisDataSource API version in use. + String pong = redisDataSource.execute("PING").toString(); return "PONG".equalsIgnoreCase(pong); } catch (RuntimeException ex) { log.warn("Valkey backplane health check failed: {}", ex.getMessage()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyConnectionConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyConnectionConfiguration.java index ce121ec66d..03aeef1f3a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyConnectionConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyConnectionConfiguration.java @@ -4,10 +4,6 @@ import java.net.URI; import java.net.URISyntaxException; import java.time.Duration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.DependsOn; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisPassword; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; @@ -18,23 +14,55 @@ import org.springframework.data.redis.core.StringRedisTemplate; import io.lettuce.core.RedisCommandExecutionException; import io.lettuce.core.SslVerifyMode; +import io.quarkus.arc.lookup.LookupIfProperty; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Named; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.Cluster; +// TODO: Migration required - this class still depends on spring-data-redis types +// (LettuceConnectionFactory, StringRedisTemplate, RedisStandaloneConfiguration, +// LettuceClientConfiguration, RedisPassword, RedisConnection). Quarkus has no spring-data-redis; +// the backplane should be reworked onto io.quarkus.redis.datasource.RedisDataSource / +// ReactiveRedisDataSource configured via quarkus.redis.* in application.properties (hosts, password, +// tls, timeout=2s). The produced beans below are consumed by ValkeyClusterBackplane and the other +// Valkey* collaborators in this package; migrating this file requires migrating those consumers in +// lockstep, so the spring-data-redis imports are retained until that coordinated change lands. The +// pure URL-parsing / handshake / auth-detection helpers (parseUrl, buildClientConfiguration, +// eagerHandshake, isAuthFailure) are framework-agnostic and carry over unchanged. +// +// DI/config mapping applied here: +// @Configuration -> @ApplicationScoped (producer bean class) +// @Bean -> @Produces (+ @Named for the StringRedisTemplate) +// @ConditionalOnProperty(cluster.enabled)-> @LookupIfProperty(name="cluster.enabled", stringValue="true") +// @ConditionalOnProperty(backplane=valkey)-> @LookupIfProperty(name="cluster.backplane", stringValue="valkey") +// @DependsOn("clusterLicenseGate") -> TODO: ordering; ensure clusterLicenseGate runs first +// (CDI has no @DependsOn; use @Observes ordering or an +// explicit @Inject of the gate bean once migrated). +// @Bean(destroyMethod="destroy") -> @PreDestroy on the produced instance is not expressible +// on a @Produces method here; rely on factory.destroy() +// already wired via Spring's destroy lifecycle until the +// RedisDataSource migration removes this bean. TODO. @Slf4j -@Configuration +@ApplicationScoped @RequiredArgsConstructor -@ConditionalOnProperty(name = "cluster.enabled", havingValue = "true") -@DependsOn("clusterLicenseGate") +@LookupIfProperty(name = "cluster.enabled", stringValue = "true") public class ValkeyConnectionConfiguration { private final ApplicationProperties applicationProperties; - @Bean(destroyMethod = "destroy") - @ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey") + // TODO: Migration required - replace LettuceConnectionFactory with a configured + // io.quarkus.redis.datasource.RedisDataSource (quarkus.redis.* config). destroyMethod="destroy" + // has no @Produces equivalent without a @Disposes method; keep factory.destroy() lifecycle until + // the RedisDataSource migration. + @Produces + @LookupIfProperty(name = "cluster.backplane", stringValue = "valkey") public LettuceConnectionFactory valkeyConnectionFactory() { Cluster cluster = applicationProperties.getCluster(); Endpoint endpoint = parseUrl(cluster.getValkey().getUrl()); @@ -257,8 +285,12 @@ public class ValkeyConnectionConfiguration { return t.getMessage(); } - @Bean - @ConditionalOnProperty(name = "cluster.backplane", havingValue = "valkey") + // TODO: Migration required - StringRedisTemplate is spring-data-redis. Once the connection + // migrates to RedisDataSource, this producer should be removed and consumers should inject the + // Quarkus RedisDataSource (string commands via redisDataSource.value(String.class)) directly. + @Produces + @Named("valkeyTemplate") + @LookupIfProperty(name = "cluster.backplane", stringValue = "valkey") public StringRedisTemplate valkeyTemplate(LettuceConnectionFactory factory) { return new StringRedisTemplate(factory); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyDistributedLock.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyDistributedLock.java index 0a70391c03..146bc38e8a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyDistributedLock.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyDistributedLock.java @@ -8,16 +8,36 @@ import java.util.UUID; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; -import lombok.RequiredArgsConstructor; +import io.quarkus.arc.lookup.LookupIfProperty; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; + import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.DistributedLock; -@Component -@RequiredArgsConstructor -@ConditionalOnValkeyBackplane +// DI mapping applied here: +// @Component -> @ApplicationScoped +// @RequiredArgsConstructor -> explicit @Inject constructor (single injected collaborator) +// @ConditionalOnValkeyBackplane -> the two stacked @LookupIfProperty guards below (per the note in +// ConditionalOnValkeyBackplane: Quarkus does not transitively +// propagate @LookupIfProperty through the meta-annotation, so the +// guards are repeated directly on this consumer). +// +// TODO: Migration required - this class still depends on spring-data-redis types +// (StringRedisTemplate, RedisScript, DefaultRedisScript). Quarkus has no spring-data-redis; once +// ValkeyConnectionConfiguration migrates its producer onto io.quarkus.redis.datasource.RedisDataSource, +// this lock should be reworked to use RedisDataSource: SET NX PX for tryAcquire and EVAL of the +// release/renew Lua scripts (redisDataSource.execute("EVAL", script, "1", key, value[, ttlMillis])). +// The injected bean is the @Named("valkeyTemplate") StringRedisTemplate produced there, so this file +// and that producer must migrate in lockstep; the spring-data-redis imports are retained until then. +// The Lua scripts and the acquire/release/renew control flow are framework-agnostic and carry over. +@ApplicationScoped +@LookupIfProperty(name = "cluster.enabled", stringValue = "true") +@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey") @Slf4j public class ValkeyDistributedLock implements DistributedLock { @@ -35,6 +55,11 @@ public class ValkeyDistributedLock implements DistributedLock { private final StringRedisTemplate template; + @Inject + public ValkeyDistributedLock(@Named("valkeyTemplate") StringRedisTemplate template) { + this.template = template; + } + @Override public Optional tryAcquire(String lockKey, Duration leaseTime) { String key = PREFIX + lockKey; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyInstanceRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyInstanceRegistry.java index a8ff8d60ec..83788b0a3e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyInstanceRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyInstanceRegistry.java @@ -1,6 +1,5 @@ package stirling.software.proprietary.cluster.valkey; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -10,11 +9,14 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.ScanOptions; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.redis.datasource.hash.HashCommands; +import io.quarkus.redis.datasource.keys.KeyCommands; +import io.quarkus.redis.datasource.keys.KeyScanCursor; +import io.quarkus.redis.datasource.keys.ScanArgs; +import io.quarkus.redis.datasource.transactions.TransactionResult; + +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -25,14 +27,17 @@ import stirling.software.common.cluster.InstanceRegistry; * Valkey-backed {@link InstanceRegistry}. Each node is stored as a hash with a TTL equal to the * configured heartbeat TTL; the heartbeat re-arms the TTL. */ -@Component +// TODO: Migration required - the original @ConditionalOnValkeyBackplane (cluster.enabled=true AND +// cluster.backplane=valkey) was a runtime toggle. Quarkus build-time conditions (@IfBuildProfile / +// @LookupIfProperty) cannot express this composite runtime expression. Guard producer/usage at +// runtime via the Config values, or rework ConditionalOnValkeyBackplane into a CDI lookup guard. +@ApplicationScoped @RequiredArgsConstructor -@ConditionalOnValkeyBackplane public class ValkeyInstanceRegistry implements InstanceRegistry { private static final String PREFIX = "stirling:nodes:"; - private final StringRedisTemplate template; + private final RedisDataSource redis; @Override public void register(ClusterNode node, Duration heartbeatTtl) { @@ -47,22 +52,14 @@ public class ValkeyInstanceRegistry implements InstanceRegistry { // MULTI/EXEC so the hash fields and the TTL commit together. Without this, a crash // between HSET and EXPIRE leaves the hash with no TTL: it never expires, masks the // dead node as alive, and only a subsequent successful register() would re-arm it. - template.execute( - (RedisCallback) - connection -> { - connection.multi(); - byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); - Map hashBytes = new LinkedHashMap<>(); - for (Map.Entry f : fields.entrySet()) { - hashBytes.put( - f.getKey().getBytes(StandardCharsets.UTF_8), - f.getValue().getBytes(StandardCharsets.UTF_8)); - } - connection.hashCommands().hMSet(keyBytes, hashBytes); - connection.keyCommands().pExpire(keyBytes, ttlMs); - connection.exec(); - return null; + TransactionResult result = + redis.withTransaction( + tx -> { + tx.hash(String.class).hset(key, fields); + tx.key(String.class).pexpire(key, ttlMs); }); + // result.discarded() would be true if the transaction was aborted; the heartbeat will + // re-arm on the next register() so we do not fail hard here. } @Override @@ -72,11 +69,13 @@ public class ValkeyInstanceRegistry implements InstanceRegistry { @Override public Collection activeNodes() { - ScanOptions options = ScanOptions.scanOptions().match(PREFIX + "*").count(256).build(); List nodes = new ArrayList<>(); - try (Cursor cursor = template.scan(options)) { - while (cursor.hasNext()) { - readNode(cursor.next()).ifPresent(nodes::add); + KeyCommands keys = redis.key(String.class); + KeyScanCursor cursor = + keys.scan(new ScanArgs().match(PREFIX + "*").count(256)); + while (cursor.hasNext()) { + for (String key : cursor.next()) { + readNode(key).ifPresent(nodes::add); } } return nodes; @@ -84,32 +83,33 @@ public class ValkeyInstanceRegistry implements InstanceRegistry { @Override public void deregister(String nodeId) { - template.delete(PREFIX + nodeId); + redis.key(String.class).del(PREFIX + nodeId); } private Optional readNode(String key) { - Map entries = template.opsForHash().entries(key); + HashCommands hash = redis.hash(String.class); + Map entries = hash.hgetall(key); if (entries == null || entries.isEmpty()) { return Optional.empty(); } - Object nodeId = entries.get("nodeId"); + String nodeId = entries.get("nodeId"); if (nodeId == null) { return Optional.empty(); } Instant heartbeat = Instant.now(); - Object hb = entries.get("lastHeartbeat"); + String hb = entries.get("lastHeartbeat"); if (hb != null) { try { - heartbeat = Instant.parse(hb.toString()); + heartbeat = Instant.parse(hb); } catch (RuntimeException ignored) { // keep default } } return Optional.of( new ClusterNode( - nodeId.toString(), - String.valueOf(entries.getOrDefault("internalAddress", "")), + nodeId, + entries.getOrDefault("internalAddress", ""), heartbeat, - String.valueOf(entries.getOrDefault("role", "BOTH")))); + entries.getOrDefault("role", "BOTH"))); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 8e93871859..f4afc57bb1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -11,17 +11,22 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.ScanOptions; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.redis.datasource.hash.HashCommands; +import io.quarkus.redis.datasource.keys.KeyCommands; +import io.quarkus.redis.datasource.keys.KeyScanArgs; +import io.quarkus.redis.datasource.keys.KeyScanCursor; +import io.quarkus.redis.datasource.transactions.OptimisticLockingTransactionResult; +import io.quarkus.redis.datasource.transactions.TransactionResult; +import io.quarkus.redis.datasource.value.SetArgs; +import io.quarkus.redis.datasource.value.ValueCommands; + import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.JobStore; @@ -31,11 +36,16 @@ import stirling.software.common.cluster.JobStoreEntry; * Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId. * *

put() atomicity: the hash fields, the per-job TTL, and the reverse-index entries are - * issued inside a single pipelined Redis transaction (MULTI/EXEC). A partial failure cannot leave - * the hash without a TTL or with half the file→job index entries written. + * issued inside a single Redis transaction (MULTI/EXEC). A partial failure cannot leave the hash + * without a TTL or with half the file->job index entries written. */ -@Component -@RequiredArgsConstructor +// TODO: Migration required - @ConditionalOnValkeyBackplane (Spring @ConditionalOnExpression) is a +// runtime toggle on cluster.enabled + cluster.backplane=valkey. Quarkus has no direct equivalent +// for the composite expression; either reimplement ConditionalOnValkeyBackplane as a Quarkus +// build-time condition (@io.quarkus.arc.profile.IfBuildProfile / +// @io.quarkus.arc.lookup.LookupIfProperty) or guard bean activation at runtime. Annotation left in +// place pending that collaborator change. +@ApplicationScoped @ConditionalOnValkeyBackplane @Slf4j public class ValkeyJobStore implements JobStore { @@ -47,7 +57,21 @@ public class ValkeyJobStore implements JobStore { private static final TypeReference> LIST_STRING = new TypeReference<>() {}; private static final TypeReference> MAP_STRING = new TypeReference<>() {}; - private final StringRedisTemplate template; + // String-keyed, byte-valued command groups mirror the original byte-level access so JSON + // payloads and ids round-trip exactly as they did via StringRedisTemplate's byte commands. + private final RedisDataSource redis; + private final HashCommands hash; + private final ValueCommands value; + private final KeyCommands keys; + private final ValueCommands stringValue; + + public ValkeyJobStore(RedisDataSource redis) { + this.redis = redis; + this.hash = redis.hash(String.class, String.class, byte[].class); + this.value = redis.value(String.class, byte[].class); + this.keys = redis.key(String.class); + this.stringValue = redis.value(String.class, String.class); + } @Override public void put(JobStoreEntry entry, Duration ttl) { @@ -71,37 +95,30 @@ public class ValkeyJobStore implements JobStore { "resultMeta", writeJson(entry.resultMeta() == null ? Map.of() : entry.resultMeta())); - // Build pipelined MULTI/EXEC so the hash, its TTL, and every reverse-index entry - // commit atomically. - template.execute( - (RedisCallback) - connection -> { - connection.multi(); - byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); - Map hashBytes = new LinkedHashMap<>(); - for (Map.Entry f : fields.entrySet()) { - hashBytes.put( - f.getKey().getBytes(StandardCharsets.UTF_8), - f.getValue().getBytes(StandardCharsets.UTF_8)); - } - connection.hashCommands().hMSet(keyBytes, hashBytes); - connection.keyCommands().pExpire(keyBytes, ttlMs); - if (entry.fileIds() != null) { - for (String fileId : entry.fileIds()) { - byte[] idxKey = - (FILE_INDEX_PREFIX + fileId) - .getBytes(StandardCharsets.UTF_8); - connection - .stringCommands() - .set( - idxKey, - entry.jobId().getBytes(StandardCharsets.UTF_8)); - connection.keyCommands().pExpire(idxKey, ttlMs); - } - } - connection.exec(); - return null; - }); + Map hashBytes = new LinkedHashMap<>(); + for (Map.Entry f : fields.entrySet()) { + hashBytes.put(f.getKey(), f.getValue().getBytes(StandardCharsets.UTF_8)); + } + + // Build MULTI/EXEC so the hash, its TTL, and every reverse-index entry commit atomically. + // Quarkus' withTransaction enqueues commands issued on the transactional datasource between + // MULTI and EXEC. SetArgs.px(ttl) sets the value-with-TTL in one SET (the original issued a + // separate pExpire after SET); pexpire keeps the original two-step shape for the hash. + redis.withTransaction( + tx -> { + tx.hash(String.class, String.class, byte[].class).hset(key, hashBytes); + tx.key(String.class).pexpire(key, ttlMs); + if (entry.fileIds() != null) { + for (String fileId : entry.fileIds()) { + String idxKey = FILE_INDEX_PREFIX + fileId; + tx.value(String.class, byte[].class) + .set( + idxKey, + entry.jobId().getBytes(StandardCharsets.UTF_8), + new SetArgs().px(ttlMs)); + } + } + }); } @Override @@ -118,54 +135,40 @@ public class ValkeyJobStore implements JobStore { // case; further contention falls through to lazy TTL cleanup (acceptable - this is an // eviction path, not a correctness primitive). String jobKey = JOB_PREFIX + jobId; - byte[] jobKeyBytes = jobKey.getBytes(StandardCharsets.UTF_8); for (int attempt = 0; attempt < 2; attempt++) { - Boolean committed = - template.execute( - (RedisCallback) - connection -> { - connection.watch(jobKeyBytes); - // Read the single fileIds field with hGet rather than - // hGetAll + map.get: hGetAll returns a Map - // whose keys compare by identity, so a fresh - // "fileIds".getBytes() lookup never matches and the reverse - // index would be left orphaned. hGet resolves the field - // server-side. - byte[] fileIdsBytes = - connection - .hashCommands() - .hGet( - jobKeyBytes, - "fileIds" - .getBytes( - StandardCharsets - .UTF_8)); - List keysToDelete = new ArrayList<>(); - keysToDelete.add(jobKeyBytes); - if (fileIdsBytes != null) { - List fileIds = - readJsonList( - new String( - fileIdsBytes, - StandardCharsets.UTF_8), - jobKey); - for (String fileId : fileIds) { - keysToDelete.add( - (FILE_INDEX_PREFIX + fileId) - .getBytes(StandardCharsets.UTF_8)); - } - } - connection.multi(); - for (byte[] key : keysToDelete) { - connection.keyCommands().del(key); - } - List results = connection.exec(); - // exec() returns null when WATCH detected a concurrent - // write; spring-data-redis surfaces this as either null - // or empty depending on the driver path. - return results != null && !results.isEmpty(); - }); - if (Boolean.TRUE.equals(committed)) { + // withTransaction(preTxBlock, watchedKeys...): the preTxBlock runs after WATCH and + // before MULTI; its result feeds the transactional block. If a watched key changes + // before EXEC, Quarkus aborts and the result reports discarded() == true. + OptimisticLockingTransactionResult> result = + redis.withTransaction( + ds -> { + // Read the single fileIds field with hget rather than hgetall: + // resolve the field server-side and avoid byte[]-key identity + // pitfalls when looking it back up client-side. + byte[] fileIdsBytes = + ds.hash(String.class, String.class, byte[].class) + .hget(jobKey, "fileIds"); + if (fileIdsBytes == null) { + return List.of(); + } + return readJsonList( + new String(fileIdsBytes, StandardCharsets.UTF_8), jobKey); + }, + tx -> { + List fileIds = tx.getPreTransactionResult(); + List keysToDelete = new ArrayList<>(); + keysToDelete.add(jobKey); + for (String fileId : fileIds) { + keysToDelete.add(FILE_INDEX_PREFIX + fileId); + } + tx.key(String.class) + .del(keysToDelete.toArray(new String[0])); + }, + jobKey); + TransactionResult txResult = result.getExecutionResult(); + // EXEC returns null (discarded) when WATCH detected a concurrent write; Quarkus + // surfaces this as discarded() == true. + if (txResult != null && !txResult.discarded()) { return; } } @@ -177,34 +180,40 @@ public class ValkeyJobStore implements JobStore { @Override public boolean exists(String jobId) { - Boolean exists = template.hasKey(JOB_PREFIX + jobId); - return Boolean.TRUE.equals(exists); + return keys.exists(JOB_PREFIX + jobId); } @Override public Optional findJobIdByFileId(String fileId) { - return Optional.ofNullable(template.opsForValue().get(FILE_INDEX_PREFIX + fileId)); + return Optional.ofNullable(stringValue.get(FILE_INDEX_PREFIX + fileId)); } @Override public Collection all() { // SCAN, not KEYS - KEYS blocks the Valkey server for the duration of the walk. - ScanOptions options = ScanOptions.scanOptions().match(JOB_PREFIX + "*").count(256).build(); + KeyScanCursor cursor = + keys.scan(new KeyScanArgs().match(JOB_PREFIX + "*").count(256)); List result = new ArrayList<>(); - try (Cursor cursor = template.scan(options)) { - while (cursor.hasNext()) { - readEntry(cursor.next()).ifPresent(result::add); + while (cursor.hasNext()) { + for (String key : cursor.next()) { + readEntry(key).ifPresent(result::add); } } return result; } private Optional readEntry(String key) { - Map entries = template.opsForHash().entries(key); - if (entries == null || entries.isEmpty()) { + Map raw = hash.hgetall(key); + if (raw == null || raw.isEmpty()) { return Optional.empty(); } - Object jobId = entries.get("jobId"); + Map entries = new HashMap<>(); + for (Map.Entry e : raw.entrySet()) { + entries.put( + e.getKey(), + e.getValue() == null ? null : new String(e.getValue(), StandardCharsets.UTF_8)); + } + String jobId = entries.get("jobId"); if (jobId == null) { return Optional.empty(); } @@ -223,10 +232,10 @@ public class ValkeyJobStore implements JobStore { state = JobStoreEntry.JobState.PENDING; } String owningNodeId = String.valueOf(entries.getOrDefault("owningNodeId", "")); - String error = entries.get("error") == null ? null : entries.get("error").toString(); + String error = entries.get("error") == null ? null : entries.get("error"); return Optional.of( new JobStoreEntry( - jobId.toString(), + jobId, state, owningNodeId, createdAt, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyKeyValueCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyKeyValueCache.java index 034189a3c1..cebb964fcf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyKeyValueCache.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyKeyValueCache.java @@ -4,54 +4,60 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.concurrent.TimeUnit; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.ScanOptions; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.redis.datasource.keys.KeyScanArgs; +import io.quarkus.redis.datasource.keys.KeyScanCursor; +import io.quarkus.redis.datasource.value.SetArgs; +import io.quarkus.redis.datasource.value.ValueCommands; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; import stirling.software.common.cluster.KeyValueCache; -@Component -@RequiredArgsConstructor -@ConditionalOnValkeyBackplane +// TODO: Migration required - @ConditionalOnValkeyBackplane (a Spring @ConditionalOnExpression +// composite on cluster.enabled + cluster.backplane=valkey) has no direct CDI equivalent. Once that +// collaborator annotation is migrated, re-guard this bean (e.g. @io.quarkus.arc.lookup.LookupIfProperty +// or @io.quarkus.arc.profile.IfBuildProfile, or a runtime guard) so Valkey beans only load when +// cluster.enabled=true AND cluster.backplane=valkey. +@ApplicationScoped public class ValkeyKeyValueCache implements KeyValueCache { private static final String PREFIX = "stirling:kv:"; - private final StringRedisTemplate template; + private final RedisDataSource redis; + private final ValueCommands values; + + public ValkeyKeyValueCache(RedisDataSource redis) { + this.redis = redis; + this.values = redis.value(String.class, String.class); + } @Override public void put(String namespace, String key, String value, Duration ttl) { - template.opsForValue() - .set(buildKey(namespace, key), value, ttl.toMillis(), TimeUnit.MILLISECONDS); + values.set(buildKey(namespace, key), value, new SetArgs().px(ttl.toMillis())); } @Override public Optional get(String namespace, String key) { - return Optional.ofNullable(template.opsForValue().get(buildKey(namespace, key))); + return Optional.ofNullable(values.get(buildKey(namespace, key))); } @Override public void evict(String namespace, String key) { - template.delete(buildKey(namespace, key)); + redis.key(String.class).del(buildKey(namespace, key)); } @Override public void evictNamespace(String namespace) { - ScanOptions options = - ScanOptions.scanOptions().match(PREFIX + namespace + ":*").count(256).build(); + KeyScanArgs options = new KeyScanArgs().match(PREFIX + namespace + ":*").count(256); List keys = new ArrayList<>(); - try (Cursor cursor = template.scan(options)) { - while (cursor.hasNext()) { - keys.add(cursor.next()); - } + KeyScanCursor cursor = redis.key(String.class).scan(options); + while (cursor.hasNext()) { + keys.addAll(cursor.next()); } if (!keys.isEmpty()) { - template.delete(keys); + redis.key(String.class).del(keys.toArray(new String[0])); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyRateLimitStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyRateLimitStore.java index 0adda83c19..4a96aec88d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyRateLimitStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyRateLimitStore.java @@ -3,8 +3,13 @@ package stirling.software.proprietary.cluster.valkey; import java.nio.charset.StandardCharsets; import java.time.Duration; +// TODO: Migration required - LettuceConnectionFactory is a spring-data-redis type produced by the +// not-yet-migrated ValkeyConnectionConfiguration collaborator. This store only needs the raw +// io.lettuce.core.RedisClient that Bucket4j's Lettuce ProxyManager builds on. Once +// ValkeyConnectionConfiguration is migrated to a Quarkus producer, switch this injection point to a +// produced io.lettuce.core.RedisClient (or io.quarkus.redis.datasource.RedisDataSource) and delete +// the getNativeClient() unwrap in initProxyManager(). Kept for now so the Bucket4j logic stays intact. import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.stereotype.Component; import io.github.bucket4j.BucketConfiguration; import io.github.bucket4j.ConsumptionProbe; @@ -14,9 +19,12 @@ import io.github.bucket4j.distributed.proxy.ProxyManager; import io.github.bucket4j.redis.lettuce.Bucket4jLettuce; import io.lettuce.core.AbstractRedisClient; import io.lettuce.core.RedisClient; +import io.quarkus.arc.lookup.LookupIfProperty; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import stirling.software.common.cluster.RateLimitStore; @@ -25,8 +33,13 @@ import stirling.software.common.cluster.RateLimitStore; * refills continuously and enforces one global limit across nodes, with the same semantics as the * in-process {@code InProcessRateLimitStore} (which also uses Bucket4j). */ -@Component +// @ConditionalOnValkeyBackplane is documentary only under CDI (see that annotation's javadoc); +// the two guards below must be carried directly so the Valkey beans load only when +// cluster.enabled=true AND cluster.backplane=valkey, otherwise the in-process @DefaultBean wins. +@ApplicationScoped @ConditionalOnValkeyBackplane +@LookupIfProperty(name = "cluster.enabled", stringValue = "true") +@LookupIfProperty(name = "cluster.backplane", stringValue = "valkey") public class ValkeyRateLimitStore implements RateLimitStore { private static final String PREFIX = "stirling:rl:"; @@ -34,6 +47,7 @@ public class ValkeyRateLimitStore implements RateLimitStore { private final LettuceConnectionFactory connectionFactory; private ProxyManager proxyManager; + @Inject public ValkeyRateLimitStore(LettuceConnectionFactory connectionFactory) { this.connectionFactory = connectionFactory; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java index ea096a8d23..fb8ec01209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java @@ -4,58 +4,60 @@ import java.util.Map; import java.util.concurrent.Executor; import java.util.concurrent.Executors; -import org.slf4j.MDC; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.task.TaskDecorator; -import org.springframework.core.task.support.TaskExecutorAdapter; -import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.security.concurrent.DelegatingSecurityContextExecutor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Named; -@Configuration -@EnableAsync +import org.slf4j.MDC; + +@ApplicationScoped public class AsyncConfig { /** - * MDC context-propagating task decorator. Copies MDC context from the caller thread to the - * virtual thread executing the task. + * Wraps a delegate {@link Executor} so that the caller thread's MDC context is propagated to the + * worker (virtual) thread executing the task, then cleared afterwards to avoid leaks. */ - static class MDCContextTaskDecorator implements TaskDecorator { - @Override - public Runnable decorate(Runnable runnable) { - // Capture the MDC context from the current thread + static Executor mdcPropagating(Executor delegate) { + return command -> { + // Capture the MDC context from the current (caller) thread Map contextMap = MDC.getCopyOfContextMap(); - return () -> { - try { - // Set the captured context on the worker thread - if (contextMap != null) { - MDC.setContextMap(contextMap); - } - // Execute the task - runnable.run(); - } finally { - // Clear the context to prevent memory leaks - MDC.clear(); - } - }; - } + delegate.execute( + () -> { + try { + // Set the captured context on the worker thread + if (contextMap != null) { + MDC.setContextMap(contextMap); + } + // Execute the task + command.run(); + } finally { + // Clear the context to prevent memory leaks + MDC.clear(); + } + }); + }; } - @Bean(name = "auditExecutor") + @Produces + @Named("auditExecutor") + @ApplicationScoped public Executor auditExecutor() { - TaskExecutorAdapter adapter = - new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); - adapter.setTaskDecorator(new MDCContextTaskDecorator()); - return adapter; + return mdcPropagating(Executors.newVirtualThreadPerTaskExecutor()); } /** Propagates the request's SecurityContext onto background AI-orchestration threads. */ - @Bean(name = "aiStreamExecutor") + @Produces + @Named("aiStreamExecutor") + @ApplicationScoped public Executor aiStreamExecutor() { - TaskExecutorAdapter adapter = - new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); - adapter.setTaskDecorator(new MDCContextTaskDecorator()); - return new DelegatingSecurityContextExecutor(adapter); + // TODO: Migration required - this previously wrapped the executor in Spring Security's + // DelegatingSecurityContextExecutor to propagate the SecurityContext onto background + // threads. Quarkus has no direct equivalent; the SecurityIdentity must be captured on the + // caller thread and re-established on the worker thread (e.g. via a captured + // io.quarkus.security.identity.SecurityIdentity or + // org.eclipse.microprofile.context.ThreadContext from MicroProfile Context Propagation). + // For now only MDC context is propagated; security context propagation is NOT preserved. + return mdcPropagating(Executors.newVirtualThreadPerTaskExecutor()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java index 366d91b11c..95cf86a1e4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -1,8 +1,7 @@ package stirling.software.proprietary.config; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -14,10 +13,12 @@ import stirling.software.proprietary.audit.AuditLevel; * Configuration properties for the audit system. Reads values from the ApplicationProperties under * premium.enterpriseFeatures.audit */ +// TODO: Migration required - Spring @Order(HIGHEST_PRECEDENCE + 10) had no direct CDI +// equivalent; bean ordering/precedence must be handled via @Priority or explicit ordering at +// injection points if it was relied upon. @Slf4j @Getter -@Component -@Order(Ordered.HIGHEST_PRECEDENCE + 10) +@ApplicationScoped public class AuditConfigurationProperties { private final boolean enabled; @@ -27,6 +28,7 @@ public class AuditConfigurationProperties { private final boolean capturePdfAuthor; private final boolean captureOperationResults; + @Inject public AuditConfigurationProperties(ApplicationProperties applicationProperties) { ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig = applicationProperties.getPremium().getEnterpriseFeatures().getAudit(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java index 1ff8f4eb96..51bc58e2a5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java @@ -1,12 +1,12 @@ package stirling.software.proprietary.config; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.annotation.EnableTransactionManagement; +import jakarta.enterprise.context.ApplicationScoped; /** Configuration for audit system transaction management. */ -@Configuration -@EnableTransactionManagement -public class AuditJpaConfig { - // Scheduling is enabled on SPDFApplication — no duplicate @EnableScheduling needed. - // JPA repositories are now managed by DatabaseConfig to avoid conflicts. -} +// TODO: Migration required - Quarkus enables transaction management automatically +// (Narayana/JTA via quarkus-narayana-jta); the Spring @EnableTransactionManagement is +// not needed. Use jakarta.transaction.@Transactional on methods/beans as required. +// Scheduling is enabled on the application — no duplicate @EnableScheduling needed. +// JPA repositories are auto-discovered by Quarkus (no @EnableJpaRepositories needed). +@ApplicationScoped +public class AuditJpaConfig {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java index 1dd8d5f297..03addc2576 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java @@ -4,13 +4,9 @@ import java.time.Instant; import java.util.List; import java.util.Map; +import jakarta.enterprise.context.ApplicationScoped; + import org.slf4j.MDC; -import org.springframework.boot.actuate.audit.AuditEvent; -import org.springframework.boot.actuate.audit.AuditEventRepository; -import org.springframework.context.annotation.Primary; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.util.CollectionUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,30 +17,32 @@ import stirling.software.proprietary.util.SecretMasker; import tools.jackson.databind.ObjectMapper; -@Component -@Primary +// TODO: Migration required - this class implemented Spring Boot Actuator's +// org.springframework.boot.actuate.audit.AuditEventRepository (with @Primary). Quarkus has no +// Actuator equivalent, so the interface and the org.springframework.boot.actuate.audit.AuditEvent +// type are gone. The write side has been ported to a plain CDI bean that accepts the audit data +// directly (see add(...) below). Whatever Spring code previously published AuditEvents to this +// repository must be updated to call this bean's add(...) method (or an equivalent producer) once +// the audit-publishing pipeline is migrated. The read-side find(...) was intentionally inert +// (endpoint disabled) and has been dropped. +@ApplicationScoped @RequiredArgsConstructor @Slf4j -public class CustomAuditEventRepository implements AuditEventRepository { +public class CustomAuditEventRepository { private final PersistentAuditEventRepository repo; private final ObjectMapper mapper; - /* ── READ side intentionally inert (endpoint disabled) ── */ - @Override - public List find(String p, Instant after, String type) { - return List.of(); - } - - /* ── WRITE side (async) ───────────────────────────────── */ - @Async("auditExecutor") - @Override - public void add(AuditEvent ev) { + /* ── WRITE side ───────────────────────────────────────── */ + // TODO: Migration required - was @Async("auditExecutor") (Spring async executor). Quarkus has + // no @Async; run this off the request thread via a managed executor (e.g. inject + // org.eclipse.microprofile.context.ManagedExecutor and submit, or annotate with + // @io.smallrye.common.annotation.Blocking on a reactive path). Logic is kept synchronous for + // now to avoid changing behavior incorrectly. + public void add(String principal, String type, Instant timestamp, Map data) { try { Map clean = - CollectionUtils.isEmpty(ev.getData()) - ? Map.of() - : SecretMasker.mask(ev.getData()); + (data == null || data.isEmpty()) ? Map.of() : SecretMasker.mask(data); if (clean.isEmpty() || (clean.size() == 1 && clean.containsKey("details"))) { return; @@ -61,17 +59,24 @@ public class CustomAuditEventRepository implements AuditEventRepository { PersistentAuditEvent ent = PersistentAuditEvent.builder() - .principal(ev.getPrincipal()) - .type(ev.getType()) + .principal(principal) + .type(type) .data(auditEventData) - .timestamp(ev.getTimestamp()) + .timestamp(timestamp) .build(); + // TODO: Migration required - repo.save(...) depends on PersistentAuditEventRepository + // being migrated to a Quarkus PanacheRepository (save -> persist). Update this call + // once that collaborator is converted. repo.save(ent); } catch (Exception e) { - log.error( - "Failed to persist audit event (fail-open); principal={}", - ev.getPrincipal(), - e); + log.error("Failed to persist audit event (fail-open); principal={}", principal, e); } } + + /* ── READ side intentionally inert (endpoint disabled) ── + * Original find(String, Instant, String) returned List.of(); the Actuator read endpoint was + * disabled. Re-add a typed read method here if an audit-query endpoint is reintroduced. */ + public List find() { + return List.of(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java index 6e82d1d994..11d4a6c7c6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java @@ -1,23 +1,23 @@ package stirling.software.proprietary.configuration; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import io.quarkus.runtime.StartupEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.service.ServerCertificateServiceInterface; -@Component +@ApplicationScoped @RequiredArgsConstructor @Slf4j public class ServerCertificateInitializer { private final ServerCertificateServiceInterface serverCertificateService; - @EventListener(ApplicationReadyEvent.class) - public void initializeServerCertificate() { + public void initializeServerCertificate(@Observes StartupEvent event) { try { serverCertificateService.initializeServerCertificate(); } catch (Exception e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java index ef941c603f..91567d81ae 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java @@ -2,12 +2,12 @@ package stirling.software.proprietary.controller.api; import java.util.Map; -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 jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,11 +23,11 @@ import stirling.software.common.service.TaskManager; * Admin controller for job management. These endpoints require admin privileges and provide insight * into system jobs and queues. */ -@RestController +@ApplicationScoped +@Path("/api/v1/admin") @RequiredArgsConstructor @Slf4j -@RequestMapping("/api/v1/admin") -@PreAuthorize("hasRole('ADMIN')") +@RolesAllowed("ADMIN") @Tag(name = "Admin Job Management", description = "Admin-only Job Management APIs") public class AdminJobController { @@ -39,16 +39,17 @@ public class AdminJobController { * * @return Job statistics */ - @GetMapping("/job/stats") + @GET + @Path("/job/stats") @Operation(summary = "Get job statistics") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity getJobStats() { + @RolesAllowed("ADMIN") + public Response getJobStats() { JobStats stats = taskManager.getJobStats(); log.info( "Admin requested job stats: {} active, {} completed jobs", stats.getActiveJobs(), stats.getCompletedJobs()); - return ResponseEntity.ok(stats); + return Response.ok(stats).build(); } /** @@ -56,13 +57,14 @@ public class AdminJobController { * * @return Queue statistics */ - @GetMapping("/job/queue/stats") + @GET + @Path("/job/queue/stats") @Operation(summary = "Get job queue statistics") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity getQueueStats() { + @RolesAllowed("ADMIN") + public Response getQueueStats() { Map queueStats = jobQueue.getQueueStats(); log.info("Admin requested queue stats: {} queued jobs", queueStats.get("queuedJobs")); - return ResponseEntity.ok(queueStats); + return Response.ok(queueStats).build(); } /** @@ -70,10 +72,11 @@ public class AdminJobController { * * @return A response indicating how many jobs were cleaned up */ - @PostMapping("/job/cleanup") + @POST + @Path("/job/cleanup") @Operation(summary = "Cleanup old jobs") - @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity cleanupOldJobs() { + @RolesAllowed("ADMIN") + public Response cleanupOldJobs() { int beforeCount = taskManager.getJobStats().getTotalJobs(); taskManager.cleanupOldJobs(); int afterCount = taskManager.getJobStats().getTotalJobs(); @@ -84,10 +87,11 @@ public class AdminJobController { removedCount, afterCount); - return ResponseEntity.ok( - Map.of( - "message", "Cleanup complete", - "removedJobs", removedCount, - "remainingJobs", afterCount)); + return Response.ok( + Map.of( + "message", "Cleanup complete", + "removedJobs", removedCount, + "remainingJobs", afterCount)) + .build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java index a96b3d1240..d57837a0bb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java @@ -5,26 +5,28 @@ import java.util.List; import java.util.Map; import java.util.concurrent.Executor; -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.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.eclipse.microprofile.config.inject.ConfigProperty; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Named; import jakarta.validation.Valid; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.sse.OutboundSseEvent; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; import lombok.extern.slf4j.Slf4j; @@ -47,8 +49,8 @@ import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ObjectNode; @Slf4j -@RestController -@RequestMapping("/api/v1/ai") +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/ai") @Hidden @Tag(name = "AI Engine", description = "Endpoints for AI-powered PDF workflows") public class AiEngineController { @@ -60,25 +62,31 @@ public class AiEngineController { private final TaskManager taskManager; private final JobOwnershipService jobOwnershipService; private final AiEngineEndpointResolver endpointResolver; - private final UserServiceInterface userService; + private final Instance userService; /** * SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a * 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the * executor. Configurable via {@code stirling.ai.streamTimeoutMs}. + * + *

TODO: Migration required - the JAX-RS SSE API has no per-emitter timeout equivalent to + * Spring's {@code SseEmitter} constructor argument. Enforce this timeout against the background + * orchestration task (e.g. a scheduled cancellation / Future.get with timeout) if a hard cap is + * required; for now it only drives the timeout error frame's wording. */ - @Value("${stirling.ai.streamTimeoutMs:1800000}") - private long streamTimeoutMs; + @ConfigProperty(name = "stirling.ai.streamTimeoutMs", defaultValue = "1800000") + long streamTimeoutMs; + @Inject public AiEngineController( AiEngineClient aiEngineClient, AiWorkflowService aiWorkflowService, ObjectMapper objectMapper, - @Qualifier("aiStreamExecutor") Executor aiStreamExecutor, + @Named("aiStreamExecutor") Executor aiStreamExecutor, TaskManager taskManager, JobOwnershipService jobOwnershipService, AiEngineEndpointResolver endpointResolver, - @Autowired(required = false) UserServiceInterface userService) { + Instance userService) { this.aiEngineClient = aiEngineClient; this.aiWorkflowService = aiWorkflowService; this.objectMapper = objectMapper; @@ -90,71 +98,70 @@ public class AiEngineController { } private String currentUserId() { - return userService != null ? userService.getCurrentUsername() : null; + return userService.isResolvable() ? userService.get().getCurrentUsername() : null; } - @GetMapping("/health") + @GET + @jakarta.ws.rs.Path("/health") @Operation( summary = "AI engine health check", description = "Returns the health status of the AI engine including configured models") - public ResponseEntity health() throws IOException { + public Response health() throws IOException { String response = aiEngineClient.get("/health", currentUserId()); - return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response); + return Response.ok(response, MediaType.APPLICATION_JSON).build(); } - @PostMapping(value = "/orchestrate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @jakarta.ws.rs.Path("/orchestrate") + @Consumes(MediaType.MULTIPART_FORM_DATA) @Operation( summary = "Run an AI workflow against a PDF", description = "Accepts PDF uploads and a user message and returns an AI workflow result." + " When the workflow produces files, they are registered with the job" + " system and downloadable via GET /api/v1/general/files/{fileId}.") - public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request) + // TODO: Migration required - @BeanParam multipart binding depends on collaborator changes: + // AiWorkflowRequest / AiWorkflowFileInput must have their multipart fields annotated with + // @org.jboss.resteasy.reactive.RestForm and the nested AiWorkflowFileInput.fileInput must be + // ported off Spring's MultipartFile to FileUpload + FileUploadMultipartFile.of(...). Until then + // RESTEasy Reactive cannot populate this request from the multipart form body. + public AiWorkflowResponse orchestrate(@Valid @BeanParam AiWorkflowRequest request) throws IOException { AiWorkflowResponse result = aiWorkflowService.orchestrate(request); registerFileResultAsJob(result); return result; } - @PostMapping(value = "/orchestrate/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @jakarta.ws.rs.Path("/orchestrate/stream") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @org.jboss.resteasy.reactive.RestStreamElementType(MediaType.APPLICATION_JSON) @Operation( summary = "Run an AI workflow with streaming progress", description = "Accepts a PDF upload and a user message, returns SSE events with progress" + " updates followed by the final AI workflow result") - public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) { - SseEmitter emitter = new SseEmitter(streamTimeoutMs); - - emitter.onTimeout( - () -> { - // Emit an explicit error frame so the frontend reports a timeout rather than - // silently seeing the stream end without a result. - log.warn( - "SSE emitter timed out for AI orchestration stream after {} ms", - streamTimeoutMs); - sendEvent( - emitter, - "error", - Map.of( - "message", - "AI workflow timed out after " - + (streamTimeoutMs / 1000) - + " seconds")); - emitter.complete(); - }); - emitter.onError(e -> log.warn("SSE emitter error for AI orchestration stream", e)); - - aiStreamExecutor.execute(() -> runOrchestrationStream(request, emitter)); - - return emitter; + // TODO: Migration required - same @BeanParam multipart binding dependency as orchestrate(): + // AiWorkflowRequest / AiWorkflowFileInput need @RestForm fields and a FileUpload-based file + // model before RESTEasy Reactive can bind this request from the multipart body. + public void orchestrateStream( + @Valid @BeanParam AiWorkflowRequest request, + @Context Sse sse, + @Context SseEventSink sink) { + // The JAX-RS SseEventSink replaces Spring's SseEmitter. There is no onTimeout/onError + // callback registration; sink.send(...) returns a CompletionStage and a disconnected + // client surfaces as a failed send / closed sink, which the orchestration loop detects + // via ClientDisconnectedException below. + aiStreamExecutor.execute(() -> runOrchestrationStream(request, sse, sink)); } - private void runOrchestrationStream(AiWorkflowRequest request, SseEmitter emitter) { + private void runOrchestrationStream( + AiWorkflowRequest request, Sse sse, SseEventSink sink) { AiWorkflowService.ProgressListener listener = new AiWorkflowService.ProgressListener() { @Override public void onProgress(AiWorkflowProgressEvent event) { - sendEvent(emitter, "progress", event); + sendEvent(sse, sink, "progress", event); } @Override @@ -163,26 +170,27 @@ public class AiEngineController { // real progress events; if the frontend has gone away, sendEvent throws, // which propagates up through the stream consumer and closes our upstream // engine connection so the engine can cancel its in-flight workflow. - sendEvent(emitter, "heartbeat", Map.of()); + sendEvent(sse, sink, "heartbeat", Map.of()); } }; try { AiWorkflowResponse result = aiWorkflowService.orchestrate(request, listener); registerFileResultAsJob(result); - sendEvent(emitter, "result", result); - emitter.complete(); + sendEvent(sse, sink, "result", result); + sink.close(); } catch (ClientDisconnectedException e) { // The frontend gave up mid-stream. The exception unwinding through orchestrate() // already closed the upstream engine connection (engine sees disconnect and cancels). - // The emitter is already toast; nothing useful left to send. + // The sink is already toast; nothing useful left to send. log.debug("Client disconnected mid-stream; aborting workflow", e); } catch (Exception e) { log.error("AI orchestration stream failed", e); - // Emit an error frame for the frontend and then complete normally. Using - // completeWithError here as well would double-complete the emitter - the error + // Emit an error frame for the frontend and then complete normally. The error // frame already conveys the failure to the client. - sendEvent(emitter, "error", Map.of("message", e.getMessage())); - emitter.complete(); + sendEvent(sse, sink, "error", Map.of("message", e.getMessage())); + if (!sink.isClosed()) { + sink.close(); + } } } @@ -217,21 +225,30 @@ public class AiEngineController { taskManager.setComplete(jobKey); } - private void sendEvent(SseEmitter emitter, String name, Object data) { + private void sendEvent(Sse sse, SseEventSink sink, String name, Object data) { + if (sink.isClosed()) { + throw new ClientDisconnectedException("Client disconnected from SSE stream", null); + } + OutboundSseEvent event = + sse.newEventBuilder() + .name(name) + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(data) + .build(); try { - emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON)); - } catch (IOException e) { - // Surface the disconnect so the streaming pipeline unwinds: callers higher up close - // the upstream engine connection, which lets the engine cancel its in-flight workflow. - // Without this, the engine would keep producing (and billing for) tokens whose results - // nobody is reading. + // CompletionStage join surfaces a delivery failure (client gone) synchronously so the + // streaming pipeline unwinds: callers higher up close the upstream engine connection, + // which lets the engine cancel its in-flight workflow. Without this, the engine would + // keep producing (and billing for) tokens whose results nobody is reading. + sink.send(event).toCompletableFuture().join(); + } catch (RuntimeException e) { throw new ClientDisconnectedException("Client disconnected from SSE stream", e); } } /** - * Thrown by {@link #sendEvent} when the SSE emitter's underlying connection is gone. Treated as - * a signal to abort the workflow, not as an error to report. + * Thrown by {@link #sendEvent} when the SSE sink's underlying connection is gone. Treated as a + * signal to abort the workflow, not as an error to report. */ private static final class ClientDisconnectedException extends RuntimeException { ClientDisconnectedException(String message, Throwable cause) { @@ -239,29 +256,31 @@ public class AiEngineController { } } - @PostMapping(value = "/pdf/edit", consumes = MediaType.APPLICATION_JSON_VALUE) + @POST + @jakarta.ws.rs.Path("/pdf/edit") + @Consumes(MediaType.APPLICATION_JSON) @Operation( summary = "Generate a PDF edit plan", description = "Sends a user message to the PDF edit agent which returns a structured plan" + " of tool operations to perform") - public ResponseEntity pdfEdit(@RequestBody String requestBody) throws IOException { + public Response pdfEdit(String requestBody) throws IOException { JsonNode parsed = parseJson(requestBody); if (!parsed.isObject()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Request body must be a JSON object"); + throw new WebApplicationException( + "Request body must be a JSON object", Response.Status.BAD_REQUEST); } String forwardedBody = withEnabledEndpoints((ObjectNode) parsed); String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody, currentUserId()); - return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response); + return Response.ok(response, MediaType.APPLICATION_JSON).build(); } private JsonNode parseJson(String body) { try { return objectMapper.readValue(body, JsonNode.class); } catch (JacksonException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Request body is not valid JSON"); + throw new WebApplicationException( + "Request body is not valid JSON", Response.Status.BAD_REQUEST); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java index 837c28bf5a..582e520312 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java @@ -13,21 +13,17 @@ 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; -import org.springframework.data.domain.Sort; -import org.springframework.format.annotation.DateTimeFormat; -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.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.RestController; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; @@ -36,6 +32,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; + import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.model.api.audit.AuditDataRequest; import stirling.software.proprietary.model.api.audit.AuditDataResponse; @@ -50,9 +49,9 @@ import tools.jackson.databind.ObjectMapper; /** REST endpoints for the audit dashboard. */ @Slf4j -@RestController -@RequestMapping("/api/v1/audit") -@PreAuthorize("hasRole('ADMIN')") +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/audit") +@RolesAllowed("ADMIN") @RequiredArgsConstructor @EnterpriseEndpoint @Tag(name = "Audit", description = "Only Enterprise - Audit related operations") @@ -62,14 +61,20 @@ public class AuditDashboardController { private final ObjectMapper objectMapper; /** Get audit events data for the dashboard tables. */ - @GetMapping("/data") + @GET + @jakarta.ws.rs.Path("/data") @Operation(summary = "Get audit events data") - public AuditDataResponse getAuditData(@ParameterObject AuditDataRequest request) { + public AuditDataResponse getAuditData(@BeanParam AuditDataRequest request) { - Pageable pageable = - PageRequest.of( - request.getPage(), request.getSize(), Sort.by("timestamp").descending()); - Page events; + // TODO: Migration required - PersistentAuditEventRepository is a collaborator that must be + // migrated to io.quarkus.hibernate.orm.panache.PanacheRepositoryBase. Its paged finders should return io.quarkus.panache.common.PanacheQuery (or apply + // the Page/Sort built here) instead of org.springframework.data.domain.Page. The pagination + // request below is expressed with Panache Page/Sort; once the repository accepts these the + // .page(...)/.list()/.count()/.pageCount() calls used here will resolve. + Page page = Page.of(request.getPage(), request.getSize()); + Sort sort = Sort.by("timestamp", Sort.Direction.Descending); + io.quarkus.hibernate.orm.panache.PanacheQuery query; String type = request.getType(); String principal = request.getPrincipal(); @@ -79,49 +84,52 @@ public class AuditDashboardController { 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 = + query = auditRepository.findByPrincipalAndTypeAndTimestampBetween( - principal, type, start, end, pageable); + principal, type, start, end, page, sort); } else if (type != null && principal != null) { - events = auditRepository.findByPrincipalAndType(principal, type, pageable); + query = auditRepository.findByPrincipalAndType(principal, type, page, sort); } 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.findByTypeAndTimestampBetween(type, start, end, pageable); + query = auditRepository.findByTypeAndTimestampBetween(type, start, end, page, sort); } 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 = + query = auditRepository.findByPrincipalAndTimestampBetween( - principal, start, end, pageable); + principal, start, end, page, sort); } else if (startDate != null && endDate != null) { Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = auditRepository.findByTimestampBetween(start, end, pageable); + query = auditRepository.findByTimestampBetween(start, end, page, sort); } else if (type != null) { - events = auditRepository.findByType(type, pageable); + query = auditRepository.findByType(type, page, sort); } else if (principal != null) { - events = auditRepository.findByPrincipal(principal, pageable); + query = auditRepository.findByPrincipal(principal, page, sort); } else { - events = auditRepository.findAll(pageable); + query = auditRepository.findAll(sort).page(page); } // Logging - List content = events.getContent(); + List content = query.list(); return new AuditDataResponse( - content, events.getTotalPages(), events.getTotalElements(), events.getNumber()); + content, query.pageCount(), query.count(), query.page().index); } /** Get statistics for charts (last X days). Existing behavior preserved. */ - @GetMapping("/stats") + @GET + @jakarta.ws.rs.Path("/stats") + @Produces(MediaType.APPLICATION_JSON) @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", requiredMode = Schema.RequiredMode.REQUIRED) - @RequestParam(value = "days", defaultValue = "7") + @QueryParam("days") + @DefaultValue("7") int days) { // Get events from the last X days @@ -158,9 +166,10 @@ public class AuditDashboardController { } // /** Advanced statistics using repository aggregations, with explicit date range. */ - // @GetMapping("/stats/range") + // @GET + // @Path("/stats/range") // @Operation(summary = "Get audit statistics for a date range (aggregated in DB)") - // public Map getAuditStatsRange(@ParameterObject AuditDateExportRequest + // public Map getAuditStatsRange(@BeanParam AuditDateExportRequest // request) { // LocalDate startDate = request.getStartDate(); @@ -199,7 +208,9 @@ public class AuditDashboardController { // } /** Get all unique event types from the database for filtering. */ - @GetMapping("/types") + @GET + @jakarta.ws.rs.Path("/types") + @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Get all unique audit event types") public List getAuditTypes() { // Get distinct event types from the database @@ -220,9 +231,10 @@ public class AuditDashboardController { } /** Export audit data as CSV. */ - @GetMapping("/export/csv") + @GET + @jakarta.ws.rs.Path("/export/csv") @Operation(summary = "Export audit data as CSV") - public ResponseEntity exportAuditData(@ParameterObject AuditExportRequest request) { + public Response exportAuditData(@BeanParam AuditExportRequest request) { List events = getAuditEventsByCriteria(request); @@ -243,17 +255,19 @@ public class AuditDashboardController { byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8); // Set up HTTP headers for download - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); - headers.setContentDispositionFormData("attachment", "audit_export.csv"); - - return ResponseEntity.ok().headers(headers).body(csvBytes); + return Response.ok(csvBytes) + .type(MediaType.APPLICATION_OCTET_STREAM) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "form-data; name=\"attachment\"; filename=\"audit_export.csv\"") + .build(); } /** Export audit data as JSON. */ - @GetMapping("/export/json") + @GET + @jakarta.ws.rs.Path("/export/json") @Operation(summary = "Export audit data as JSON") - public ResponseEntity exportAuditDataJson(@ParameterObject AuditExportRequest request) { + public Response exportAuditDataJson(@BeanParam AuditExportRequest request) { List events = getAuditEventsByCriteria(request); @@ -262,66 +276,71 @@ public class AuditDashboardController { 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); + return Response.ok(jsonBytes) + .type(MediaType.APPLICATION_JSON) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "form-data; name=\"attachment\"; filename=\"audit_export.json\"") + .build(); } catch (JacksonException e) { log.error("Error serializing audit events to JSON", e); - return ResponseEntity.internalServerError().build(); + return Response.serverError().build(); } } // /** Get all unique principals. */ - // @GetMapping("/principals") + // @GET + // @Path("/principals") // @Operation(summary = "Get all distinct principals") // public List getPrincipals() { // return auditRepository.findDistinctPrincipals(); // } // /** Get principals by event type. */ - // @GetMapping("/types/{type}/principals") + // @GET + // @Path("/types/{type}/principals") // @Operation(summary = "Get distinct principals for a given type") - // public List getPrincipalsByType(@PathVariable("type") String type) { + // public List getPrincipalsByType(@PathParam("type") String type) { // return auditRepository.findDistinctPrincipalsByType(type); // } // /** Latest helpers */ - // @GetMapping("/latest") + // @GET + // @Path("/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) { + // public Response getLatest( + // @QueryParam("type") String type, + // @QueryParam("principal") String principal) { // if (type != null) { // return auditRepository // .findTopByTypeOrderByTimestampDesc(type) - // .map(ResponseEntity::ok) - // .orElse(ResponseEntity.noContent().build()); + // .map(e -> Response.ok(e).build()) + // .orElse(Response.noContent().build()); // } else if (principal != null) { // return auditRepository // .findTopByPrincipalOrderByTimestampDesc(principal) - // .map(ResponseEntity::ok) - // .orElse(ResponseEntity.noContent().build()); + // .map(e -> Response.ok(e).build()) + // .orElse(Response.noContent().build()); // } // return auditRepository // .findTopByOrderByTimestampDesc() - // .map(ResponseEntity::ok) - // .orElse(ResponseEntity.noContent().build()); + // .map(e -> Response.ok(e).build()) + // .orElse(Response.noContent().build()); // } /** Cleanup endpoints data before a certain date */ - @DeleteMapping("/cleanup/before") + @DELETE + @jakarta.ws.rs.Path("/cleanup/before") + @Produces(MediaType.APPLICATION_JSON) @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) + @QueryParam("date") @Schema( description = "The cutoff date for cleanup", example = "2025-01-01", format = "date") - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { if (date != null && !date.isAfter(LocalDate.now())) { Instant cutoff = date.atStartOfDay(ZoneId.systemDefault()).toInstant(); @@ -390,7 +409,7 @@ public class AuditDashboardController { } else if (principal != null) { events = auditRepository.findAllByPrincipalForExport(principal); } else { - events = auditRepository.findAll(); + events = auditRepository.listAll(); } return events; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java index a208a46a81..adb0aba3b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java @@ -9,18 +9,25 @@ import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +// TODO: Migration required - PersistentAuditEventRepository is a not-yet-migrated collaborator +// (Spring Data JPA, task: Code: Spring Data JPA -> Hibernate ORM Panache). It still returns Spring +// org.springframework.data.domain.Page and accepts Pageable. These four Spring Data imports must +// stay until that repository is ported to PanacheRepositoryBase. Once it is, replace Pageable with +// io.quarkus.panache.common.Page, Sort.by("timestamp").descending() with +// io.quarkus.panache.common.Sort.descending("timestamp"), and Page<...> with PanacheQuery<...>. import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.format.annotation.DateTimeFormat; -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.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,8 +43,12 @@ import tools.jackson.databind.ObjectMapper; /** REST API controller for audit data used by React frontend. */ @Slf4j +@ApplicationScoped +// @ProprietaryUiDataApi carries only the OpenAPI @Tag; JAX-RS does not inherit @Path from +// meta-annotations, so the path is declared explicitly here. +@jakarta.ws.rs.Path("/api/v1/proprietary/ui-data") @ProprietaryUiDataApi -@PreAuthorize("hasRole('ADMIN')") +@RolesAllowed("ADMIN") @RequiredArgsConstructor @EnterpriseEndpoint public class AuditRestController { @@ -51,33 +62,32 @@ public class AuditRestController { * * @param page Page number (0-indexed) * @param pageSize Number of items per page - * @param eventType Filter by event type(s) - can be single value or array - * @param username Filter by username(s) - can be single value or array - * @param startDate Filter start date - * @param endDate Filter end date + * @param eventTypes Filter by event type(s) - can be single value or array + * @param usernames Filter by username(s) - can be single value or array + * @param startDateStr Filter start date (ISO yyyy-MM-dd) + * @param endDateStr Filter end date (ISO yyyy-MM-dd) * @return Paginated audit events response */ - @GetMapping("/audit-events") - public ResponseEntity getAuditEvents( - @RequestParam(value = "page", defaultValue = "0") int page, - @RequestParam(value = "pageSize", defaultValue = "30") int pageSize, - @RequestParam(value = "eventType", required = false) String[] eventTypes, - @RequestParam(value = "username", required = false) String[] usernames, - @RequestParam(value = "startDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate startDate, - @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate endDate) { + @GET + @jakarta.ws.rs.Path("/audit-events") + public Response getAuditEvents( + @QueryParam("page") @jakarta.ws.rs.DefaultValue("0") int page, + @QueryParam("pageSize") @jakarta.ws.rs.DefaultValue("30") int pageSize, + @QueryParam("eventType") List eventTypes, + @QueryParam("username") List usernames, + @QueryParam("startDate") String startDateStr, + @QueryParam("endDate") String endDateStr) { + + LocalDate startDate = parseIsoDate(startDateStr); + LocalDate endDate = parseIsoDate(endDateStr); Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending()); Page events; // Convert arrays to lists List eventTypeList = - (eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null; - List usernameList = - (usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null; + (eventTypes != null && !eventTypes.isEmpty()) ? eventTypes : null; + List usernameList = (usernames != null && !usernames.isEmpty()) ? usernames : null; Instant startInstant = null; Instant endInstant = null; @@ -129,7 +139,7 @@ public class AuditRestController { .totalPages(events.getTotalPages()) .build(); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } /** @@ -138,9 +148,10 @@ public class AuditRestController { * @param period Time period for charts (day/week/month) * @return Chart data for events by type, user, and over time */ - @GetMapping("/audit-charts") - public ResponseEntity getAuditCharts( - @RequestParam(value = "period", defaultValue = "week") String period) { + @GET + @jakarta.ws.rs.Path("/audit-charts") + public Response getAuditCharts( + @QueryParam("period") @jakarta.ws.rs.DefaultValue("week") String period) { // Calculate days based on period int days; @@ -224,7 +235,7 @@ public class AuditRestController { .eventsOverTime(eventsOverTimeChart) .build(); - return ResponseEntity.ok(chartsData); + return Response.ok(chartsData).build(); } /** @@ -232,8 +243,9 @@ public class AuditRestController { * * @return List of unique event types */ - @GetMapping("/audit-event-types") - public ResponseEntity> getEventTypes() { + @GET + @jakarta.ws.rs.Path("/audit-event-types") + public Response getEventTypes() { // Get distinct event types from the database List dbTypes = auditRepository.findDistinctEventTypes(); @@ -250,7 +262,7 @@ public class AuditRestController { List result = combinedTypes.stream().sorted().collect(Collectors.toList()); - return ResponseEntity.ok(result); + return Response.ok(result).build(); } /** @@ -258,8 +270,9 @@ public class AuditRestController { * * @return List of unique usernames */ - @GetMapping("/audit-users") - public ResponseEntity> getUsers() { + @GET + @jakarta.ws.rs.Path("/audit-users") + public Response getUsers() { // Use the countByPrincipal query to get unique principals List principalCounts = auditRepository.countByPrincipal(); @@ -269,7 +282,7 @@ public class AuditRestController { .sorted() .collect(Collectors.toList()); - return ResponseEntity.ok(users); + return Response.ok(users).build(); } /** @@ -279,9 +292,10 @@ public class AuditRestController { * @param period Time period for statistics (day/week/month) * @return Audit statistics data for dashboard KPI cards and enhanced charts */ - @GetMapping("/audit-stats") - public ResponseEntity getAuditStats( - @RequestParam(value = "period", defaultValue = "week") String period) { + @GET + @jakarta.ws.rs.Path("/audit-stats") + public Response getAuditStats( + @QueryParam("period") @jakarta.ws.rs.DefaultValue("week") String period) { // Calculate days based on period int days; @@ -323,24 +337,25 @@ public class AuditRestController { hourlyDistribution.put(String.format("%02d", hour), count); } - return ResponseEntity.ok( - AuditStatsData.builder() - .totalEvents(currentMetrics.totalEvents) - .prevTotalEvents(prevMetrics.totalEvents) - .uniqueUsers(currentMetrics.uniqueUsers) - .prevUniqueUsers(prevMetrics.uniqueUsers) - .successRate(currentMetrics.successRate) - .prevSuccessRate(prevMetrics.successRate) - .avgLatencyMs(currentMetrics.avgLatencyMs) - .prevAvgLatencyMs(prevMetrics.avgLatencyMs) - .errorCount(currentMetrics.errorCount) - .topEventType(currentMetrics.topEventType) - .topUser(currentMetrics.topUser) - .eventsByType(currentMetrics.eventsByType) - .eventsByUser(currentMetrics.eventsByUser) - .topTools(currentMetrics.topTools) - .hourlyDistribution(hourlyDistribution) - .build()); + return Response.ok( + AuditStatsData.builder() + .totalEvents(currentMetrics.totalEvents) + .prevTotalEvents(prevMetrics.totalEvents) + .uniqueUsers(currentMetrics.uniqueUsers) + .prevUniqueUsers(prevMetrics.uniqueUsers) + .successRate(currentMetrics.successRate) + .prevSuccessRate(prevMetrics.successRate) + .avgLatencyMs(currentMetrics.avgLatencyMs) + .prevAvgLatencyMs(prevMetrics.avgLatencyMs) + .errorCount(currentMetrics.errorCount) + .topEventType(currentMetrics.topEventType) + .topUser(currentMetrics.topUser) + .eventsByType(currentMetrics.eventsByType) + .eventsByUser(currentMetrics.eventsByUser) + .topTools(currentMetrics.topTools) + .hourlyDistribution(hourlyDistribution) + .build()) + .build(); } /** Compute metrics from a list of audit events. */ @@ -520,31 +535,30 @@ public class AuditRestController { * "date,username,tool,documentName,author,fileHash") * @param eventTypes Filter by event type(s) - can be single value or array * @param usernames Filter by username(s) - can be single value or array - * @param startDate Filter start date - * @param endDate Filter end date + * @param startDateStr Filter start date (ISO yyyy-MM-dd) + * @param endDateStr Filter end date (ISO yyyy-MM-dd) * @return File download response */ - @GetMapping("/audit-export") - public ResponseEntity exportAuditData( - @RequestParam(value = "format", defaultValue = "csv") String format, - @RequestParam(value = "fields", required = false) String fields, - @RequestParam(value = "eventType", required = false) String[] eventTypes, - @RequestParam(value = "username", required = false) String[] usernames, - @RequestParam(value = "startDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate startDate, - @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate endDate) { + @GET + @jakarta.ws.rs.Path("/audit-export") + public Response exportAuditData( + @QueryParam("format") @jakarta.ws.rs.DefaultValue("csv") String format, + @QueryParam("fields") String fields, + @QueryParam("eventType") List eventTypes, + @QueryParam("username") List usernames, + @QueryParam("startDate") String startDateStr, + @QueryParam("endDate") String endDateStr) { + + LocalDate startDate = parseIsoDate(startDateStr); + LocalDate endDate = parseIsoDate(endDateStr); // Get data with same filtering as getAuditEvents List events; // Convert arrays to lists List eventTypeList = - (eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null; - List usernameList = - (usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null; + (eventTypes != null && !eventTypes.isEmpty()) ? eventTypes : null; + List usernameList = (usernames != null && !usernames.isEmpty()) ? usernames : null; Instant startInstant = null; Instant endInstant = null; @@ -592,6 +606,19 @@ public class AuditRestController { // Helper methods + /** Parse an ISO yyyy-MM-dd date string, returning null when blank/unparseable. */ + private LocalDate parseIsoDate(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + try { + return LocalDate.parse(value.trim()); + } catch (Exception e) { + log.trace("Failed to parse ISO date value: {}", value); + return null; + } + } + private AuditEventDto convertToDto(PersistentAuditEvent event) { // Parse the JSON data field if present Map details = new HashMap<>(); @@ -628,7 +655,7 @@ public class AuditRestController { .build(); } - private ResponseEntity exportAsCsv(List events, String fields) { + private Response exportAsCsv(List events, String fields) { // Parse selected fields (comma-separated: // date,username,tool,documentName,author,fileHash,ipAddress,etc) Set selectedFields = new HashSet<>(); @@ -680,15 +707,17 @@ public class AuditRestController { } byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8")); - headers.setContentDispositionFormData( - "attachment", "audit_export_" + System.currentTimeMillis() + ".csv"); - - return ResponseEntity.ok().headers(headers).body(csvBytes); + return Response.ok(csvBytes) + .header(HttpHeaders.CONTENT_TYPE, "text/csv;charset=UTF-8") + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"audit_export_" + + System.currentTimeMillis() + + ".csv\"") + .build(); } - private ResponseEntity exportAsDefaultCsv(List events) { + private Response exportAsDefaultCsv(List events) { StringBuilder csv = new StringBuilder(); csv.append("ID,Principal,Type,Timestamp,Data\n"); @@ -703,11 +732,12 @@ public class AuditRestController { } byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8")); - headers.setContentDispositionFormData("attachment", "audit_export.csv"); - - return ResponseEntity.ok().headers(headers).body(csvBytes); + return Response.ok(csvBytes) + .header(HttpHeaders.CONTENT_TYPE, "text/csv;charset=UTF-8") + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"audit_export.csv\"") + .build(); } private Map extractEventData( @@ -798,18 +828,19 @@ public class AuditRestController { }; } - private ResponseEntity exportAsJson(List events) { + private Response exportAsJson(List events) { try { byte[] jsonBytes = objectMapper.writeValueAsBytes(events); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setContentDispositionFormData("attachment", "audit_export.json"); - - return ResponseEntity.ok().headers(headers).body(jsonBytes); + return Response.ok(jsonBytes) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"audit_export.json\"") + .build(); } catch (JacksonException e) { log.error("Error serializing audit events to JSON", e); - return ResponseEntity.internalServerError().build(); + return Response.serverError().build(); } } @@ -900,18 +931,20 @@ public class AuditRestController { * * @return Success response */ - @PostMapping("/audit-clear-all") - public ResponseEntity clearAllAuditData() { + @POST + @jakarta.ws.rs.Path("/audit-clear-all") + public Response clearAllAuditData() { try { // Delete all audit events auditRepository.deleteAll(); log.warn("All audit data has been cleared by admin user"); - return ResponseEntity.ok() - .body(Map.of("message", "All audit data has been cleared successfully")); + return Response.ok(Map.of("message", "All audit data has been cleared successfully")) + .build(); } catch (Exception e) { log.error("Error clearing audit data", e); - return ResponseEntity.internalServerError() - .body("Failed to clear audit data: " + e.getMessage()); + return Response.serverError() + .entity("Failed to clear audit data: " + e.getMessage()) + .build(); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java index 15b7982dec..f81a29ac7c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java @@ -6,14 +6,14 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.List; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + import org.apache.pdfbox.pdmodel.PDDocument; -import org.springframework.core.io.Resource; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.jboss.resteasy.reactive.RestForm; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Hidden; @@ -39,8 +39,8 @@ import stirling.software.common.util.WebResponseUtils; */ @Slf4j @Hidden -@RestController -@RequestMapping("/api/v1/ai/tools") +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/ai/tools") @RequiredArgsConstructor @Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") public class CreatePdfAgentController { @@ -70,18 +70,17 @@ public class CreatePdfAgentController { return false; } - @PostMapping( - value = "/create-pdf-from-html-agent", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @jakarta.ws.rs.Path("/create-pdf-from-html-agent") + @Consumes(MediaType.MULTIPART_FORM_DATA) @Operation( summary = "Convert AI-generated HTML to a PDF", description = "Accepts an HTML document as a plain-text parameter and returns a PDF." + " This endpoint is dispatched by the AI workflow orchestrator as a" + " plan step; it is not intended for direct client use.") - public ResponseEntity createPdfFromHtml( - @RequestParam("htmlContent") String htmlContent, - @RequestParam("filename") String filename) + public Response createPdfFromHtml( + @RestForm("htmlContent") String htmlContent, @RestForm("filename") String filename) throws Exception { log.info( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java index 20ca5c109f..683085154c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java @@ -3,14 +3,15 @@ package stirling.software.proprietary.controller.api; import java.io.IOException; import java.math.BigDecimal; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -19,6 +20,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.proprietary.model.api.ai.Verdict; import stirling.software.proprietary.service.AiToolInputValidator; import stirling.software.proprietary.service.MathAuditorOrchestrator; @@ -29,26 +32,28 @@ import stirling.software.proprietary.service.MathAuditorOrchestrator; *

Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the * multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict} as JSON. * - *

This endpoint is a pure specialist — it produces the structured finding and nothing more. + *

This endpoint is a pure specialist - it produces the structured finding and nothing more. * Presentation (rendering as a chat answer, projecting to PDF comments, etc.) is the responsibility * of the caller (e.g. the orchestrator's {@code delegate_pdf_question} or {@code * delegate_pdf_review} meta-agents). * *

Lives under {@code /api/v1/ai/tools/} so it is dispatchable by the AI orchestrator via the - * standard {@code InternalApiClient} allowlist — no special-case plumbing needed. + * standard {@code InternalApiClient} allowlist - no special-case plumbing needed. * *

The raw PDF never leaves Java. Python receives only structured text and CSV data. */ @Slf4j -@RestController -@RequestMapping("/api/v1/ai/tools") +@ApplicationScoped +@Path("/api/v1/ai/tools") @RequiredArgsConstructor @Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") public class MathAuditorAgentController { private final MathAuditorOrchestrator orchestrator; - @PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @Path("/math-auditor-agent") + @Consumes(MediaType.MULTIPART_FORM_DATA) @Operation( summary = "Validate mathematical calculations in a PDF", description = @@ -67,34 +72,37 @@ public class MathAuditorAgentController { Input: PDF Output: JSON Type: SISO """) - public ResponseEntity mathAuditorAgent( + public Response mathAuditorAgent( @Parameter(description = "The PDF document to audit", required = true) - @RequestParam("fileInput") - MultipartFile fileInput, + @RestForm("fileInput") + FileUpload fileInput, @Parameter( description = - "Arithmetic tolerance — differences smaller than this are" + "Arithmetic tolerance - differences smaller than this are" + " ignored (default: 0.01)") - @RequestParam(value = "tolerance", defaultValue = "0.01") + @RestForm("tolerance") BigDecimal tolerance) { - AiToolInputValidator.validatePdfUpload(fileInput); - if (tolerance.compareTo(BigDecimal.ZERO) < 0) { - return ResponseEntity.badRequest().build(); + BigDecimal effectiveTolerance = tolerance != null ? tolerance : new BigDecimal("0.01"); + + MultipartFile fileInputMpf = FileUploadMultipartFile.of(fileInput); + AiToolInputValidator.validatePdfUpload(fileInputMpf); + if (effectiveTolerance.compareTo(BigDecimal.ZERO) < 0) { + return Response.status(Response.Status.BAD_REQUEST).build(); } String safeName = - fileInput.getOriginalFilename() != null - ? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_") + fileInputMpf.getOriginalFilename() != null + ? fileInputMpf.getOriginalFilename().replaceAll("[\\r\\n]", "_") : ""; - log.info("[math-auditor-agent] request file={} tolerance={}", safeName, tolerance); + log.info("[math-auditor-agent] request file={} tolerance={}", safeName, effectiveTolerance); try { - Verdict verdict = orchestrator.audit(fileInput, tolerance); - return ResponseEntity.ok(verdict); + Verdict verdict = orchestrator.audit(fileInputMpf, effectiveTolerance); + return Response.ok(verdict).build(); } catch (IOException e) { log.error("[math-auditor-agent] IO error during audit", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java index ef0ceaba25..f9d31251f8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java @@ -2,24 +2,27 @@ package stirling.software.proprietary.controller.api; import java.io.IOException; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.core.io.Resource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; + import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.proprietary.service.AiToolResponseHeaders; import stirling.software.proprietary.service.PdfCommentAgentOrchestrator; import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf; @@ -39,19 +42,18 @@ import tools.jackson.databind.node.ObjectNode; *

The raw PDF never leaves Java. Python only receives positioned text chunks. */ @Slf4j -@RestController -@RequestMapping("/api/v1/ai/tools") -@RequiredArgsConstructor +@ApplicationScoped +@Path("/api/v1/ai/tools") @Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") public class PdfCommentAgentController { - private final PdfCommentAgentOrchestrator orchestrator; - private final ObjectMapper objectMapper; + @Inject PdfCommentAgentOrchestrator orchestrator; + @Inject ObjectMapper objectMapper; - @PostMapping( - value = "/pdf-comment-agent", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - produces = MediaType.APPLICATION_PDF_VALUE) + @POST + @Path("/pdf-comment-agent") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces("application/pdf") @Operation( summary = "Annotate a PDF with AI-generated sticky-note comments", description = @@ -66,18 +68,20 @@ public class PdfCommentAgentController { Input: PDF + prompt Output: PDF Type: SISO """) - public ResponseEntity pdfCommentAgent( + public Response pdfCommentAgent( @Parameter(description = "The PDF document to annotate", required = true) - @RequestParam("fileInput") - MultipartFile fileInput, + @RestForm("fileInput") + FileUpload fileInputUpload, @Parameter( description = - "Natural-language instructions for the AI — what to comment on", + "Natural-language instructions for the AI - what to comment on", required = true) - @RequestParam("prompt") + @RestForm("prompt") String prompt) throws IOException { + MultipartFile fileInput = FileUploadMultipartFile.of(fileInputUpload); + String safeName = fileInput.getOriginalFilename() != null ? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_") @@ -87,15 +91,19 @@ public class PdfCommentAgentController { safeName, prompt == null ? 0 : prompt.length()); - // ResponseStatusException (validation errors) propagates to Spring's default handler; + // ResponseStatusException (validation errors) propagates to the default handler; // IOException is re-thrown to produce a 500. Other RuntimeExceptions likewise propagate. AnnotatedPdf annotated = orchestrator.applyComments(fileInput, prompt); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_PDF); - headers.setContentDispositionFormData("attachment", annotated.fileName()); - headers.setContentLength(annotated.bytes().length); - headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated)); - return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(annotated.bytes())); + return Response.ok(annotated.bytes()) + .type("application/pdf") + .header(HttpHeaders.CONTENT_LENGTH, annotated.bytes().length) + .header( + "Content-Disposition", + "form-data; name=\"attachment\"; filename=\"" + + annotated.fileName() + + "\"") + .header(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated)) + .build(); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 161c1d7055..bd82abe2f9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -6,14 +6,16 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Response; + +import io.quarkus.security.identity.SecurityIdentity; import io.swagger.v3.oas.annotations.Operation; @@ -45,7 +47,6 @@ import stirling.software.proprietary.security.model.SessionEntity; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.dto.AdminUserSummary; import stirling.software.proprietary.security.repository.TeamRepository; -import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.service.DatabaseServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.MfaService; @@ -57,6 +58,8 @@ import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; @Slf4j +@ApplicationScoped +@Path("/api/v1/proprietary/ui-data") @ProprietaryUiDataApi public class ProprietaryUIDataController { @@ -74,6 +77,9 @@ public class ProprietaryUIDataController { private final MfaService mfaService; private final LoginAttemptService loginAttemptService; + @Inject SecurityIdentity securityIdentity; + + @Inject public ProprietaryUIDataController( ApplicationProperties applicationProperties, AuditConfigurationProperties auditConfig, @@ -83,7 +89,7 @@ public class ProprietaryUIDataController { SessionRepository sessionRepository, DatabaseServiceInterface databaseService, ObjectMapper objectMapper, - @Qualifier("runningEE") boolean runningEE, + @Named("runningEE") boolean runningEE, UserLicenseSettingsService licenseSettingsService, PersistentAuditEventRepository auditRepository, MfaService mfaService, @@ -119,11 +125,12 @@ public class ProprietaryUIDataController { return "http://localhost:8080"; } - @GetMapping("/audit-dashboard") - @PreAuthorize("hasRole('ADMIN')") + @GET + @Path("/audit-dashboard") + @RolesAllowed("ADMIN") @EnterpriseEndpoint @Operation(summary = "Get audit dashboard data") - public ResponseEntity getAuditDashboardData() { + public Response getAuditDashboardData() { AuditDashboardData data = new AuditDashboardData(); data.setAuditEnabled(auditConfig.isEnabled()); data.setAuditLevel(auditConfig.getAuditLevel()); @@ -139,12 +146,13 @@ public class ProprietaryUIDataController { data.setPdfMetadataEnabled( auditConfig.isCaptureFileHash() || auditConfig.isCapturePdfAuthor()); - return ResponseEntity.ok(data); + return Response.ok(data).build(); } - @GetMapping("/login") + @GET + @Path("/login") @Operation(summary = "Get login page data") - public ResponseEntity getLoginData() { + public Response getLoginData() { LoginData data = new LoginData(); Map providerList = new HashMap<>(); Security securityProps = applicationProperties.getSecurity(); @@ -247,13 +255,14 @@ public class ProprietaryUIDataController { data.setLanguages(applicationProperties.getUi().getLanguages()); data.setDefaultLocale(applicationProperties.getSystem().getDefaultLocale()); - return ResponseEntity.ok(data); + return Response.ok(data).build(); } - @GetMapping("/admin-settings") - @PreAuthorize("hasRole('ADMIN')") + @GET + @Path("/admin-settings") + @RolesAllowed("ADMIN") @Operation(summary = "Get admin settings data") - public ResponseEntity getAdminSettingsData(Authentication authentication) { + public Response getAdminSettingsData() { List allUsers = userRepository.findAllWithTeam(); Iterator iterator = allUsers.iterator(); Map roleDetails = Role.getAllRoleDetails(); @@ -375,7 +384,7 @@ public class ProprietaryUIDataController { AdminSettingsData data = new AdminSettingsData(); data.setUsers(userSummaries); - data.setCurrentUsername(authentication.getName()); + data.setCurrentUsername(securityIdentity.getPrincipal().getName()); data.setRoleDetails(roleDetails); data.setUserSessions(userSessions); data.setUserLastRequest(userLastRequest); @@ -393,39 +402,37 @@ public class ProprietaryUIDataController { data.setUserSettings(userSettings); data.setLockedUsers(loginAttemptService.getAllBlockedUsers()); - return ResponseEntity.ok(data); + return Response.ok(data).build(); } - @GetMapping("/account") - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @GET + @Path("/account") + // TODO: Migration required - Spring "!hasAuthority('ROLE_DEMO_USER')" (negated authority) has + // no @RolesAllowed equivalent. Enforce the DEMO_USER exclusion via a Quarkus + // SecurityIdentity check below / an augmentor, or quarkus.http.auth.* policy. @Operation(summary = "Get account page data") - public ResponseEntity getAccountData(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401).build(); + public Response getAccountData() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return Response.status(Response.Status.UNAUTHORIZED).build(); } - Object principal = authentication.getPrincipal(); - String username = null; + // TODO: Migration required - Spring distinguished UserDetails / OAuth2User / + // CustomSaml2AuthenticatedPrincipal off authentication.getPrincipal() to set the + // oAuth2Login / saml2Login flags. Under Quarkus the auth mechanism is exposed via + // SecurityIdentity attributes (e.g. quarkus-oidc IdToken / SAML augmentor). Until OAuth2/ + // SAML are wired to quarkus-oidc, only the username is resolved and the login-type flags + // default to false. + String username = securityIdentity.getPrincipal().getName(); boolean isOAuth2Login = false; boolean isSaml2Login = false; - if (principal instanceof UserDetails detailsUser) { - username = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - username = oAuth2User.getName(); - isOAuth2Login = true; - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - username = saml2User.name(); - isSaml2Login = true; - } - if (username == null) { - return ResponseEntity.status(401).build(); + return Response.status(Response.Status.UNAUTHORIZED).build(); } Optional user = userRepository.findByUsernameIgnoreCaseWithSettings(username); if (user.isEmpty()) { - return ResponseEntity.status(404).build(); + return Response.status(Response.Status.NOT_FOUND).build(); } String settingsJson; @@ -433,7 +440,7 @@ public class ProprietaryUIDataController { settingsJson = objectMapper.writeValueAsString(user.get().getSettings()); } catch (JacksonException e) { log.error("Error converting settings map", e); - return ResponseEntity.status(500).build(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); } AccountData data = new AccountData(); @@ -446,13 +453,14 @@ public class ProprietaryUIDataController { data.setMfaEnabled(mfaService.isMfaEnabled(user.get())); data.setMfaRequired(mfaService.isMfaRequired(user.get())); - return ResponseEntity.ok(data); + return Response.ok(data).build(); } - @GetMapping("/teams") - @PreAuthorize("hasRole('ADMIN')") + @GET + @Path("/teams") + @RolesAllowed("ADMIN") @Operation(summary = "Get teams list data") - public ResponseEntity getTeamsData() { + public Response getTeamsData() { List allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount(); List teamsWithCounts = allTeamsWithCounts.stream() @@ -472,20 +480,21 @@ public class ProprietaryUIDataController { data.setTeamsWithCounts(teamsWithCounts); data.setTeamLastRequest(teamLastRequest); - return ResponseEntity.ok(data); + return Response.ok(data).build(); } - @GetMapping("/teams/{id}") - @PreAuthorize("hasRole('ADMIN')") + @GET + @Path("/teams/{id}") + @RolesAllowed("ADMIN") @Operation(summary = "Get team details data") - public ResponseEntity getTeamDetailsData(@PathVariable("id") Long id) { + public Response getTeamDetailsData(@PathParam("id") Long id) { Team team = teamRepository .findById(id) .orElseThrow(() -> new RuntimeException("Team not found")); if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) { - return ResponseEntity.status(403).build(); + return Response.status(Response.Status.FORBIDDEN).build(); } List teamUsers = userRepository.findAllByTeamId(id); @@ -516,13 +525,14 @@ public class ProprietaryUIDataController { data.setAvailableUsers(availableUsers); data.setUserLastRequest(userLastRequest); - return ResponseEntity.ok(data); + return Response.ok(data).build(); } - @GetMapping("/database") - @PreAuthorize("hasRole('ADMIN')") + @GET + @Path("/database") + @RolesAllowed("ADMIN") @Operation(summary = "Get database management data") - public ResponseEntity getDatabaseData() { + public Response getDatabaseData() { List backupList = databaseService.getBackupList(); String dbVersion = databaseService.getH2Version(); boolean isVersionUnknown = "Unknown".equalsIgnoreCase(dbVersion); @@ -532,7 +542,7 @@ public class ProprietaryUIDataController { data.setDatabaseVersion(dbVersion); data.setVersionUnknown(isVersionUnknown); - return ResponseEntity.ok(data); + return Response.ok(data).build(); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java index 3295e62abb..1be1cc98dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java @@ -8,16 +8,15 @@ import java.util.List; import java.util.Map; import java.util.stream.Stream; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -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.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import io.swagger.v3.oas.annotations.tags.Tag; @@ -32,12 +31,19 @@ import stirling.software.proprietary.service.SignatureService; /** * Controller for managing user signatures in proprietary/authenticated mode only. Requires user - * authentication and enforces per-user storage limits. All endpoints require authentication - * via @PreAuthorize("isAuthenticated()"). + * authentication and enforces per-user storage limits. + * + *

TODO: Migration required - the original endpoints were guarded by Spring Security SpEL + * expressions ({@code @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")} and + * {@code @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")}). These are not simple role checks, so + * they cannot be expressed with {@code @RolesAllowed}. Authentication should be enforced via Quarkus + * (e.g. inject {@code io.quarkus.security.identity.SecurityIdentity} or add an HTTP auth policy in + * application.properties), and the DEMO_USER exclusion needs to be re-implemented as a runtime check + * against the current identity's roles. */ @Slf4j -@RestController -@RequestMapping("/api/v1/proprietary/signatures") +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/proprietary/signatures") @RequiredArgsConstructor @Tag( name = "Saved Signatures", @@ -52,10 +58,13 @@ public class SignatureController { * Save a new signature for the authenticated user. Enforces storage limits and authentication * requirements. */ - @PostMapping - @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") - public ResponseEntity saveSignature( - @RequestBody SavedSignatureRequest request) { + // TODO: Migration required - replace @PreAuthorize("isAuthenticated() && + // !hasAuthority('ROLE_DEMO_USER')") with a Quarkus authentication policy + DEMO_USER runtime + // guard. + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response saveSignature(SavedSignatureRequest request) { try { String username = userService.getCurrentUsername(); @@ -63,24 +72,24 @@ public class SignatureController { log.warn( "User {} attempted to create shared signature without admin role", username); - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return Response.status(Response.Status.FORBIDDEN).build(); } // Validate request if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) { log.warn("User {} attempted to save signature without dataUrl", username); - return ResponseEntity.badRequest().build(); + return Response.status(Response.Status.BAD_REQUEST).build(); } SavedSignatureResponse response = signatureService.saveSignature(username, request); log.info("User {} saved signature {}", username, request.getId()); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } catch (IllegalArgumentException e) { log.warn("Invalid signature save request: {}", e.getMessage()); - return ResponseEntity.badRequest().build(); + return Response.status(Response.Status.BAD_REQUEST).build(); } catch (IOException e) { log.error("Failed to save signature", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); } } @@ -88,16 +97,19 @@ public class SignatureController { * List all signatures accessible to the authenticated user. Includes both personal and shared * signatures. */ - @GetMapping - @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") - public ResponseEntity> listSignatures() { + // TODO: Migration required - replace @PreAuthorize("isAuthenticated() && + // !hasAuthority('ROLE_DEMO_USER')") with a Quarkus authentication policy + DEMO_USER runtime + // guard. + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response listSignatures() { try { String username = userService.getCurrentUsername(); List signatures = signatureService.getSavedSignatures(username); - return ResponseEntity.ok(signatures); + return Response.ok(signatures).build(); } catch (IOException e) { log.error("Failed to list signatures for user", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); } } @@ -105,10 +117,13 @@ public class SignatureController { * Update a signature label. Users can update labels for their own personal signatures and for * shared signatures. */ - @PostMapping("/{signatureId}/label") - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - public ResponseEntity updateSignatureLabel( - @PathVariable String signatureId, @RequestBody Map body) { + // TODO: Migration required - replace @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") with a + // DEMO_USER runtime guard against the current identity's roles. + @POST + @jakarta.ws.rs.Path("/{signatureId}/label") + @Consumes(MediaType.APPLICATION_JSON) + public Response updateSignatureLabel( + @PathParam("signatureId") String signatureId, Map body) { try { String username = userService.getCurrentUsername(); String newLabel = body.get("label"); @@ -116,7 +131,7 @@ public class SignatureController { if (newLabel == null || newLabel.trim().isEmpty()) { log.warn("Invalid label update request"); - return ResponseEntity.badRequest().build(); + return Response.status(Response.Status.BAD_REQUEST).build(); } if (signatureService.isSharedSignature(signatureId) && !isAdmin) { @@ -124,15 +139,15 @@ public class SignatureController { "User {} attempted to update shared signature {} without admin role", username, signatureId); - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return Response.status(Response.Status.FORBIDDEN).build(); } signatureService.updateSignatureLabel(username, signatureId, newLabel); log.info("User {} updated label for signature {}", username, signatureId); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } catch (IOException e) { log.warn("Failed to update signature label: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + return Response.status(Response.Status.NOT_FOUND).build(); } } @@ -140,9 +155,11 @@ public class SignatureController { * Delete a signature owned by the authenticated user. Users can delete their own personal * signatures. Admins can also delete shared signatures. */ - @DeleteMapping("/{signatureId}") - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - public ResponseEntity deleteSignature(@PathVariable String signatureId) { + // TODO: Migration required - replace @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") with a + // DEMO_USER runtime guard against the current identity's roles. + @DELETE + @jakarta.ws.rs.Path("/{signatureId}") + public Response deleteSignature(@PathParam("signatureId") String signatureId) { try { String username = userService.getCurrentUsername(); boolean isAdmin = userService.isCurrentUserAdmin(); @@ -152,21 +169,21 @@ public class SignatureController { || signatureId.contains("/") || signatureId.contains("\\")) { log.warn("Invalid signature ID: {}", signatureId); - return ResponseEntity.badRequest().build(); + return Response.status(Response.Status.BAD_REQUEST).build(); } // Try to delete from personal folder first try { signatureService.deleteSignature(username, signatureId); log.info("User {} deleted personal signature {}", username, signatureId); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } catch (IOException e) { // If not found in personal folder, check if it's in shared folder if (isAdmin) { // Admin can delete from shared folder if (deleteFromSharedFolder(signatureId)) { log.info("Admin {} deleted shared signature {}", username, signatureId); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } } // If not admin or not found in shared folder either, return 404 @@ -174,7 +191,7 @@ public class SignatureController { } } catch (IOException e) { log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage()); - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + return Response.status(Response.Status.NOT_FOUND).build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 9f4fa470ba..5bb0b850b9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -5,10 +5,13 @@ import java.time.Instant; import java.util.*; import java.util.stream.Collectors; -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.RequestParam; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,8 +27,10 @@ import tools.jackson.databind.ObjectMapper; /** REST API controller for usage analytics data used by React frontend. */ @Slf4j +@ApplicationScoped @ProprietaryUiDataApi -@PreAuthorize("hasRole('ADMIN')") +@Path("/api/v1/proprietary/ui-data") +@RolesAllowed("ADMIN") @RequiredArgsConstructor @EnterpriseEndpoint public class UsageRestController { @@ -43,11 +48,12 @@ public class UsageRestController { * @param days Lookback window in days (default 30, clamped to 1-365) * @return Endpoint statistics response */ - @GetMapping("/usage-endpoint-statistics") - public ResponseEntity getEndpointStatistics( - @RequestParam(value = "limit", required = false) Integer limit, - @RequestParam(value = "dataType", defaultValue = "all") String dataType, - @RequestParam(value = "days", defaultValue = "30") Integer days) { + @GET + @Path("/usage-endpoint-statistics") + public Response getEndpointStatistics( + @QueryParam("limit") Integer limit, + @QueryParam("dataType") @DefaultValue("all") String dataType, + @QueryParam("days") @DefaultValue("30") Integer days) { int lookbackDays = Math.max(1, Math.min(days, 365)); @@ -99,7 +105,7 @@ public class UsageRestController { .totalVisits((int) totalVisits) .build(); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java index d3103b8d4c..b54f46c8ac 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/McpServerController.java @@ -5,16 +5,15 @@ import java.util.List; import java.util.Map; import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import io.quarkus.arc.lookup.LookupIfProperty; +import io.quarkus.security.identity.SecurityIdentity; import lombok.extern.slf4j.Slf4j; @@ -30,9 +29,14 @@ import tools.jackson.databind.node.ObjectNode; /** Streamable-HTTP MCP server endpoint serving JSON-RPC 2.0 frames on {@code POST /mcp}. */ @Slf4j -@RestController -@RequestMapping -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@jakarta.ws.rs.Path("/mcp") +// @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") -> LookupIfProperty. +// LookupIfProperty gates programmatic lookup; for a JAX-RS resource Quarkus always registers the +// endpoint. TODO: Migration required - to truly disable the /mcp route when mcp.enabled=false, +// add a runtime guard (e.g. reject in handle() when disabled) or use a build-time conditional; +// LookupIfProperty alone does not unregister the REST path. +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class McpServerController { private static final String PREFERRED_PROTOCOL_VERSION = "2025-06-18"; @@ -44,6 +48,9 @@ public class McpServerController { private final ApplicationProperties applicationProperties; private final Map toolsByName; + @Inject SecurityIdentity securityIdentity; + + @Inject public McpServerController( ObjectMapper mapper, ApplicationProperties applicationProperties, List tools) { this.mapper = mapper; @@ -58,24 +65,24 @@ public class McpServerController { toolsByName.keySet()); } - @PostMapping( - path = "/mcp", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity handle(@RequestBody JsonNode body) { + @POST + @Consumes(MediaType.APPLICATION_JSON) + @jakarta.ws.rs.Produces(MediaType.APPLICATION_JSON) + public Response handle(JsonNode body) { JsonRpcRequest request = decode(body); if (request == null) { // Valid JSON but not a JSON-RPC request -> Invalid Request, not Parse error. - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( JsonRpcResponse.failure( null, JsonRpcError.invalidRequest( - "Body is not a valid JSON-RPC 2.0 request"))); + "Body is not a valid JSON-RPC 2.0 request"))) + .build(); } if (request.isNotification()) { log.debug("Notification received: {}", sanitizeForLog(request.method())); - return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + return Response.status(Response.Status.NO_CONTENT).build(); } JsonRpcResponse response; try { @@ -92,17 +99,23 @@ public class McpServerController { JsonRpcError.internalError( "Internal error handling " + request.method())); } - return ResponseEntity.ok(response); + return Response.ok(response).build(); } - /** Wrap malformed-JSON failures (caught before {@link #handle}) as a JSON-RPC Parse error. */ - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleUnreadable(HttpMessageNotReadableException ex) { - return ResponseEntity.badRequest() - .contentType(MediaType.APPLICATION_JSON) - .body( + // Spring's @ExceptionHandler(HttpMessageNotReadableException.class) wrapped malformed-JSON + // failures as a JSON-RPC Parse error. In JAX-RS this maps to a + // jakarta.ws.rs.ext.ExceptionMapper provider. TODO: Migration required - move this handling to + // a @Provider ExceptionMapper<...> (e.g. mapping the JSON deserialization exception thrown by + // the Jackson MessageBodyReader) returning HTTP 400 with + // JsonRpcResponse.failure(null, JsonRpcError.parseError("Request body is not valid JSON")). + // Kept here for reference; it is no longer wired as an exception handler. + private Response handleUnreadable() { + return Response.status(Response.Status.BAD_REQUEST) + .type(MediaType.APPLICATION_JSON) + .entity( JsonRpcResponse.failure( - null, JsonRpcError.parseError("Request body is not valid JSON"))); + null, JsonRpcError.parseError("Request body is not valid JSON"))) + .build(); } private static String sanitizeForLog(String value) { @@ -197,21 +210,32 @@ public class McpServerController { private McpCallContext resolveContext() { boolean scopesEnabled = applicationProperties.getMcp().isScopesEnabled(); - org.springframework.security.core.Authentication auth = - org.springframework.security.core.context.SecurityContextHolder.getContext() - .getAuthentication(); - // Fail closed: no/unauthenticated principal yields an empty context so scoped ops are - // refused. - if (auth == null || !auth.isAuthenticated() || auth.getName() == null) { + // Spring SecurityContextHolder.getContext().getAuthentication() -> Quarkus SecurityIdentity. + // Fail closed: an anonymous/unauthenticated identity yields an empty context so scoped ops + // are refused. + if (securityIdentity == null + || securityIdentity.isAnonymous() + || securityIdentity.getPrincipal() == null + || securityIdentity.getPrincipal().getName() == null) { return new McpCallContext(null, Set.of(), scopesEnabled); } java.util.Set scopes = new java.util.HashSet<>(); - for (org.springframework.security.core.GrantedAuthority ga : auth.getAuthorities()) { - String authority = ga.getAuthority(); - if (authority != null && authority.startsWith("SCOPE_")) { - scopes.add(authority.substring("SCOPE_".length())); + // TODO: Migration required - the Spring code derived scopes from GrantedAuthority values + // prefixed with "SCOPE_". Quarkus SecurityIdentity.getRoles() typically already carries the + // bare role/scope names (quarkus-oidc maps OIDC scopes to roles without the SCOPE_ prefix). + // Confirm the configured quarkus.oidc role/scope mapping; if scopes arrive as a "scope" + // claim, read them via securityIdentity.getAttribute("scope")/getClaims() instead. For now + // we accept both the bare role and any "SCOPE_"-prefixed authority for parity. + for (String role : securityIdentity.getRoles()) { + if (role == null) { + continue; + } + if (role.startsWith("SCOPE_")) { + scopes.add(role.substring("SCOPE_".length())); + } else { + scopes.add(role); } } - return new McpCallContext(auth.getName(), scopes, scopesEnabled); + return new McpCallContext(securityIdentity.getPrincipal().getName(), scopes, scopesEnabled); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java index 2821c9181f..df61da2e31 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/McpToolCatalog.java @@ -2,25 +2,17 @@ package stirling.software.proprietary.mcp.catalog; import java.lang.reflect.Method; import java.util.ArrayList; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; -import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.ApplicationContext; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; -import org.springframework.core.MethodParameter; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.mvc.method.RequestMappingInfo; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import io.quarkus.runtime.StartupEvent; import io.swagger.v3.oas.annotations.Operation; @@ -33,19 +25,23 @@ import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.node.ObjectNode; /** - * Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on {@link - * ContextRefreshedEvent} and filtered on read by {@link + * Discovers MCP-exposable operations and caches a per-op {@link OperationMeta}. Refreshed on + * application startup ({@code @Observes StartupEvent}) and filtered on read by {@link * EndpointConfiguration#isEndpointEnabledForUri}. AI capabilities are fed in via {@link * #replaceAiCapabilities}. */ @Slf4j -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +// TODO: Migration required - the original @ConditionalOnProperty(name = "mcp.enabled", +// havingValue = "true") gated this bean on a runtime property. Quarkus build-time conditions +// (@io.quarkus.arc.lookup.LookupIfProperty / @io.quarkus.arc.profile.IfBuildProfile) cannot honour +// a purely runtime toggle. The bean is now always present; callers must guard on +// applicationProperties.getMcp() / a runtime "mcp.enabled" check, or wire @LookupIfProperty on the +// injection points once "mcp.enabled" is promoted to a build-time property. public class McpToolCatalog { private static final String WRITE_SCOPE = "mcp.tools.write"; - private final ApplicationContext applicationContext; private final EndpointConfiguration endpointConfiguration; private final ApplicationProperties applicationProperties; private final SimpleSchemaGenerator schemaGenerator; @@ -60,12 +56,11 @@ public class McpToolCatalog { // never a partially-merged one. private volatile Map aiOps = new ConcurrentHashMap<>(); + @Inject public McpToolCatalog( - ApplicationContext applicationContext, EndpointConfiguration endpointConfiguration, ApplicationProperties applicationProperties, ObjectMapper objectMapper) { - this.applicationContext = applicationContext; this.endpointConfiguration = endpointConfiguration; this.applicationProperties = applicationProperties; this.schemaGenerator = new SimpleSchemaGenerator(objectMapper); @@ -86,52 +81,38 @@ public class McpToolCatalog { return true; } - @EventListener(ContextRefreshedEvent.class) - public void discover() { + void discover(@Observes StartupEvent event) { pdfOps.clear(); - for (RequestMappingHandlerMapping mapping : - applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) { - for (Map.Entry e : - mapping.getHandlerMethods().entrySet()) { - indexOne(e.getKey(), e.getValue()); - } - } + // TODO: Migration required - endpoint discovery relied on Spring MVC's + // RequestMappingHandlerMapping (ApplicationContext.getBeansOfType(...) -> + // mapping.getHandlerMethods()) to enumerate every @RequestMapping/@PostMapping handler, + // its URL patterns (RequestMappingInfo#getDirectPaths), its HTTP methods + // (RequestMethod POST/PUT), and the HandlerMethod/MethodParameter reflection used to build + // request schemas. Quarkus/RESTEasy Reactive has no equivalent runtime registry of JAX-RS + // resources. To restore catalog population, replace this with one of: + // (a) a build-time scan via a Quarkus extension / @io.quarkus.runtime.annotations.Recorder + // over Jandex-indexed @Path + @POST/@PUT methods, or + // (b) a custom registry populated as endpoints register themselves, or + // (c) classpath reflection (Jandex CombinedIndexBuildItem) over the @XxxApi-annotated + // resource classes. + // The per-handler helpers below (buildMeta/paramSchemaFor/firstComplexParamType/indexOne/ + // extractPatterns/isInvocableMethod) all depended on Spring MVC types and have been removed; + // the schema-generation logic (SimpleSchemaGenerator) and OperationMeta model are reusable + // once a Quarkus-native handler enumeration is supplied. log.info("MCP tool catalog discovered {} PDF operation(s)", pdfOps.size()); } - private void indexOne(RequestMappingInfo info, HandlerMethod handler) { - Set patterns = extractPatterns(info); - if (patterns.isEmpty()) { - return; - } - Set methods = info.getMethodsCondition().getMethods(); - if (!isInvocableMethod(methods)) { - return; - } - for (String pattern : patterns) { - OperationCategory category = OperationCategory.fromUrl(pattern); - if (category == null) { - continue; - } - String opId = extractOpId(pattern, category); - if (opId == null) { - continue; - } - OperationMeta meta = buildMeta(opId, category, pattern, handler); - // First handler wins on duplicate URLs. - pdfOps.putIfAbsent(opId, meta); - } - } - private OperationMeta buildMeta( - String opId, OperationCategory category, String url, HandlerMethod handler) { - Method method = handler.getMethod(); + String opId, OperationCategory category, String url, Method method) { Operation opAnno = method.getAnnotation(Operation.class); String summary = opAnno != null && !opAnno.summary().isBlank() ? opAnno.summary() : prettifyOpId(opId); - ObjectNode schema = paramSchemaFor(handler); + // TODO: Migration required - request body type was previously resolved from Spring's + // HandlerMethod#getMethodParameters(); resolve the first complex parameter type via plain + // reflection on the JAX-RS resource method instead, then call schemaGenerator.toSchema(...). + ObjectNode schema = paramSchemaFor(method); // Every mutating endpoint requires the write scope. return new OperationMeta( opId, @@ -141,11 +122,11 @@ public class McpToolCatalog { WRITE_SCOPE, OperationMeta.Target.JAVA_ENDPOINT, url, - handler); + method); } - private ObjectNode paramSchemaFor(HandlerMethod handler) { - Optional> bodyType = firstComplexParamType(handler); + private ObjectNode paramSchemaFor(Method method) { + Optional> bodyType = firstComplexParamType(method); return bodyType.map(schemaGenerator::toSchema).orElseGet(() -> emptyObjectSchema()); } @@ -156,13 +137,12 @@ public class McpToolCatalog { return out; } - private Optional> firstComplexParamType(HandlerMethod handler) { - for (MethodParameter p : handler.getMethodParameters()) { - Class type = p.getParameterType(); + private Optional> firstComplexParamType(Method method) { + for (Class type : method.getParameterTypes()) { if (type.isPrimitive() || type == String.class || type.getName().startsWith("java.")) { continue; } - // Skip Spring-managed parameter types (HttpServletRequest, Principal, etc.). + // Skip container-managed parameter types (HttpServletRequest, Principal, etc.). String pkg = type.getPackageName(); if (pkg.startsWith("jakarta.") || pkg.startsWith("org.springframework.")) { continue; @@ -220,46 +200,10 @@ public class McpToolCatalog { log.info("MCP tool catalog AI capabilities replaced: {} entries", next.size()); } - /** Only POST/PUT endpoints are exposed as tools; DELETE and GET are excluded. */ - static boolean isInvocableMethod(Set methods) { - return methods.contains(RequestMethod.POST) || methods.contains(RequestMethod.PUT); - } - - private static String extractOpId(String pattern, OperationCategory category) { - if (category.urlPrefix() == null || !pattern.startsWith(category.urlPrefix())) { - return null; - } - String tail = pattern.substring(category.urlPrefix().length()); - if (tail.isBlank() || tail.contains("/") || tail.contains("{")) { - // Skip nested paths and path-variable templates. - return null; - } - return tail; - } - private static String prettifyOpId(String id) { return id.replace('-', ' '); } - private static Set extractPatterns(RequestMappingInfo info) { - try { - Method getDirectPaths = info.getClass().getMethod("getDirectPaths"); - Object result = getDirectPaths.invoke(info); - if (result instanceof Set set) { - Set patterns = new TreeSet<>(); - for (Object v : set) { - if (v instanceof String s) { - patterns.add(s); - } - } - return patterns; - } - } catch (Exception e) { - log.trace("getDirectPaths unavailable on RequestMappingInfo", e); - } - return Collections.emptySet(); - } - public Map snapshotPdfOps() { return new LinkedHashMap<>(pdfOps); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java index a9d8b2a9a9..bcf10c38b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/OperationMeta.java @@ -1,6 +1,6 @@ package stirling.software.proprietary.mcp.catalog; -import org.springframework.web.method.HandlerMethod; +import java.lang.reflect.Method; import tools.jackson.databind.node.ObjectNode; @@ -13,7 +13,12 @@ public record OperationMeta( String requiredScope, Target target, String endpointPath, - HandlerMethod handlerMethod) { + // TODO: Migration required - was org.springframework.web.method.HandlerMethod (Spring MVC, + // no Quarkus equivalent). Replaced with the underlying java.lang.reflect.Method. The + // collaborator McpToolCatalog must be updated to discover JAX-RS resource methods (e.g. via + // RESTEasy Reactive ResourceScanningSupport / jakarta.ws.rs annotations) instead of + // Spring's RequestMappingHandlerMapping, and pass a reflect.Method here. + Method handlerMethod) { public enum Target { JAVA_ENDPOINT, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java index 0cef8d32bf..efd1543b4c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/catalog/SimpleSchemaGenerator.java @@ -8,7 +8,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -import org.springframework.web.multipart.MultipartFile; +import stirling.software.common.model.MultipartFile; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java index ed023bd25b..4688e85f46 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/engine/EngineCapabilityClient.java @@ -12,13 +12,12 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; +import io.quarkus.runtime.StartupEvent; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; import lombok.extern.slf4j.Slf4j; @@ -36,8 +35,11 @@ import tools.jackson.databind.node.ObjectNode; * {@link McpToolCatalog}. */ @Slf4j -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +// TODO: Migration required - @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +// has no direct CDI equivalent. The onReady() observer below guards on a runtime config toggle +// instead; consider @io.quarkus.arc.lookup.LookupIfProperty / a build-time profile if the bean +// itself should be excluded. public class EngineCapabilityClient { private final ApplicationProperties applicationProperties; @@ -70,8 +72,7 @@ public class EngineCapabilityClient { }); } - @EventListener(ApplicationReadyEvent.class) - public void onReady() { + public void onReady(@Observes StartupEvent event) { long minutes = Math.max(1, applicationProperties.getMcp().getEngineCapabilityRefreshMinutes()); // First refresh immediately, then on the configured cadence. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java index e45dadb0c0..065f1ff43b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java @@ -4,19 +4,12 @@ import java.io.IOException; import java.util.List; import java.util.Optional; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -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.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; - +import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; @@ -28,12 +21,11 @@ import stirling.software.proprietary.security.service.UserService; * that user with the MCP scopes. */ @Slf4j -public class McpApiKeyAuthFilter extends OncePerRequestFilter { +public class McpApiKeyAuthFilter implements Filter { - private static final List MCP_SCOPES = - List.of( - new SimpleGrantedAuthority("SCOPE_mcp.tools.read"), - new SimpleGrantedAuthority("SCOPE_mcp.tools.write")); + // MCP scopes granted to a request authenticated via API key. + private static final List MCP_SCOPES = + List.of("SCOPE_mcp.tools.read", "SCOPE_mcp.tools.write"); private final UserService userService; @@ -42,33 +34,35 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter { } @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - Authentication existing = SecurityContextHolder.getContext().getAuthentication(); - // Treat an anonymous token as not authenticated so the key is still processed. - boolean unauthenticated = - existing == null - || existing instanceof AnonymousAuthenticationToken - || !existing.isAuthenticated(); - if (unauthenticated) { - String apiKey = extractKey(request); - if (apiKey != null && !apiKey.isBlank()) { - Optional user = userService.getUserByApiKey(apiKey); - if (user.isPresent() && user.get().isEnabled()) { - UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken( - user.get().getUsername(), null, MCP_SCOPES); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(auth); - SecurityContextHolder.setContext(context); - } else { - log.warn( - "MCP access denied: presented API key did not match an active account"); - } + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, + FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + + // TODO: Migration required - Spring Security removed. This filter previously read the + // current Authentication from SecurityContextHolder to decide whether to process the API + // key. Quarkus has no SecurityContextHolder; the current identity is exposed via + // io.quarkus.security.identity.SecurityIdentity. With the binding below not yet wired, we + // always attempt to validate the presented key so the lookup logic is preserved. + String apiKey = extractKey(request); + if (apiKey != null && !apiKey.isBlank()) { + Optional user = userService.getUserByApiKey(apiKey); + if (user.isPresent() && user.get().isEnabled()) { + // TODO: Migration required - bind the resolved user + MCP_SCOPES to the request + // identity. Spring's UsernamePasswordAuthenticationToken / + // SecurityContextHolder.setContext(...) has no servlet-filter equivalent in + // Quarkus. Implement an io.quarkus.security.identity.SecurityIdentityAugmentor (or + // a custom io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism / + // IdentityProvider keyed off the X-API-KEY / Bearer credential) that produces a + // SecurityIdentity with principal=user.getUsername() and roles=MCP_SCOPES. + log.debug( + "MCP API key matched active account '{}' (identity binding pending Quarkus" + + " SecurityIdentity migration)", + user.get().getUsername()); + } else { + log.warn("MCP access denied: presented API key did not match an active account"); } } - filterChain.doFilter(request, response); + filterChain.doFilter(servletRequest, servletResponse); } private String extractKey(HttpServletRequest request) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java index 7430776def..4a123749cc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java @@ -2,16 +2,21 @@ package stirling.software.proprietary.mcp.security; import java.util.List; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; -import org.springframework.security.oauth2.jwt.Jwt; - /** * RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its * {@code aud} claim. Fails closed when the resource id is unset. + * + *

TODO: Migration required - this was a Spring Security + * {@code OAuth2TokenValidator}. Quarkus-oidc has no equivalent validator SPI; the standard way + * to enforce audience binding is configuration: + * {@code quarkus.oidc.token.audience=} (combined with + * {@code mp.jwt.verify.audiences} for smallrye-jwt). The fail-closed behaviour when no resource id + * is configured must be reproduced either by making that config mandatory or by augmenting the + * {@code io.quarkus.security.identity.SecurityIdentity} via a + * {@code SecurityIdentityAugmentor}. The pure audience-check logic below is preserved so it can be + * invoked from such an augmentor or a custom {@code jakarta.ws.rs.container.ContainerRequestFilter}. */ -public class McpAudienceValidator implements OAuth2TokenValidator { +public class McpAudienceValidator { private final String expectedResourceId; @@ -19,26 +24,37 @@ public class McpAudienceValidator implements OAuth2TokenValidator { this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId; } - @Override - public OAuth2TokenValidatorResult validate(Jwt token) { + /** + * Validates that the supplied token audience claim contains this server's resource id. + * + * @param audience the {@code aud} claim values from the JWT + * @return a result describing success or the failure reason + */ + public Result validate(List audience) { if (expectedResourceId.isBlank()) { - return OAuth2TokenValidatorResult.failure( - new OAuth2Error( - "invalid_token", - "MCP server has no resource id configured; rejecting all tokens" - + " until mcp.auth.resource-id is set.", - null)); + return Result.failure( + "invalid_token", + "MCP server has no resource id configured; rejecting all tokens" + + " until mcp.auth.resource-id is set."); } - List aud = token.getAudience(); - if (aud == null || !aud.contains(expectedResourceId)) { - return OAuth2TokenValidatorResult.failure( - new OAuth2Error( - "invalid_token", - "Token audience does not include this server's resource id (" - + expectedResourceId - + ").", - null)); + if (audience == null || !audience.contains(expectedResourceId)) { + return Result.failure( + "invalid_token", + "Token audience does not include this server's resource id (" + + expectedResourceId + + ")."); + } + return Result.success(); + } + + /** Outcome of an audience validation, replacing Spring's OAuth2TokenValidatorResult. */ + public record Result(boolean valid, String errorCode, String description) { + static Result success() { + return new Result(true, null, null); + } + + static Result failure(String errorCode, String description) { + return new Result(false, errorCode, description); } - return OAuth2TokenValidatorResult.success(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java index 5139ea1621..d29bf8e4dc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAuthenticationEntryPoint.java @@ -2,31 +2,38 @@ package stirling.software.proprietary.mcp.security; import java.io.IOException; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; - +import jakarta.enterprise.context.ApplicationScoped; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Response; /** * Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring * X-Forwarded-* headers to build the public-facing metadata URL. + * + *

TODO: Migration required - this was a Spring Security {@code AuthenticationEntryPoint} + * (commence(...) invoked by the SecurityFilterChain on authentication failure). Quarkus has no + * SecurityFilterChain equivalent. The 401 response must instead be produced by a Quarkus auth + * mechanism / failure handler (e.g. an {@link io.quarkus.security.AuthenticationFailedException} + * mapper via a {@code jakarta.ws.rs.ext.ExceptionMapper}, or a custom HttpAuthenticationMechanism + * sendChallenge). The reusable header-building logic below has been preserved; wire + * {@link #commence(HttpServletRequest, HttpServletResponse)} into that handler. */ -public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint { +@ApplicationScoped +public class McpAuthenticationEntryPoint { private final String metadataPath; + public McpAuthenticationEntryPoint() { + this("/.well-known/oauth-protected-resource"); + } + public McpAuthenticationEntryPoint(String metadataPath) { this.metadataPath = metadataPath == null ? "/.well-known/oauth-protected-resource" : metadataPath; } - @Override - public void commence( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException authException) + public void commence(HttpServletRequest request, HttpServletResponse response) throws IOException { String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme()); String authority = forwardedHost(request, scheme); @@ -34,7 +41,7 @@ public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint { response.setHeader( "WWW-Authenticate", "Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\""); - response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized"); + response.sendError(Response.Status.UNAUTHORIZED.getStatusCode(), "Unauthorized"); } /** host[:port] from forwarded headers when present, else the servlet host/port. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java index b6c410a30c..274f67b8bf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpRequestSizeFilter.java @@ -9,12 +9,13 @@ import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import org.springframework.web.filter.OncePerRequestFilter; - +import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ReadListener; import jakarta.servlet.ServletException; import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; @@ -23,7 +24,11 @@ import jakarta.servlet.http.HttpServletResponse; * Caps MCP request body size (via Content-Length and by buffering up to the cap) and rejects * oversized bodies with a clean 413 before JSON parsing. */ -public class McpRequestSizeFilter extends OncePerRequestFilter { +// TODO: Migration required - this filter was a Spring OncePerRequestFilter; under Quarkus +// (quarkus-undertow) register it as a jakarta.servlet.Filter via @WebFilter or a programmatic +// FilterRegistrationBean equivalent, and ensure it runs once per request and before the MCP +// endpoint. Registration ordering must be verified by the collaborator wiring the servlet filters. +public class McpRequestSizeFilter implements Filter { private final long maxBodyBytes; @@ -32,9 +37,11 @@ public class McpRequestSizeFilter extends OncePerRequestFilter { } @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; long declared = request.getContentLengthLong(); if (declared > maxBodyBytes) { tooLarge(response); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java index ab6341ed39..6349e0bf93 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java @@ -1,38 +1,7 @@ package stirling.software.proprietary.mcp.security; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; -import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.intercept.AuthorizationFilter; -import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; -import org.springframework.web.cors.CorsConfigurationSource; - import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; import lombok.extern.slf4j.Slf4j; @@ -42,37 +11,67 @@ import stirling.software.proprietary.security.service.UserService; /** * MCP security chain: validates JWTs (JWKS + RFC 8707 audience), maps scope claims to authorities, * and fails closed when the issuer is unset. + * + *

TODO: Migration required - this class was a Spring Security {@code SecurityFilterChain} / + * {@code HttpSecurity} DSL configuration, which has NO direct Quarkus equivalent. The Spring + * security DSL has been removed; the equivalent behaviour must be rebuilt on Quarkus primitives: + * + *

    + *
  • HTTP path matching ({@code /mcp}, {@code /mcp/**}, {@code /.well-known/oauth-protected-resource}) + * and authenticated-vs-permitAll policy -> declare via {@code quarkus.http.auth.permission.*} + * in application.properties (permit GET on the metadata path, authenticate the rest), or via a + * {@code jakarta.ws.rs.container.ContainerRequestFilter}. + *
  • Stateless session ({@code SessionCreationPolicy.STATELESS}) and CSRF-disabled -> Quarkus REST + * is stateless by default; no CSRF filter is added unless quarkus-csrf-reactive is enabled. + *
  • OAuth2 resource-server JWT validation (issuer/JWKS + RFC 8707 audience + scope->authority + * mapping) -> quarkus-oidc in {@code service} application type, or quarkus-smallrye-jwt for + * bearer validation. Wire {@code quarkus.oidc.auth-server-url}=issuer-uri, + * {@code quarkus.oidc.token.audience}=resource-id; map the {@code scope} claim to roles via a + * {@code io.quarkus.security.identity.SecurityIdentityAugmentor} (replacing + * {@code JwtGrantedAuthoritiesConverter} with prefix {@code SCOPE_} and the {@code AUDIENCE_} + * authorities added below). The fail-closed behaviour when issuer-uri is blank is preserved by + * NOT configuring quarkus.oidc when blank (every bearer request then 401s). + *
  • API-key mode ({@code mcp.auth.mode=apikey}) -> register {@link McpApiKeyAuthFilter} as a + * {@code jakarta.ws.rs.container.ContainerRequestFilter @Provider} (or a jakarta.servlet + * Filter via quarkus-undertow) that validates the X-API-KEY / Bearer key against + * {@link UserService} and returns the 401 + {@code WWW-Authenticate} response below. + *
  • RFC 9728 protected-resource metadata ({@code /.well-known/oauth-protected-resource} with + * resource/authorizationServer/scopes mcp.tools.read + mcp.tools.write) -> serve from a small + * JAX-RS resource returning the JSON document. + *
  • Pre-auth body-size cap ({@link McpRequestSizeFilter}) and post-auth user binding + * ({@link McpUserBindingFilter}) -> register as ContainerRequestFilters with explicit + * {@code @Priority} so size-cap runs before auth and user-binding runs after; ordering matters. + *
  • Reused CORS source ({@code corsConfigurationSource}) -> configure via {@code quarkus.http.cors.*}. + *
+ * + * The helper components ({@link McpApiKeyAuthFilter}, {@link McpUserBindingFilter}, + * {@link McpRequestSizeFilter}, {@link McpAudienceValidator}, {@link McpAuthenticationEntryPoint}) + * are preserved unchanged and should be wired in by the new Quarkus security plumbing. The + * configuration-reading and fail-closed warning logic below is kept verbatim. */ @Slf4j -@Configuration -@Order(Ordered.HIGHEST_PRECEDENCE) -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +// TODO: Migration required - @Order(Ordered.HIGHEST_PRECEDENCE) and +// @ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") were removed. Gate MCP +// security wiring on the runtime property mcp.enabled=true (the value is a runtime toggle, not a +// build profile, so prefer a runtime guard in the new ContainerRequestFilter/augmentor rather than +// @IfBuildProfile). Filter ordering (highest precedence) must be re-expressed via JAX-RS @Priority +// or quarkus.http.auth.permission ordering. public class McpSecurityConfig { private final ApplicationProperties applicationProperties; - private final UserService userService; - // Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean - // exists. - private final ObjectProvider corsConfigurationSource; + // TODO: Migration required - UserService was injected @Lazy to break a circular wiring with the + // security chain. With the Spring chain removed, inject it directly into the new API-key / + // user-binding ContainerRequestFilters instead of holding it here. + private final UserService userService; private static final String BASE_PATH = "/mcp"; public McpSecurityConfig( - ApplicationProperties applicationProperties, - @Lazy UserService userService, - ObjectProvider corsConfigurationSource) { + ApplicationProperties applicationProperties, UserService userService) { this.applicationProperties = applicationProperties; this.userService = userService; - this.corsConfigurationSource = corsConfigurationSource; - } - - /** Enable CORS on the MCP chain using the app-wide source when available. */ - private void applyCors(HttpSecurity http) throws Exception { - CorsConfigurationSource source = corsConfigurationSource.getIfAvailable(); - if (source != null) { - http.cors(cors -> cors.configurationSource(source)); - } } @PostConstruct @@ -98,165 +97,49 @@ public class McpSecurityConfig { } } - @Bean - @Order(0) - SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http, JwtDecoder mcpJwtDecoder) - throws Exception { - ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth(); - if (isApiKeyMode()) { - return apiKeyFilterChain(http); - } - return oauthFilterChain(http, mcpJwtDecoder, auth); - } - private boolean isApiKeyMode() { return "apikey".equalsIgnoreCase(applicationProperties.getMcp().getAuth().getMode()); } - /** - * API-key chain: a Stirling per-user API key is validated by {@link McpApiKeyAuthFilter}; - * otherwise 401. - */ - private SecurityFilterChain apiKeyFilterChain(HttpSecurity http) throws Exception { - applyCors(http); - http.securityMatcher(BASE_PATH, BASE_PATH + "/**") - // CSRF intentionally disabled: /mcp is a stateless JSON-RPC API authenticated by an - // out-of-band X-API-KEY header (or Authorization: Bearer ). No cookies, no - // session, no form submissions; a browser cannot trick a victim into sending the - // header cross-origin, so the CSRF attack model does not apply. CodeQL flags this - // generically; the SessionCreationPolicy.STATELESS below is the relevant guarantee. - .csrf(csrf -> csrf.disable()) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(a -> a.anyRequest().authenticated()) - .exceptionHandling( - e -> - e.authenticationEntryPoint( - (request, response, ex) -> { - response.setStatus(401); - response.setHeader( - "WWW-Authenticate", - "Bearer realm=\"Stirling MCP (API key)\""); - response.setContentType("application/json"); - response.getWriter() - .write( - "{\"error\":\"unauthorized\",\"message\":\"Provide a valid Stirling API key via the X-API-KEY header (or Authorization: Bearer ).\"}"); - })) - .addFilterBefore( - new McpRequestSizeFilter( - applicationProperties.getMcp().getMaxRequestBytes()), - AuthorizationFilter.class) - // Authenticate before the anonymous filter sets an anonymous token. - .addFilterBefore( - new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class); - return http.build(); - } + // TODO: Migration required - the following describe the original chain wiring so the Quarkus + // re-implementation can reproduce it faithfully. They are documented as constants/notes rather + // than executable HttpSecurity DSL (which does not exist in Quarkus). - /** OAuth2 resource-server chain (JWT, RFC 8707 audience, RFC 9728 metadata). */ - private SecurityFilterChain oauthFilterChain( - HttpSecurity http, JwtDecoder mcpJwtDecoder, ApplicationProperties.Mcp.Auth auth) - throws Exception { - String metadataPath = "/.well-known/oauth-protected-resource"; - applyCors(http); - http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath) - // CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server - // authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no - // session, no form submissions; CSRF requires browser-attached ambient credentials - // and the bearer token is supplied per-request by the MCP client. CodeQL flags - // this generically; the SessionCreationPolicy.STATELESS below is the actual - // guarantee, and the .well-known metadata endpoint only serves GET. - .csrf(csrf -> csrf.disable()) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests( - a -> - a.requestMatchers(HttpMethod.GET, metadataPath) - .permitAll() - .anyRequest() - .authenticated()) - // Cap body size pre-auth, then bind the validated token to a Stirling user after - // the bearer filter. - .addFilterBefore( - new McpRequestSizeFilter( - applicationProperties.getMcp().getMaxRequestBytes()), - BearerTokenAuthenticationFilter.class) - .addFilterAfter( - new McpUserBindingFilter( - userService, - auth.getUsernameClaim(), - auth.isRequireExistingAccount()), - BearerTokenAuthenticationFilter.class) - .oauth2ResourceServer( - oauth2 -> - oauth2.authenticationEntryPoint( - new McpAuthenticationEntryPoint(metadataPath)) - // RFC 9728 protected-resource metadata for OAuth discovery. - .protectedResourceMetadata( - prm -> - prm.protectedResourceMetadataCustomizer( - builder -> { - if (!auth.getResourceId() - .isBlank()) { - builder.resource( - auth - .getResourceId()); - } - if (!auth.getIssuerUri() - .isBlank()) { - builder.authorizationServer( - auth - .getIssuerUri()); - } - builder.scope("mcp.tools.read"); - builder.scope( - "mcp.tools.write"); - })) - .jwt( - jwt -> - jwt.decoder(mcpJwtDecoder) - .jwtAuthenticationConverter( - mcpJwtAuthenticationConverter()))); - return http.build(); - } + // API-key chain (mcp.auth.mode=apikey): securityMatcher(BASE_PATH, BASE_PATH + "/**"); + // CSRF disabled (stateless JSON-RPC, X-API-KEY / Bearer , no cookies/session); + // SessionCreationPolicy.STATELESS; anyRequest().authenticated(); + // authenticationEntryPoint -> 401 with header WWW-Authenticate: Bearer realm="Stirling MCP + // (API key)", Content-Type application/json, body + // {"error":"unauthorized","message":"Provide a valid Stirling API key via the X-API-KEY + // header (or Authorization: Bearer )."}; + // addFilterBefore(new McpRequestSizeFilter(maxRequestBytes), AuthorizationFilter.class); + // addFilterBefore(new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class) + // (authenticate before any anonymous token is set). - @Bean - JwtDecoder mcpJwtDecoder() { - ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth(); - if (auth.getIssuerUri().isBlank()) { - // Fail-closed decoder: rejects every token until the issuer is set. - return token -> { - throw new org.springframework.security.oauth2.jwt.BadJwtException( - "mcp.auth.issuer-uri is not configured"); - }; - } - String jwksUri = auth.getJwksUri(); - NimbusJwtDecoder decoder = - jwksUri.isBlank() - ? NimbusJwtDecoder.withIssuerLocation(auth.getIssuerUri()).build() - : NimbusJwtDecoder.withJwkSetUri(jwksUri).build(); - OAuth2TokenValidator defaultValidators = - JwtValidators.createDefaultWithIssuer(auth.getIssuerUri()); - OAuth2TokenValidator combined = - new DelegatingOAuth2TokenValidator<>( - defaultValidators, new McpAudienceValidator(auth.getResourceId())); - decoder.setJwtValidator(combined); - return decoder; - } + // OAuth2 resource-server chain: metadataPath = "/.well-known/oauth-protected-resource"; + // securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath); + // CSRF disabled; SessionCreationPolicy.STATELESS; + // GET metadataPath permitAll, anyRequest().authenticated(); + // addFilterBefore(new McpRequestSizeFilter(maxRequestBytes), BearerTokenAuthenticationFilter.class); + // addFilterAfter(new McpUserBindingFilter(userService, auth.getUsernameClaim(), + // auth.isRequireExistingAccount()), BearerTokenAuthenticationFilter.class); + // oauth2ResourceServer: authenticationEntryPoint = new McpAuthenticationEntryPoint(metadataPath); + // RFC 9728 protected-resource metadata -> resource=auth.getResourceId() (if non-blank), + // authorizationServer=auth.getIssuerUri() (if non-blank), scopes mcp.tools.read + + // mcp.tools.write; + // jwt: decoder=mcpJwtDecoder, jwtAuthenticationConverter=mcpJwtAuthenticationConverter. - private Converter mcpJwtAuthenticationConverter() { - JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); - scopes.setAuthorityPrefix("SCOPE_"); - scopes.setAuthoritiesClaimName("scope"); - JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); - converter.setJwtGrantedAuthoritiesConverter( - jwt -> { - Collection out = new ArrayList<>(scopes.convert(jwt)); - List aud = jwt.getAudience(); - if (aud != null) { - for (String a : aud) { - out.add(new SimpleGrantedAuthority("AUDIENCE_" + a)); - } - } - return out; - }); - return converter; - } + // JWT decoder (was @Bean JwtDecoder mcpJwtDecoder): fail-closed when auth.getIssuerUri() is + // blank (reject every token); else NimbusJwtDecoder.withJwkSetUri(jwksUri) when jwks-uri set, + // otherwise NimbusJwtDecoder.withIssuerLocation(issuerUri); validators = + // DelegatingOAuth2TokenValidator(default-with-issuer, new McpAudienceValidator(resourceId)). + // -> Replace with quarkus-oidc/quarkus-smallrye-jwt config (auth-server-url=issuer-uri, + // token.audience=resource-id, jwks via discovery or quarkus.oidc.jwks-path). Keep + // McpAudienceValidator's audience logic in a custom validator if OIDC's audience check is + // insufficient. Do NOT configure when issuer-uri is blank to preserve fail-closed behaviour. + + // JWT authentication converter (scope -> authority mapping): map the "scope" claim to authorities + // with prefix "SCOPE_", and additionally add "AUDIENCE_" for each audience entry on the + // token. -> Re-implement in a io.quarkus.security.identity.SecurityIdentityAugmentor that adds + // roles "SCOPE_" and "AUDIENCE_" to the SecurityIdentity. } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java index 59dfbbfacc..d553ef25ca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpUserBindingFilter.java @@ -3,16 +3,11 @@ package stirling.software.proprietary.mcp.security; import java.io.IOException; import java.util.Optional; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.web.filter.OncePerRequestFilter; - +import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -28,9 +23,21 @@ import tools.jackson.databind.node.ObjectNode; * Binds an MCP-validated JWT to a provisioned Stirling user: optionally rejects subjects with no * enabled account, then rebinds the principal to the canonical Stirling username (scope authorities * only) so audit/metering attribute correctly. + * + *

TODO: Migration required - this was a Spring Security {@code OncePerRequestFilter} that read + * and rewrote the {@code SecurityContextHolder} ({@code JwtAuthenticationToken}/{@code Jwt}). + * Quarkus has no global mutable security context; the canonical replacement is a + * {@code io.quarkus.security.identity.SecurityIdentityAugmentor} that runs after quarkus-oidc/ + * quarkus-smallrye-jwt validates the bearer token, reads the username claim from the + * {@code JsonWebToken}, looks up the Stirling account via {@link UserService}, and rebuilds the + * {@code SecurityIdentity} with the canonical principal name while preserving the original scope + * roles. The account-lookup and reject logic below is preserved; only the identity read/rebind and + * the request rejection plumbing still need to be wired to the augmentor (or to a + * {@code jakarta.ws.rs.container.ContainerRequestFilter @Provider} that aborts with 403). Until + * then this filter passes every request through unchanged. */ @Slf4j -public class McpUserBindingFilter extends OncePerRequestFilter { +public class McpUserBindingFilter implements Filter { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -47,15 +54,19 @@ public class McpUserBindingFilter extends OncePerRequestFilter { } @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain) throws ServletException, IOException { - Authentication current = SecurityContextHolder.getContext().getAuthentication(); + HttpServletResponse response = (HttpServletResponse) res; - // Only act on a JWT-authenticated request; everything else passes through. - if (current instanceof JwtAuthenticationToken jwtAuth && jwtAuth.isAuthenticated()) { - Jwt jwt = jwtAuth.getToken(); - String username = jwt.getClaimAsString(usernameClaim); + // TODO: Migration required - extract the validated JWT and its claims from the Quarkus + // SecurityIdentity / JsonWebToken instead of Spring's SecurityContextHolder. The block + // below preserves the original binding logic but cannot run until that wiring exists, so + // for now every request passes through untouched. + boolean jwtAuthenticated = false; // TODO: derive from injected SecurityIdentity / JWT + if (jwtAuthenticated) { + // TODO: Migration required - read the claim value from the validated token, e.g. + // jsonWebToken.getClaim(usernameClaim). Placeholder keeps the surrounding logic intact. + String username = null; // TODO: jwt.getClaim(usernameClaim) if (username == null || username.isBlank()) { reject( @@ -85,17 +96,15 @@ public class McpUserBindingFilter extends OncePerRequestFilter { boundUsername = account.get().getUsername(); } - // Rebind to the Stirling username, carrying only the OAuth scope authorities. - UsernamePasswordAuthenticationToken bound = - new UsernamePasswordAuthenticationToken( - boundUsername, null, jwtAuth.getAuthorities()); - bound.setDetails(jwtAuth.getDetails()); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(bound); - SecurityContextHolder.setContext(context); + // TODO: Migration required - rebind to the Stirling username, carrying only the OAuth + // scope authorities. With quarkus-oidc/smallrye-jwt this is done by a + // SecurityIdentityAugmentor that returns a new SecurityIdentity whose principal name is + // boundUsername and whose roles are the original token scopes. boundUsername is computed + // above and ready to feed into that augmentor. + log.debug("MCP user binding resolved canonical username: {}", boundUsername); } - filterChain.doFilter(request, response); + filterChain.doFilter(req, res); } /** Strip CR/LF so a crafted claim value can't forge log lines. */ @@ -104,7 +113,10 @@ public class McpUserBindingFilter extends OncePerRequestFilter { } private void reject(HttpServletResponse response, String message) throws IOException { - SecurityContextHolder.clearContext(); + // TODO: Migration required - on the Quarkus path, rejection should clear/deny the + // SecurityIdentity (augmentor throws AuthenticationFailedException) or the + // ContainerRequestFilter should abortWith(Response.status(403)...). The 403 JSON body below + // is preserved as the intended response shape. response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json"); ObjectNode body = MAPPER.createObjectNode(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java index 8c75ee1459..b066f4971c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/AbstractCategoryTool.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.mcp.tools; import java.util.List; -import org.springframework.beans.factory.ObjectProvider; +import jakarta.enterprise.inject.Instance; import stirling.software.proprietary.mcp.McpCallContext; import stirling.software.proprietary.mcp.McpTool; @@ -22,13 +22,13 @@ import tools.jackson.databind.node.ObjectNode; abstract class AbstractCategoryTool implements McpTool { protected final ObjectMapper mapper; - protected final ObjectProvider catalogProvider; - protected final ObjectProvider executorProvider; + protected final Instance catalogProvider; + protected final Instance executorProvider; protected AbstractCategoryTool( ObjectMapper mapper, - ObjectProvider catalog, - ObjectProvider executor) { + Instance catalog, + Instance executor) { this.mapper = mapper; this.catalogProvider = catalog; this.executorProvider = executor; @@ -37,7 +37,7 @@ abstract class AbstractCategoryTool implements McpTool { protected abstract OperationCategory category(); protected List enabledOperations() { - McpToolCatalog catalog = catalogProvider.getIfAvailable(); + McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null; if (catalog == null) { return List.of(); } @@ -104,7 +104,7 @@ abstract class AbstractCategoryTool implements McpTool { return operationListError(null); } String opId = opNode.asText(); - McpToolCatalog catalog = catalogProvider.getIfAvailable(); + McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null; if (catalog == null) { return McpResponses.error(mapper, "MCP catalog is not available"); } @@ -118,7 +118,8 @@ abstract class AbstractCategoryTool implements McpTool { mapper, "Insufficient scope: this operation requires '" + meta.requiredScope() + "'."); } - McpOperationExecutor executor = executorProvider.getIfAvailable(); + McpOperationExecutor executor = + executorProvider.isResolvable() ? executorProvider.get() : null; if (executor == null) { return McpResponses.error(mapper, "MCP execution is not available."); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java index c93578136a..25957cb493 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/DescribeOperationTool.java @@ -1,8 +1,9 @@ package stirling.software.proprietary.mcp.tools; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; import stirling.software.proprietary.mcp.McpCallContext; import stirling.software.proprietary.mcp.McpTool; @@ -15,14 +16,15 @@ import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ObjectNode; /** Returns the JSON Schema for one operation's parameters, from the live {@link McpToolCatalog}. */ -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class DescribeOperationTool implements McpTool { private final ObjectMapper mapper; - private final ObjectProvider catalogProvider; + private final Instance catalogProvider; - public DescribeOperationTool(ObjectMapper mapper, ObjectProvider catalog) { + @Inject + public DescribeOperationTool(ObjectMapper mapper, Instance catalog) { this.mapper = mapper; this.catalogProvider = catalog; } @@ -63,7 +65,8 @@ public class DescribeOperationTool implements McpTool { return McpResponses.error(mapper, "Missing required argument: operation"); } String opId = opNode.asText(); - McpToolCatalog catalog = catalogProvider.getIfAvailable(); + McpToolCatalog catalog = + catalogProvider.isResolvable() ? catalogProvider.get() : null; if (catalog == null) { return McpResponses.error(mapper, "MCP catalog is not available"); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java index 28cb56d965..08824be6a7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/McpOperationExecutor.java @@ -1,25 +1,24 @@ package stirling.software.proprietary.mcp.tools; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.core.io.Resource; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClientResponseException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.io.InputStreamResource; +import stirling.software.common.model.io.Resource; import stirling.software.common.service.FileStorage; import stirling.software.common.service.InternalApiClient; import stirling.software.common.service.InternalApiTimeoutException; @@ -35,8 +34,11 @@ import tools.jackson.databind.node.ObjectNode; * to the Stirling endpoint over the loopback via {@link InternalApiClient}, and stores the result. */ @Slf4j -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +// TODO: Migration required - the Spring @ConditionalOnProperty(name = "mcp.enabled", +// havingValue = "true") guard is not directly portable. For a build-time toggle use +// @io.quarkus.arc.lookup.LookupIfProperty(name = "mcp.enabled", stringValue = "true") on the +// injection points, or gate the call sites at runtime; this bean is otherwise always created. public class McpOperationExecutor { private final ObjectMapper mapper; @@ -95,11 +97,13 @@ public class McpOperationExecutor { inputName = fileName != null ? fileName : "input.pdf"; } - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("fileInput", bytesResource(inputBytes, inputName)); + // The migrated InternalApiClient takes a Map> (replacing Spring's + // MultiValueMap) and returns a jakarta.ws.rs.core.Response. + Map> body = new LinkedHashMap<>(); + addToBody(body, "fileInput", bytesResource(inputBytes, inputName)); addParameters(body, arguments == null ? null : arguments.get("parameters")); - ResponseEntity response; + Response response; try { response = internalApiClient.post(meta.endpointPath(), body); } catch (InternalApiTimeoutException e) { @@ -109,34 +113,39 @@ public class McpOperationExecutor { + " timed out after " + e.getReadTimeout().toSeconds() + "s. Try a smaller file or a different approach."); - } catch (RestClientResponseException e) { - log.warn( - "MCP {} upstream error: HTTP {} - {}", - meta.id(), - e.getStatusCode().value(), - snippet(e.getResponseBodyAsString())); - return McpResponses.error( - mapper, meta.id() + " failed: HTTP " + e.getStatusCode().value() + "."); } catch (SecurityException e) { return McpResponses.error( mapper, meta.id() + " endpoint is not permitted for MCP dispatch."); + } catch (UncheckedIOException e) { + log.warn("MCP execution of {} failed", meta.id(), e); + return McpResponses.error( + mapper, meta.id() + " failed unexpectedly. See server logs for details."); } catch (RuntimeException e) { log.warn("MCP execution of {} failed", meta.id(), e); return McpResponses.error( mapper, meta.id() + " failed unexpectedly. See server logs for details."); } + + // Spring's RestTemplate threw RestClientResponseException on non-2xx upstream responses; + // the migrated HttpClient-based InternalApiClient returns the upstream status as a Response. + int status = response.getStatus(); + if (status < 200 || status >= 300) { + String responseBody = readErrorBody(response); + log.warn("MCP {} upstream error: HTTP {} - {}", meta.id(), status, snippet(responseBody)); + return McpResponses.error(mapper, meta.id() + " failed: HTTP " + status + "."); + } return buildResult(meta, response); } - private ObjectNode buildResult(OperationMeta meta, ResponseEntity response) { - Resource body = response.getBody(); + private ObjectNode buildResult(OperationMeta meta, Response response) { + Resource body = (Resource) response.getEntity(); if (body == null) { return McpResponses.error(mapper, meta.id() + " returned an empty response."); } - MediaType contentType = response.getHeaders().getContentType(); + MediaType contentType = response.getMediaType(); // A JSON body is a structured report (e.g. get-info), not a file. - if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + if (contentType != null && MediaType.APPLICATION_JSON_TYPE.isCompatible(contentType)) { try (InputStream is = body.getInputStream()) { return McpResponses.text( mapper, new String(is.readAllBytes(), StandardCharsets.UTF_8)); @@ -152,7 +161,7 @@ public class McpOperationExecutor { String mimeType = contentType != null ? contentType.toString() - : MediaType.APPLICATION_OCTET_STREAM_VALUE; + : MediaType.APPLICATION_OCTET_STREAM; long maxInline = applicationProperties.getMcp().getMaxInlineResponseBytes(); try { long size = body.contentLength(); @@ -207,7 +216,7 @@ public class McpOperationExecutor { } } - private void addParameters(MultiValueMap body, JsonNode params) { + private void addParameters(Map> body, JsonNode params) { if (params == null || !params.isObject()) { return; } @@ -220,31 +229,54 @@ public class McpOperationExecutor { } if (value instanceof List list) { if (containsStructured(list)) { - body.add(entry.getKey(), mapper.writeValueAsString(list)); + addToBody(body, entry.getKey(), mapper.writeValueAsString(list)); } else { - list.forEach(item -> body.add(entry.getKey(), item)); + list.forEach(item -> addToBody(body, entry.getKey(), item)); } } else if (value instanceof Map) { - body.add(entry.getKey(), mapper.writeValueAsString(value)); + addToBody(body, entry.getKey(), mapper.writeValueAsString(value)); } else { - body.add(entry.getKey(), value); + addToBody(body, entry.getKey(), value); } } } + /** + * Add a value to a multi-value form body. The body is a {@code Map>} + * (replacing Spring's {@code MultiValueMap}) because the migrated {@link InternalApiClient} + * encodes the multipart request manually. + */ + private static void addToBody(Map> body, String key, Object value) { + body.computeIfAbsent(key, k -> new java.util.ArrayList<>()).add(value); + } + private static boolean containsStructured(List list) { return list.stream().anyMatch(item -> item instanceof Map || item instanceof List); } private static Resource bytesResource(byte[] bytes, String filename) { - return new ByteArrayResource(bytes) { + return new InputStreamResource(new ByteArrayInputStream(bytes), filename) { @Override - public String getFilename() { - return filename; + public long contentLength() { + return bytes.length; } }; } + private static String readErrorBody(Response response) { + try { + Object entity = response.getEntity(); + if (entity instanceof Resource resource) { + try (InputStream is = resource.getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + return entity != null ? String.valueOf(entity) : null; + } catch (IOException e) { + return null; + } + } + private static String snippet(String body) { if (body == null || body.isBlank()) { return "(no body)"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java index fc3696b658..ca8872da90 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingAiTool.java @@ -3,9 +3,10 @@ package stirling.software.proprietary.mcp.tools; import java.io.IOException; import java.util.List; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; @@ -26,18 +27,19 @@ import tools.jackson.databind.node.ObjectNode; * capabilities manifest. */ @Slf4j -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class StirlingAiTool implements McpTool { private final ObjectMapper mapper; - private final ObjectProvider catalogProvider; - private final ObjectProvider engineClientProvider; + private final Instance catalogProvider; + private final Instance engineClientProvider; + @Inject public StirlingAiTool( ObjectMapper mapper, - ObjectProvider catalog, - ObjectProvider engineClient) { + Instance catalog, + Instance engineClient) { this.mapper = mapper; this.catalogProvider = catalog; this.engineClientProvider = engineClient; @@ -99,7 +101,7 @@ public class StirlingAiTool implements McpTool { return McpResponses.error(mapper, "Missing required argument: operation"); } String opId = opNode.asText(); - McpToolCatalog catalog = catalogProvider.getIfAvailable(); + McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null; if (catalog == null) { return McpResponses.error(mapper, "MCP catalog is not available"); } @@ -117,7 +119,8 @@ public class StirlingAiTool implements McpTool { mapper, "Insufficient scope: this capability requires '" + meta.requiredScope() + "'."); } - AiEngineClient client = engineClientProvider.getIfAvailable(); + AiEngineClient client = + engineClientProvider.isResolvable() ? engineClientProvider.get() : null; if (client == null) { return McpResponses.error( mapper, "AI engine client is not configured - enable aiEngine in settings."); @@ -140,7 +143,7 @@ public class StirlingAiTool implements McpTool { } private List aiOps() { - McpToolCatalog catalog = catalogProvider.getIfAvailable(); + McpToolCatalog catalog = catalogProvider.isResolvable() ? catalogProvider.get() : null; if (catalog == null) { return List.of(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java index 9e89f6a5c9..16618f0915 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingConvertTool.java @@ -1,8 +1,9 @@ package stirling.software.proprietary.mcp.tools; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; + +import io.quarkus.arc.lookup.LookupIfProperty; import stirling.software.proprietary.mcp.catalog.McpToolCatalog; import stirling.software.proprietary.mcp.catalog.OperationCategory; @@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory; import tools.jackson.databind.ObjectMapper; /** Exposes the {@code /api/v1/convert/*} namespace as a single MCP tool. */ -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class StirlingConvertTool extends AbstractCategoryTool { public StirlingConvertTool( ObjectMapper mapper, - ObjectProvider catalog, - ObjectProvider executor) { + Instance catalog, + Instance executor) { super(mapper, catalog, executor); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java index 22fad23845..696863240b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingDownloadTool.java @@ -3,9 +3,10 @@ package stirling.software.proprietary.mcp.tools; import java.io.IOException; import java.util.Base64; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.MediaType; + +import io.quarkus.arc.lookup.LookupIfProperty; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.FileStorage; @@ -20,8 +21,8 @@ import tools.jackson.databind.node.ObjectNode; * Fetches a stored file's content by fileId, returned inline as base64. For large results that were * not returned inline by an operation. */ -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class StirlingDownloadTool implements McpTool { private final ObjectMapper mapper; @@ -102,7 +103,7 @@ public class StirlingDownloadTool implements McpTool { McpResponses.resourceBlock( mapper, "stirling://file/" + fileId, - MediaType.APPLICATION_OCTET_STREAM_VALUE, + MediaType.APPLICATION_OCTET_STREAM, Base64.getEncoder().encodeToString(bytes))); } catch (SecurityException e) { return McpResponses.error(mapper, "Unknown or inaccessible fileId '" + fileId + "'."); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java index 3d76f47a7f..382ec6925e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingMiscTool.java @@ -1,8 +1,9 @@ package stirling.software.proprietary.mcp.tools; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; + +import io.quarkus.arc.lookup.LookupIfProperty; import stirling.software.proprietary.mcp.catalog.McpToolCatalog; import stirling.software.proprietary.mcp.catalog.OperationCategory; @@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory; import tools.jackson.databind.ObjectMapper; /** Exposes the {@code /api/v1/misc/*} namespace as a single MCP tool. */ -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class StirlingMiscTool extends AbstractCategoryTool { public StirlingMiscTool( ObjectMapper mapper, - ObjectProvider catalog, - ObjectProvider executor) { + Instance catalog, + Instance executor) { super(mapper, catalog, executor); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java index a736a78127..661eaf4f1f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingPagesTool.java @@ -1,8 +1,9 @@ package stirling.software.proprietary.mcp.tools; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; + +import io.quarkus.arc.lookup.LookupIfProperty; import stirling.software.proprietary.mcp.catalog.McpToolCatalog; import stirling.software.proprietary.mcp.catalog.OperationCategory; @@ -10,14 +11,14 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory; import tools.jackson.databind.ObjectMapper; /** Exposes the {@code /api/v1/general/*} (page operations) namespace as a single MCP tool. */ -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class StirlingPagesTool extends AbstractCategoryTool { public StirlingPagesTool( ObjectMapper mapper, - ObjectProvider catalog, - ObjectProvider executor) { + Instance catalog, + Instance executor) { super(mapper, catalog, executor); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java index 300b6c1d09..323aeb2258 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingSecurityTool.java @@ -1,8 +1,10 @@ package stirling.software.proprietary.mcp.tools; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import io.quarkus.arc.lookup.LookupIfProperty; import stirling.software.proprietary.mcp.catalog.McpToolCatalog; import stirling.software.proprietary.mcp.catalog.OperationCategory; @@ -10,14 +12,15 @@ import stirling.software.proprietary.mcp.catalog.OperationCategory; import tools.jackson.databind.ObjectMapper; /** Exposes the {@code /api/v1/security/*} namespace as a single MCP tool. */ -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class StirlingSecurityTool extends AbstractCategoryTool { + @Inject public StirlingSecurityTool( ObjectMapper mapper, - ObjectProvider catalog, - ObjectProvider executor) { + Instance catalog, + Instance executor) { super(mapper, catalog, executor); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java index ebee4e4122..c88d1f06b9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/tools/StirlingUploadTool.java @@ -2,8 +2,10 @@ package stirling.software.proprietary.mcp.tools; import java.io.IOException; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +import io.quarkus.arc.lookup.LookupIfProperty; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; @@ -20,13 +22,14 @@ import tools.jackson.databind.node.ObjectNode; * most operations accept the file inline via their {@code file} argument. */ @Slf4j -@Component -@ConditionalOnProperty(name = "mcp.enabled", havingValue = "true") +@ApplicationScoped +@LookupIfProperty(name = "mcp.enabled", stringValue = "true") public class StirlingUploadTool implements McpTool { private final ObjectMapper mapper; private final FileStorage fileStorage; + @Inject public StirlingUploadTool(ObjectMapper mapper, FileStorage fileStorage) { this.mapper = mapper; this.fileStorage = fileStorage; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowFileInput.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowFileInput.java index c83fa55698..4142eee098 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowFileInput.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowFileInput.java @@ -1,11 +1,11 @@ package stirling.software.proprietary.model.api.ai; -import org.springframework.http.MediaType; -import org.springframework.web.multipart.MultipartFile; - import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.core.MediaType; + +import stirling.software.common.model.MultipartFile; import lombok.Data; @@ -16,7 +16,7 @@ public class AiWorkflowFileInput { @NotNull @Schema( description = "The input PDF file", - contentMediaType = MediaType.APPLICATION_PDF_VALUE, + contentMediaType = MediaType.APPLICATION_PDF, format = "binary") private MultipartFile fileInput; } 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 index 6ce947d098..68def30285 100644 --- 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 @@ -2,8 +2,6 @@ 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; @@ -20,11 +18,12 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint; @EqualsAndHashCode public class AuditDateExportRequest { - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + // TODO: Migration required - Spring @DateTimeFormat(iso = ISO.DATE) removed; JAX-RS binds + // LocalDate via its default ISO-8601 (yyyy-MM-dd) ParamConverter, so ISO.DATE form values + // still bind. If a non-ISO format is ever needed, register a jakarta.ws.rs.ext.ParamConverter. @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/policy/config/FolderAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java index 3abf919f1d..aecf9376b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java @@ -2,11 +2,14 @@ package stirling.software.proprietary.policy.config; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import org.springframework.core.env.Environment; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.smallrye.config.SmallRyeConfig; + +import org.eclipse.microprofile.config.Config; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; @@ -34,7 +37,7 @@ import stirling.software.proprietary.policy.model.Policy; * root. (Symlink escape is not defended here; an operator who configures an allowed root containing * a symlink to a sensitive location is trusted.) */ -@Component +@ApplicationScoped public class FolderAccessGuard { public static final String FOLDER_TYPE = "folder"; @@ -43,8 +46,12 @@ public class FolderAccessGuard { private final List allowedRoots; private final List protectedRoots; - public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) { - this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas"); + @Inject + public FolderAccessGuard(ApplicationProperties applicationProperties, Config config) { + // Spring's Environment.getActiveProfiles() maps to SmallRye's profile list; the "saas" + // build/runtime profile is matched the same way Spring matched the "saas" Spring profile. + this.saasActive = + config.unwrap(SmallRyeConfig.class).getProfiles().contains("saas"); this.allowedRoots = normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots()); this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath()))); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index df65fbd99c..9177f4a751 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -1,39 +1,42 @@ package stirling.software.proprietary.policy.controller; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.util.MultiValueMap; -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.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.multipart.MultipartHttpServletRequest; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.jboss.resteasy.reactive.server.multipart.FormValue; +import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.sse.OutboundSseEvent; +import jakarta.ws.rs.sse.Sse; +import jakarta.ws.rs.sse.SseEventSink; + import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.io.FileSystemResource; +import stirling.software.common.model.io.Resource; import stirling.software.common.model.job.JobResponse; import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.TempFile; @@ -66,25 +69,27 @@ import tools.jackson.databind.ObjectMapper; * the file ids in the run view. */ @Slf4j -@RestController -@RequestMapping("/api/v1/policies") +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/policies") @Hidden @PremiumEndpoint -@RequiredArgsConstructor @Tag(name = "Policies", description = "Run tool pipelines on the backend") public class PolicyController { - private final PolicyRunner policyRunner; - private final PolicyRunRegistry runRegistry; - private final PolicyStore policyStore; - private final PolicyValidator policyValidator; - private final FolderAccessGuard folderAccessGuard; - private final UserServiceInterface userService; - private final ApplicationProperties applicationProperties; - private final ObjectMapper objectMapper; - private final TempFileManager tempFileManager; + @Inject PolicyRunner policyRunner; + @Inject PolicyRunRegistry runRegistry; + @Inject PolicyStore policyStore; + @Inject PolicyValidator policyValidator; + @Inject FolderAccessGuard folderAccessGuard; + @Inject UserServiceInterface userService; + @Inject ApplicationProperties applicationProperties; + @Inject ObjectMapper objectMapper; + @Inject TempFileManager tempFileManager; - @PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @jakarta.ws.rs.Path("/run") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Run a tool pipeline", description = @@ -93,34 +98,40 @@ public class PolicyController { + " 'company-logo'), and a JSON pipeline definition ('json'). Runs the" + " steps in order asynchronously and returns a run id. Poll the run" + " status endpoint and download outputs via /api/v1/general/files/{id}.") - public ResponseEntity> run( - @RequestParam("json") String json, MultipartHttpServletRequest request) - throws IOException { + public Response run(MultipartFormDataInput request) throws IOException { + String json = formValue(request, "json"); PipelineDefinition definition = parseDefinition(json); PolicyInputs inputs = collectInputs(request); String runId = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId(); - return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); + return Response.status(Response.Status.ACCEPTED) + .entity(new JobResponse<>(true, runId, null)) + .build(); } - @PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @jakarta.ws.rs.Path("/run/stream") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.SERVER_SENT_EVENTS) @Operation( summary = "Run a tool pipeline with live progress", description = "Same as /run, but returns Server-Sent Events: a 'step' event as each step" + " starts and completes, then a terminal 'completed', 'failed'," + " 'cancelled', or 'waiting' event carrying the final run view.") - public SseEmitter runStream( - @RequestParam("json") String json, MultipartHttpServletRequest request) + public void runStream( + MultipartFormDataInput request, @Context SseEventSink eventSink, @Context Sse sse) throws IOException { + String json = formValue(request, "json"); PipelineDefinition definition = parseDefinition(json); PolicyInputs inputs = collectInputs(request); - SseEmitter emitter = - new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs()); - emitter.onError(e -> log.warn("Policy run SSE emitter error", e)); + // TODO: Migration required - Spring's SseEmitter supported a configurable timeout + // (applicationProperties.getPolicies().getStreamTimeoutMs()). JAX-RS SseEventSink has no + // per-sink timeout; configure via quarkus.http.* / a reverse proxy if a hard cap is needed. - PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter)); + PolicyRunHandle handle = + policyRunner.runAdHoc(definition, inputs, streamListener(eventSink, sse)); // Close the stream with a terminal event once the run finishes. whenComplete runs on the // engine's worker thread after the run is done, so this never races the step events. handle.completion() @@ -128,46 +139,51 @@ public class PolicyController { (run, throwable) -> { if (throwable != null) { sendEvent( - emitter, + eventSink, + sse, "failed", Map.of("message", throwable.getMessage())); } else { - sendEvent(emitter, terminalEventName(run), PolicyRunView.of(run)); + sendEvent( + eventSink, sse, terminalEventName(run), PolicyRunView.of(run)); } - emitter.complete(); + eventSink.close(); }); - return emitter; } - @GetMapping("/run/{runId}") + @GET + @jakarta.ws.rs.Path("/run/{runId}") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Get pipeline run status", description = "Returns the current status, step cursor, and output files of a run.") - public ResponseEntity status(@PathVariable String runId) { + public Response status(@PathParam("runId") String runId) { PolicyRun run = runRegistry.get(runId); if (run == null) { - return ResponseEntity.notFound().build(); + return Response.status(Response.Status.NOT_FOUND).build(); } - return ResponseEntity.ok(PolicyRunView.of(run)); + return Response.ok(PolicyRunView.of(run)).build(); } // --- Policy management --- - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Create or update a policy", description = "Stores a policy (trigger config + steps + output + metadata). A blank id is" + " assigned; returns the stored policy with its id.") - public ResponseEntity savePolicy(@RequestBody String json) { + public Response savePolicy(String json) { Policy policy = parsePolicy(json); requireAuthorizedForFolderAccess(policy); try { policyValidator.validate(policy); } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); } - return ResponseEntity.ok(policyStore.save(policy)); + return Response.ok(policyStore.save(policy)).build(); } /** @@ -184,36 +200,43 @@ public class PolicyController { return; } if (!userService.isCurrentUserAdmin()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Folder sources and outputs may only be configured by an administrator"); + throw new WebApplicationException( + "Folder sources and outputs may only be configured by an administrator", + Response.Status.FORBIDDEN); } } - @GetMapping + @GET + @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "List policies") public List listPolicies() { return policyStore.all(); } - @GetMapping("/{policyId}") + @GET + @jakarta.ws.rs.Path("/{policyId}") + @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "Get a policy by id") - public ResponseEntity getPolicy(@PathVariable String policyId) { + public Response getPolicy(@PathParam("policyId") String policyId) { return policyStore .get(policyId) - .map(ResponseEntity::ok) - .orElseGet(() -> ResponseEntity.notFound().build()); + .map(policy -> Response.ok(policy).build()) + .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build()); } - @DeleteMapping("/{policyId}") + @DELETE + @jakarta.ws.rs.Path("/{policyId}") @Operation(summary = "Delete a policy by id") - public ResponseEntity deletePolicy(@PathVariable String policyId) { + public Response deletePolicy(@PathParam("policyId") String policyId) { return policyStore.delete(policyId) - ? ResponseEntity.noContent().build() - : ResponseEntity.notFound().build(); + ? Response.noContent().build() + : Response.status(Response.Status.NOT_FOUND).build(); } - @PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @jakarta.ws.rs.Path("/{policyId}/run") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Run a stored policy", description = @@ -221,25 +244,29 @@ public class PolicyController { + " under 'fileInput', supporting files under their asset-key fields)." + " Runs regardless of the policy's enabled flag, which only gates" + " automatic triggering. Returns a run id.") - public ResponseEntity> runStoredPolicy( - @PathVariable String policyId, MultipartHttpServletRequest request) throws IOException { + public Response runStoredPolicy( + @PathParam("policyId") String policyId, MultipartFormDataInput request) + throws IOException { Policy policy = policyStore .get(policyId) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "No policy: " + policyId)); + new WebApplicationException( + "No policy: " + policyId, + Response.Status.NOT_FOUND)); PolicyInputs inputs = collectInputs(request); String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId(); - return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); + return Response.status(Response.Status.ACCEPTED) + .entity(new JobResponse<>(true, runId, null)) + .build(); } private Policy parsePolicy(String json) { try { return objectMapper.readValue(json, Policy.class); } catch (JacksonException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON"); + throw new WebApplicationException("Invalid policy JSON", Response.Status.BAD_REQUEST); } } @@ -248,26 +275,43 @@ public class PolicyController { try { definition = objectMapper.readValue(json, PipelineDefinition.class); } catch (JacksonException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON"); + throw new WebApplicationException( + "Invalid pipeline definition JSON", Response.Status.BAD_REQUEST); } if (definition.steps().isEmpty()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Pipeline definition has no steps"); + throw new WebApplicationException( + "Pipeline definition has no steps", Response.Status.BAD_REQUEST); } return definition; } + /** + * Extract a single text form field from the multipart request, mirroring Spring's + * {@code @RequestParam} behaviour (missing field -> 400). + */ + private static String formValue(MultipartFormDataInput request, String field) { + Collection values = request.getValues().get(field); + if (values != null) { + for (FormValue value : values) { + if (!value.isFileItem()) { + return value.getValue(); + } + } + } + throw new WebApplicationException( + "Missing required field: " + field, Response.Status.BAD_REQUEST); + } + /** * Split the multipart file parts into the primary document stream ("fileInput") and the named * supporting-file store: every other file field becomes an asset keyed by its field name, which * a step references from {@code fileParameters}. */ - private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException { - MultiValueMap fileMap = request.getMultiFileMap(); - List primary = toResources(fileMap.get("fileInput")); + private PolicyInputs collectInputs(MultipartFormDataInput request) throws IOException { + Map> formData = request.getValues(); + List primary = toResources(formData.get("fileInput")); Map> supportingFiles = new LinkedHashMap<>(); - for (Map.Entry> entry : fileMap.entrySet()) { + for (Map.Entry> entry : formData.entrySet()) { if ("fileInput".equals(entry.getKey())) { continue; } @@ -282,16 +326,24 @@ public class PolicyController { /** * A progress listener that forwards each step transition to the SSE stream as a "step" event. */ - private PolicyProgressListener streamListener(SseEmitter emitter) { + private PolicyProgressListener streamListener(SseEventSink eventSink, Sse sse) { return new PolicyProgressListener() { @Override public void onStepStart(int stepIndex, int stepCount, String operation) { - sendEvent(emitter, "step", stepEvent("started", stepIndex, stepCount, operation)); + sendEvent( + eventSink, + sse, + "step", + stepEvent("started", stepIndex, stepCount, operation)); } @Override public void onStepComplete(int stepIndex, int stepCount, String operation) { - sendEvent(emitter, "step", stepEvent("completed", stepIndex, stepCount, operation)); + sendEvent( + eventSink, + sse, + "step", + stepEvent("completed", stepIndex, stepCount, operation)); } }; } @@ -316,30 +368,50 @@ public class PolicyController { }; } - private void sendEvent(SseEmitter emitter, String name, Object data) { + private void sendEvent(SseEventSink eventSink, Sse sse, String name, Object data) { + if (eventSink.isClosed()) { + log.debug("Dropping policy SSE event '{}': sink already closed", name); + return; + } try { - emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON)); - } catch (IOException | IllegalStateException e) { - // Client disconnected or the emitter already closed. The run continues and its results + OutboundSseEvent event = + sse.newEventBuilder() + .name(name) + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(data) + .build(); + eventSink.send(event); + } catch (IllegalStateException e) { + // Client disconnected or the sink already closed. The run continues and its results // remain downloadable via the job endpoints; nothing useful left to stream. log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage()); } } - private List toResources(List files) throws IOException { + private List toResources(Collection files) throws IOException { List resources = new ArrayList<>(); if (files == null) { return resources; } - for (MultipartFile file : files) { - if (file == null || file.isEmpty()) { + for (FormValue file : files) { + if (file == null || !file.isFileItem()) { + continue; + } + long size; + try { + size = file.getFileItem().getFileSize(); + } catch (IOException e) { + size = 0; + } + if (size == 0) { continue; } TempFile tempFile = tempFileManager.createManagedTempFile("policy-run"); - file.transferTo(tempFile.getPath()); - final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename()); + file.getFileItem().write(tempFile.getPath()); + final String originalName = Filenames.toSimpleFileName(file.getFileName()); + final Path tempPath = tempFile.getPath(); resources.add( - new FileSystemResource(tempFile.getFile()) { + new FileSystemResource(tempPath) { @Override public String getFilename() { return originalName; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 8f41209c7e..62d64e2811 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -8,13 +8,12 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; -import org.springframework.core.io.Resource; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.io.Resource; import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.FileStorage; import stirling.software.common.service.InternalApiTimeoutException; @@ -48,7 +47,7 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener; * instead of oversubscribing. */ @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class PolicyEngine { @@ -188,7 +187,7 @@ public class PolicyEngine { } } - private ResponseEntity failRejectedRun( + private jakarta.ws.rs.core.Response failRejectedRun( PolicyRun run, CompletableFuture completion, Throwable ex) { // Only reached if the run never started (e.g. the queue was full). A run that started // always resolves its own completion in runToCompletion. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java index eb5f028bde..3eaf2c3551 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.policy.engine; import java.util.List; -import org.springframework.core.io.Resource; +import stirling.software.common.model.io.Resource; import tools.jackson.databind.JsonNode; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java index 92a436bdfd..055f60b5a6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java @@ -3,22 +3,19 @@ package stirling.software.proprietary.policy.engine; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import org.springframework.core.io.Resource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.io.Resource; import stirling.software.common.service.InternalApiClient; import stirling.software.common.service.InternalApiTimeoutException; import stirling.software.common.service.ToolMetadataService; @@ -47,7 +44,7 @@ import tools.jackson.databind.ObjectMapper; * caller. */ @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class PolicyExecutor { @@ -162,9 +159,9 @@ public class PolicyExecutor { PipelineStep step, List files, Map> supportingFiles) throws IOException { String endpointPath = step.operation(); - MultiValueMap body = new LinkedMultiValueMap<>(); + Map> body = new LinkedHashMap<>(); for (Resource file : files) { - body.add("fileInput", file); + addToBody(body, "fileInput", file); } // Bind supporting files to their named tool fields (e.g. stampImage, overlayFiles). These // come from the run's named asset store, not the document stream. @@ -183,7 +180,7 @@ public class PolicyExecutor { + "' but no such file was provided"); } for (Resource asset : assets) { - body.add(fieldName, asset); + addToBody(body, fieldName, asset); } } for (Map.Entry entry : step.parameters().entrySet()) { @@ -192,22 +189,25 @@ public class PolicyExecutor { // Endpoints binding lists of structured objects (e.g. /security/redact's // redactions, /general/edit-text's edits) parse a single JSON string field via // a property editor. Pre-serialize the whole list so binding succeeds. - body.add(entry.getKey(), objectMapper.writeValueAsString(list)); + addToBody(body, entry.getKey(), objectMapper.writeValueAsString(list)); } else { for (Object item : list) { - body.add(entry.getKey(), item); + addToBody(body, entry.getKey(), item); } } } else { - body.add(entry.getKey(), entry.getValue()); + addToBody(body, entry.getKey(), entry.getValue()); } } - ResponseEntity response = internalApiClient.post(endpointPath, body); - if (!HttpStatus.OK.equals(response.getStatusCode()) || response.getBody() == null) { + // The migrated InternalApiClient takes a Map> (replacing Spring's + // MultiValueMap) and returns a jakarta.ws.rs.core.Response. + Response response = internalApiClient.post(endpointPath, body); + if (response.getStatus() != Response.Status.OK.getStatusCode() + || response.getEntity() == null) { throw new IOException( - "Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath); + "Tool returned HTTP " + response.getStatus() + " for " + endpointPath); } - Resource resource = response.getBody(); + Resource resource = (Resource) response.getEntity(); // Filter operations return an empty body to signal the file was filtered out: drop it // rather than forwarding a zero-byte document. @@ -215,18 +215,17 @@ public class PolicyExecutor { return new ToolResult(List.of(), null); } - HttpHeaders headers = response.getHeaders(); - MediaType contentType = headers.getContentType(); + MediaType contentType = response.getMediaType(); // JSON-only response: the whole body is the structured report, no result file. - if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + if (contentType != null && MediaType.APPLICATION_JSON_TYPE.isCompatible(contentType)) { try (InputStream is = resource.getInputStream()) { JsonNode report = objectMapper.readTree(is); return new ToolResult(List.of(), report); } } - JsonNode report = parseReportHeader(headers, endpointPath); + JsonNode report = parseReportHeader(response, endpointPath); if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) { return new ToolResult(ZipExtractionUtils.extractZip(resource, tempFileManager), report); } @@ -237,8 +236,8 @@ public class PolicyExecutor { * Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode}, * or return null. */ - private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) { - String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT); + private JsonNode parseReportHeader(Response response, String endpointPath) { + String raw = response.getHeaderString(AiToolResponseHeaders.TOOL_REPORT); if (raw == null || raw.isBlank()) { return null; } @@ -254,6 +253,11 @@ public class PolicyExecutor { } } + /** Append a value to the multi-valued form body (replaces Spring's MultiValueMap#add). */ + private static void addToBody(Map> body, String name, Object value) { + body.computeIfAbsent(name, k -> new ArrayList<>()).add(value); + } + private static boolean containsStructuredElements(List list) { for (Object item : list) { if (item instanceof Map || item instanceof List) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyInputRequiredException.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyInputRequiredException.java index fa30373c57..0a50e1f202 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyInputRequiredException.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyInputRequiredException.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.policy.engine; import java.util.List; -import org.springframework.core.io.Resource; +import stirling.software.common.model.io.Resource; import lombok.Getter; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java index acf5a31684..b873f1af88 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java @@ -9,9 +9,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.stereotype.Service; - import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; import lombok.extern.slf4j.Slf4j; @@ -31,7 +30,7 @@ import stirling.software.proprietary.policy.model.PolicyRun; * cleanup, so eviction only frees this map's entry. */ @Slf4j -@Service +@ApplicationScoped public class PolicyRunRegistry { private final Map runs = new ConcurrentHashMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 8685bf0208..41e4e7c50f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -4,9 +4,10 @@ import java.io.IOException; import java.util.List; import java.util.function.Consumer; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.All; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.policy.input.InputSource; @@ -30,13 +31,18 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener; * yields {@link ResolvedInput units of work}, each carrying its own completion hook. */ @Slf4j -@Service -@RequiredArgsConstructor +@ApplicationScoped public class PolicyRunner { private final PolicyEngine policyEngine; private final List inputSources; + @jakarta.inject.Inject + public PolicyRunner(PolicyEngine policyEngine, @All List inputSources) { + this.policyEngine = policyEngine; + this.inputSources = inputSources; + } + /** * Run a policy by pulling from every source it configures: each source yields zero or more * units of work, and each unit becomes its own run so one failure does not affect the others. A diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index 2aa4d98bee..50bea4af6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -1,10 +1,8 @@ package stirling.software.proprietary.policy.engine; -import java.util.List; - -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; @@ -22,13 +20,16 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger; *

The trigger is optional (a {@code null} trigger is a manual-only policy and needs no * validation); every configured source is validated. */ -@Service -@RequiredArgsConstructor +@ApplicationScoped public class PolicyValidator { - private final List triggers; - private final List inputSources; - private final List outputSinks; + // Spring injected a List of all beans of each type; CDI collects all beans of a type + // via Instance, which is iterable. Field injection is used (instead of constructor + // injection via Lombok @RequiredArgsConstructor) because Instance is the CDI-native + // collection type and the fields cannot be final. + @Inject Instance triggers; + @Inject Instance inputSources; + @Inject Instance outputSinks; /** * @throws IllegalArgumentException if any facet's type is unknown or its configuration is diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java index d678202751..832f40b598 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java @@ -9,13 +9,13 @@ import java.util.List; import java.util.Map; import java.util.stream.Stream; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.io.FileSystemResource; +import stirling.software.common.model.io.Resource; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.model.InputSpec; @@ -39,7 +39,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; * Readiness is checked first (via {@link FileReadinessChecker}) so files mid-write are skipped. */ @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class FolderInputSource implements InputSource { @@ -142,7 +142,7 @@ public class FolderInputSource implements InputSource { private static Resource fileResource(Path path) { String name = path.getFileName().toString(); - return new FileSystemResource(path.toFile()) { + return new FileSystemResource(path) { @Override public String getFilename() { return name; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java index 4e6ddae834..3678d94966 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java @@ -3,7 +3,7 @@ package stirling.software.proprietary.policy.model; import java.util.List; import java.util.Map; -import org.springframework.core.io.Resource; +import stirling.software.common.model.io.Resource; /** * The files a run operates on, split into two roles: diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index 6ecec3c932..d084de3f4b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -2,17 +2,20 @@ package stirling.software.proprietary.policy.output; import java.io.IOException; import java.io.InputStream; +import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import jakarta.enterprise.context.ApplicationScoped; + import org.apache.commons.io.FilenameUtils; +// TODO: Migration required - the PolicyOutputSink interface (a collaborator) still declares +// List using Spring's org.springframework.core.io.Resource; this import stays until that +// interface is migrated to stirling.software.common.model.io.Resource. import org.springframework.core.io.Resource; -import org.springframework.http.MediaType; -import org.springframework.http.MediaTypeFactory; -import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,7 +34,7 @@ import stirling.software.proprietary.policy.model.OutputSpec; * FileStorage} entry, so folder outputs are not downloadable via {@code /files/{id}}. */ @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class FolderOutputSink implements PolicyOutputSink { @@ -70,10 +73,11 @@ public class FolderOutputSink implements PolicyOutputSink { Files.copy(is, target); } long size = Files.size(target); - String contentType = - MediaTypeFactory.getMediaType(name) - .orElse(MediaType.APPLICATION_OCTET_STREAM) - .toString(); + // Spring's MediaTypeFactory.getMediaType(name) did extension-based content-type + // guessing; jakarta.ws.rs.core.MediaType has no equivalent factory, so use the JDK's + // URLConnection.guessContentTypeFromName and fall back to application/octet-stream. + String guessed = URLConnection.guessContentTypeFromName(name); + String contentType = guessed != null ? guessed : "application/octet-stream"; results.add( ResultFile.builder() .fileId(UUID.randomUUID().toString()) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java index bef5d46b37..202eca6b84 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java @@ -2,16 +2,15 @@ package stirling.software.proprietary.policy.output; import java.io.IOException; import java.io.InputStream; +import java.net.URLConnection; import java.util.ArrayList; import java.util.List; -import org.springframework.core.io.Resource; -import org.springframework.http.MediaType; -import org.springframework.http.MediaTypeFactory; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; +import stirling.software.common.model.io.Resource; import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.FileStorage; import stirling.software.proprietary.policy.model.OutputSpec; @@ -21,11 +20,12 @@ import stirling.software.proprietary.policy.model.OutputSpec; * {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs * whose results are returned to the caller. */ -@Service +@ApplicationScoped @RequiredArgsConstructor public class InlineOutputSink implements PolicyOutputSink { private static final String TYPE = "inline"; + private static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; private final FileStorage fileStorage; @@ -47,10 +47,8 @@ public class InlineOutputSink implements PolicyOutputSink { Resource resource = outputs.get(i); String name = resource.getFilename() != null ? resource.getFilename() : "result-" + (i + 1); - String contentType = - MediaTypeFactory.getMediaType(name) - .orElse(MediaType.APPLICATION_OCTET_STREAM) - .toString(); + String guessed = URLConnection.guessContentTypeFromName(name); + String contentType = guessed != null ? guessed : APPLICATION_OCTET_STREAM; FileStorage.StoredFile stored; try (InputStream is = resource.getInputStream()) { stored = fileStorage.storeInputStream(is, name); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java index c98b55ad66..f3e4ed4016 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java @@ -3,8 +3,7 @@ package stirling.software.proprietary.policy.output; import java.io.IOException; import java.util.List; -import org.springframework.core.io.Resource; - +import stirling.software.common.model.io.Resource; import stirling.software.common.model.job.ResultFile; import stirling.software.proprietary.policy.model.OutputSpec; @@ -12,7 +11,7 @@ import stirling.software.proprietary.policy.model.OutputSpec; * Delivers a finished run's output files to a destination, returning durable {@link ResultFile} * descriptors (fileId + metadata) for the run record. * - *

Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations + *

Implementations are CDI beans selected by {@link #supports(OutputSpec)}. New destinations * (folder, S3) are added as new beans without changing the engine. */ public interface PolicyOutputSink { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index e4e05ea4c3..4836477ce1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -4,7 +4,8 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -17,7 +18,7 @@ import tools.jackson.databind.ObjectMapper; * (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the * scalar columns are kept in sync for querying. */ -@Service +@ApplicationScoped @RequiredArgsConstructor public class JpaPolicyStore implements PolicyStore { @@ -25,6 +26,7 @@ public class JpaPolicyStore implements PolicyStore { private final ObjectMapper objectMapper; @Override + @Transactional public Policy save(Policy policy) { String id = policy.id() == null || policy.id().isBlank() @@ -48,18 +50,18 @@ public class JpaPolicyStore implements PolicyStore { entity.setEnabled(stored.enabled()); entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type()); entity.setPolicyJson(objectMapper.writeValueAsString(stored)); - repository.save(entity); + repository.persist(entity); return stored; } @Override public Optional get(String id) { - return repository.findById(id).map(this::toPolicy); + return repository.findByIdOptional(id).map(this::toPolicy); } @Override public List all() { - return repository.findAll().stream().map(this::toPolicy).toList(); + return repository.listAll().stream().map(this::toPolicy).toList(); } @Override @@ -70,12 +72,9 @@ public class JpaPolicyStore implements PolicyStore { } @Override + @Transactional public boolean delete(String id) { - if (!repository.existsById(id)) { - return false; - } - repository.deleteById(id); - return true; + return repository.deleteById(id); } private Policy toPolicy(PolicyEntity entity) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java index ba6924f0f8..6b0162bcb3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java @@ -2,12 +2,15 @@ package stirling.software.proprietary.policy.store; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; +import jakarta.enterprise.context.ApplicationScoped; -@Repository -public interface PolicyRepository extends JpaRepository { +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; + +@ApplicationScoped +public class PolicyRepository implements PanacheRepositoryBase { /** Enabled policies of a given trigger type, for background triggers to activate. */ - List findByTriggerTypeAndEnabledTrue(String triggerType); + public List findByTriggerTypeAndEnabledTrue(String triggerType) { + return list("triggerType = ?1 and enabled = true", triggerType); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index dbff801b35..16fd9bdbe4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -20,9 +20,11 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.stereotype.Service; +import io.quarkus.arc.All; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; @@ -49,8 +51,7 @@ import stirling.software.proprietary.policy.store.PolicyStore; * node and rebuilds its registrations on restart from the {@link PolicyStore}. */ @Slf4j -@Service -@RequiredArgsConstructor +@ApplicationScoped public class FolderWatchTrigger implements PolicyTrigger { private static final String TYPE = "folder-watch"; @@ -60,6 +61,18 @@ public class FolderWatchTrigger implements PolicyTrigger { private final List inputSources; private final ApplicationProperties applicationProperties; + @Inject + public FolderWatchTrigger( + PolicyStore policyStore, + PolicyRunner policyRunner, + @All List inputSources, + ApplicationProperties applicationProperties) { + this.policyStore = policyStore; + this.policyRunner = policyRunner; + this.inputSources = inputSources; + this.applicationProperties = applicationProperties; + } + private final Map keysByDir = new ConcurrentHashMap<>(); private final Map dirByKey = new ConcurrentHashMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java index 1e87dcd4da..de8cec4401 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java @@ -1,32 +1,32 @@ package stirling.software.proprietary.policy.trigger; -import java.util.List; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; -import org.springframework.context.SmartLifecycle; -import org.springframework.stereotype.Service; +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * Starts and stops every {@link PolicyTrigger} with the application lifecycle. Background triggers - * (schedule, and future folder/S3) begin watching on {@link #start()} and release resources on - * {@link #stop()}; request-driven triggers (manual) are no-ops. + * (schedule, and future folder/S3) begin watching on startup and release resources on shutdown; + * request-driven triggers (manual) are no-ops. * *

This is the single activation point for triggers - a new background trigger only has to be a * {@link PolicyTrigger} bean. */ @Slf4j -@Service -@RequiredArgsConstructor -public class PolicyTriggerManager implements SmartLifecycle { +@ApplicationScoped +public class PolicyTriggerManager { - private final List triggers; + @Inject Instance triggers; private volatile boolean running; - @Override - public void start() { + public void start(@Observes StartupEvent event) { for (PolicyTrigger trigger : triggers) { try { trigger.start(); @@ -37,8 +37,7 @@ public class PolicyTriggerManager implements SmartLifecycle { running = true; } - @Override - public void stop() { + public void stop(@Observes ShutdownEvent event) { for (PolicyTrigger trigger : triggers) { try { trigger.stop(); @@ -49,7 +48,6 @@ public class PolicyTriggerManager implements SmartLifecycle { running = false; } - @Override public boolean isRunning() { return running; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index f138710d78..a6a625913a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -10,7 +10,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,7 +36,7 @@ import tools.jackson.databind.ObjectMapper; * on restart; cluster-wide coordination (leader election) is a follow-up. */ @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class ScheduleTrigger implements PolicyTrigger { 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 27bca098b3..cb47b35c31 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 @@ -4,259 +4,399 @@ import java.time.Instant; import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; +import io.quarkus.panache.common.Sort; import stirling.software.proprietary.model.security.PersistentAuditEvent; -@Repository -public interface PersistentAuditEventRepository extends JpaRepository { +/** + * Quarkus Panache repository for {@link PersistentAuditEvent}. + * + *

Migrated from a Spring Data {@code JpaRepository}. The original {@code @Query} JPQL strings are + * preserved verbatim and executed through Panache's {@link #find(String, Object...)} / + * {@link #find(String, io.quarkus.panache.common.Sort, java.util.Map)} APIs. + * + *

TODO: Migration required - the previous Spring Data signatures returned + * {@code org.springframework.data.domain.Page} and accepted {@code + * org.springframework.data.domain.Pageable}. Those Spring types are gone in Quarkus; the paged + * finders below now return a Panache {@link PanacheQuery} and accept an + * {@code io.quarkus.panache.common.Page}. Collaborators that still consume the old Spring API + * (AuditRestController, AuditCleanupService, CustomAuditEventRepository) must be updated: + *

    + *
  • {@code page.getContent()} -> {@code query.page(page).list()} + *
  • {@code page.getTotalElements()} -> {@code query.count()} + *
  • {@code page.getTotalPages()} -> {@code query.pageCount()} + *
  • {@code page.getNumber()}/{@code getSize()} -> read from the requested + * {@code io.quarkus.panache.common.Page} + *
  • build the {@code io.quarkus.panache.common.Page} from the request's page index + size + *
+ */ +@ApplicationScoped +public class PersistentAuditEventRepository + implements PanacheRepositoryBase { - // Basic queries - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," - + " :principal, '%'))") - Page findByPrincipal( - @Param("principal") String principal, Pageable pageable); + // --------------------------------------------------------------------- + // Basic paged queries + // TODO: Migration required - callers must adapt to the PanacheQuery return type (see class doc). + // --------------------------------------------------------------------- - Page findByType(String type, Pageable pageable); + public PanacheQuery findByPrincipal(String principal) { + return find( + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%'))", + Parameters.with("principal", principal)); + } - Page findByTimestampBetween( - Instant startDate, Instant endDate, Pageable pageable); + public PanacheQuery findByType(String type) { + return find("type", type); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," - + " :principal, '%')) AND e.type = :type") - Page findByPrincipalAndType( - @Param("principal") String principal, @Param("type") String type, Pageable pageable); + public PanacheQuery findByTimestampBetween( + Instant startDate, Instant endDate) { + return find( + "timestamp BETWEEN ?1 AND ?2", startDate, endDate); + } - @Query( - "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, - @Param("endDate") Instant endDate, - Pageable pageable); + public PanacheQuery findByPrincipalAndType(String principal, String type) { + return find( + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.type = :type", + Parameters.with("principal", principal).and("type", type)); + } - Page findByTypeAndTimestampBetween( - String type, Instant startDate, Instant endDate, Pageable pageable); + public PanacheQuery findByPrincipalAndTimestampBetween( + String principal, Instant startDate, Instant endDate) { + return find( + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate", + Parameters.with("principal", principal) + .and("startDate", startDate) + .and("endDate", 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") - Page findByPrincipalAndTypeAndTimestampBetween( - @Param("principal") String principal, - @Param("type") String type, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - Pageable pageable); + public PanacheQuery findByTypeAndTimestampBetween( + String type, Instant startDate, Instant endDate) { + return find( + "type = ?1 AND timestamp BETWEEN ?2 AND ?3", type, startDate, endDate); + } + public PanacheQuery findByPrincipalAndTypeAndTimestampBetween( + String principal, String type, Instant startDate, Instant endDate) { + return find( + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate" + + " AND :endDate", + Parameters.with("principal", principal) + .and("type", type) + .and("startDate", startDate) + .and("endDate", endDate)); + } + + // --------------------------------------------------------------------- // Non-paged versions for export - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," - + " :principal, '%'))") - List findAllByPrincipalForExport(@Param("principal") String principal); + // --------------------------------------------------------------------- - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type") - List findByTypeForExport(@Param("type") String type); + public List findAllByPrincipalForExport(String principal) { + return find( + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE" + + " UPPER(CONCAT('%', :principal, '%'))", + Parameters.with("principal", principal)) + .list(); + } - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp > :startDate") - List findByTypeAndTimestampAfterForExport( - @Param("type") String type, @Param("startDate") Instant startDate); + public List findByTypeForExport(String type) { + return list("type", type); + } - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate") - List findAllByTimestampBetweenForExport( - @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + public List findByTypeAndTimestampAfterForExport( + String type, Instant startDate) { + return list("type = ?1 AND timestamp > ?2", type, startDate); + } - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp > :startDate") - List findByTimestampAfter(@Param("startDate") Instant startDate); + public List findAllByTimestampBetweenForExport( + Instant startDate, Instant endDate) { + return list("timestamp BETWEEN ?1 AND ?2", startDate, endDate); + } - @Query( - "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); + public List findByTimestampAfter(Instant startDate) { + return list("timestamp > ?1", startDate); + } - @Query( - "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); + public List findAllByPrincipalAndTypeForExport( + String principal, String type) { + return find( + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE" + + " UPPER(CONCAT('%', :principal, '%')) AND e.type = :type", + Parameters.with("principal", principal).and("type", type)) + .list(); + } - @Query( - "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); + public List findAllByPrincipalAndTimestampBetweenForExport( + String principal, Instant startDate, Instant endDate) { + return find( + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE" + + " UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN" + + " :startDate AND :endDate", + Parameters.with("principal", principal) + .and("startDate", startDate) + .and("endDate", endDate)) + .list(); + } - @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") - List findAllByPrincipalAndTypeAndTimestampBetweenForExport( - @Param("principal") String principal, - @Param("type") String type, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate); + public List findAllByTypeAndTimestampBetweenForExport( + String type, Instant startDate, Instant endDate) { + return list( + "type = ?1 AND timestamp BETWEEN ?2 AND ?3", type, startDate, endDate); + } + public List findAllByPrincipalAndTypeAndTimestampBetweenForExport( + String principal, String type, Instant startDate, Instant endDate) { + return find( + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE" + + " UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND" + + " e.timestamp BETWEEN :startDate AND :endDate", + Parameters.with("principal", principal) + .and("type", type) + .and("startDate", startDate) + .and("endDate", endDate)) + .list(); + } + + // --------------------------------------------------------------------- // Cleanup queries - @Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1") - @Modifying + // --------------------------------------------------------------------- + @Transactional - int deleteByTimestampBefore(Instant cutoffDate); + public int deleteByTimestampBefore(Instant cutoffDate) { + return (int) delete("timestamp < ?1", cutoffDate); + } - // Find IDs for batch deletion - using JPQL with setMaxResults instead of native query - @Query("SELECT e.id FROM PersistentAuditEvent e WHERE e.timestamp < ?1 ORDER BY e.id") - List findIdsForBatchDeletion(Instant cutoffDate, Pageable pageable); + /** + * Find IDs for batch deletion - using JPQL with paging instead of a native query. + * + *

TODO: Migration required - originally accepted a Spring {@code Pageable}; callers must pass + * an {@code io.quarkus.panache.common.Page} instead (see class doc). + */ + public List findIdsForBatchDeletion(Instant cutoffDate, io.quarkus.panache.common.Page page) { + return getEntityManager() + .createQuery( + "SELECT e.id FROM PersistentAuditEvent e WHERE e.timestamp < :cutoffDate" + + " ORDER BY e.id", + Long.class) + .setParameter("cutoffDate", cutoffDate) + .setFirstResult(page.index * page.size) + .setMaxResults(page.size) + .getResultList(); + } + // --------------------------------------------------------------------- // Stats queries - @Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type") - List countByType(); + // --------------------------------------------------------------------- - @Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal") - List countByPrincipal(); + public List countByType() { + return getEntityManager() + .createQuery( + "SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type", + Object[].class) + .getResultList(); + } - @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); + public List countByPrincipal() { + return getEntityManager() + .createQuery( + "SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY" + + " e.principal", + Object[].class) + .getResultList(); + } - @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); + public List countByTypeBetween(Instant startDate, Instant endDate) { + return getEntityManager() + .createQuery( + "SELECT e.type, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp" + + " BETWEEN :startDate AND :endDate GROUP BY e.type", + Object[].class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDate) + .getResultList(); + } + + public List countByPrincipalBetween(Instant startDate, Instant endDate) { + return getEntityManager() + .createQuery( + "SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp" + + " BETWEEN :startDate AND :endDate GROUP BY e.principal", + Object[].class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDate) + .getResultList(); + } // 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); + public List histogramByDayBetween(Instant startDate, Instant endDate) { + return getEntityManager() + .createQuery( + "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)", + Object[].class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDate) + .getResultList(); + } - @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); + public List histogramByHourBetween(Instant startDate, Instant endDate) { + return getEntityManager() + .createQuery( + "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)", + Object[].class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDate) + .getResultList(); + } + // --------------------------------------------------------------------- // 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(); + public List findDistinctEventTypes() { + return getEntityManager() + .createQuery( + "SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type", + String.class) + .getResultList(); + } - @Query( - "SELECT DISTINCT e.principal FROM PersistentAuditEvent e WHERE e.type = :type ORDER BY" - + " e.principal") - List findDistinctPrincipalsByType(@Param("type") String type); + public List findDistinctPrincipals() { + return getEntityManager() + .createQuery( + "SELECT DISTINCT e.principal FROM PersistentAuditEvent e ORDER BY" + + " e.principal", + String.class) + .getResultList(); + } + public List findDistinctPrincipalsByType(String type) { + return getEntityManager() + .createQuery( + "SELECT DISTINCT e.principal FROM PersistentAuditEvent e WHERE e.type =" + + " :type ORDER BY e.principal", + String.class) + .setParameter("type", type) + .getResultList(); + } + + // --------------------------------------------------------------------- // Top/Latest helpers & existence checks - Optional findTopByOrderByTimestampDesc(); + // --------------------------------------------------------------------- - Optional findTopByPrincipalOrderByTimestampDesc(String principal); + public Optional findTopByOrderByTimestampDesc() { + return find("", Sort.by("timestamp").descending()).firstResultOptional(); + } - Optional findTopByTypeOrderByTimestampDesc(String type); + public Optional findTopByPrincipalOrderByTimestampDesc(String principal) { + return find("principal", Sort.by("timestamp").descending(), principal).firstResultOptional(); + } + public Optional findTopByTypeOrderByTimestampDesc(String type) { + return find("type", Sort.by("timestamp").descending(), type).firstResultOptional(); + } + + // --------------------------------------------------------------------- // Multi-value queries for filtering by multiple types and/or principals - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types") - Page findByTypeIn(@Param("types") List types, Pageable pageable); + // TODO: Migration required - callers must adapt to the PanacheQuery return type (see class doc). + // --------------------------------------------------------------------- - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals") - Page findByPrincipalIn( - @Param("principals") List principals, Pageable pageable); + public PanacheQuery findByTypeIn(List types) { + return find("type IN ?1", types); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate") - Page findByTypeInAndTimestampBetween( - @Param("types") List types, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - Pageable pageable); + public PanacheQuery findByPrincipalIn(List principals) { + return find("principal IN ?1", principals); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate") - Page findByPrincipalInAndTimestampBetween( - @Param("principals") List principals, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - Pageable pageable); + public PanacheQuery findByTypeInAndTimestampBetween( + List types, Instant startDate, Instant endDate) { + return find( + "type IN ?1 AND timestamp BETWEEN ?2 AND ?3", types, startDate, endDate); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals") - Page findByTypeInAndPrincipalIn( - @Param("types") List types, - @Param("principals") List principals, - Pageable pageable); + public PanacheQuery findByPrincipalInAndTimestampBetween( + List principals, Instant startDate, Instant endDate) { + return find( + "principal IN ?1 AND timestamp BETWEEN ?2 AND ?3", principals, startDate, endDate); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate") - Page findByTypeInAndPrincipalInAndTimestampBetween( - @Param("types") List types, - @Param("principals") List principals, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate, - Pageable pageable); + public PanacheQuery findByTypeInAndPrincipalIn( + List types, List principals) { + return find("type IN ?1 AND principal IN ?2", types, principals); + } + public PanacheQuery findByTypeInAndPrincipalInAndTimestampBetween( + List types, List principals, Instant startDate, Instant endDate) { + return find( + "type IN ?1 AND principal IN ?2 AND timestamp BETWEEN ?3 AND ?4", + types, + principals, + startDate, + endDate); + } + + // --------------------------------------------------------------------- // Export versions (non-paged) - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types") - List findByTypeInForExport(@Param("types") List types); + // --------------------------------------------------------------------- - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals") - List findByPrincipalInForExport( - @Param("principals") List principals); + public List findByTypeInForExport(List types) { + return list("type IN ?1", types); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate") - List findByTypeInAndTimestampBetweenForExport( - @Param("types") List types, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate); + public List findByPrincipalInForExport(List principals) { + return list("principal IN ?1", principals); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate") - List findByPrincipalInAndTimestampBetweenForExport( - @Param("principals") List principals, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate); + public List findByTypeInAndTimestampBetweenForExport( + List types, Instant startDate, Instant endDate) { + return list( + "type IN ?1 AND timestamp BETWEEN ?2 AND ?3", types, startDate, endDate); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals") - List findByTypeInAndPrincipalInForExport( - @Param("types") List types, @Param("principals") List principals); + public List findByPrincipalInAndTimestampBetweenForExport( + List principals, Instant startDate, Instant endDate) { + return list( + "principal IN ?1 AND timestamp BETWEEN ?2 AND ?3", principals, startDate, endDate); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate") - List findByTypeInAndPrincipalInAndTimestampBetweenForExport( - @Param("types") List types, - @Param("principals") List principals, - @Param("startDate") Instant startDate, - @Param("endDate") Instant endDate); + public List findByTypeInAndPrincipalInForExport( + List types, List principals) { + return list("type IN ?1 AND principal IN ?2", types, principals); + } + + public List findByTypeInAndPrincipalInAndTimestampBetweenForExport( + List types, List principals, Instant startDate, Instant endDate) { + return list( + "type IN ?1 AND principal IN ?2 AND timestamp BETWEEN ?3 AND ?4", + types, + principals, + startDate, + endDate); + } // Query events excluding a specific type (used for analytics where we want to exclude UI_DATA) - @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType") - List findAllExceptTypeForExport(@Param("excludeType") String excludeType); + public List findAllExceptTypeForExport(String excludeType) { + return list("type != ?1", excludeType); + } - @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate") - List findAllExceptTypeAndTimestampAfterForExport( - @Param("excludeType") String excludeType, @Param("startDate") Instant startDate); + public List findAllExceptTypeAndTimestampAfterForExport( + String excludeType, Instant startDate) { + return list("type != ?1 AND timestamp > ?2", excludeType, startDate); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java index 4141155a1c..d2a16764b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java @@ -3,14 +3,8 @@ package stirling.software.proprietary.security; import java.io.IOException; import java.util.Optional; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.DisabledException; -import org.springframework.security.authentication.InternalAuthenticationServiceException; -import org.springframework.security.authentication.LockedException; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; - +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -24,38 +18,60 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; +// TODO: Migration required - this class extended Spring Security's +// SimpleUrlAuthenticationFailureHandler and was wired into the form-login +// SecurityFilterChain. Quarkus has no direct equivalent for an +// AuthenticationFailureHandler. The login-failure flow (lockout, bad +// credentials, oauth2 errors, disabled users) must be re-hosted on a Quarkus +// authentication mechanism - typically a custom form-auth (quarkus.http.auth.*) +// or quarkus-oidc - with the redirect decisions implemented in a +// jakarta.ws.rs.container.ContainerRequestFilter / custom HttpAuthenticationMechanism +// that inspects the AuthenticationFailedException. The decision logic below is +// preserved verbatim so it can be reused; the Spring AuthenticationException +// type hierarchy (BadCredentialsException, DisabledException, LockedException, +// UsernameNotFoundException, InternalAuthenticationServiceException) and +// getRedirectStrategy()/sendRedirect() must be replaced with the Quarkus +// equivalents. The exception parameter is currently typed as a generic +// java.lang.Throwable until the Quarkus mechanism's failure type is decided. @Slf4j -public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { +@ApplicationScoped +public class CustomAuthenticationFailureHandler { private LoginAttemptService loginAttemptService; private UserService userService; + @Inject public CustomAuthenticationFailureHandler( final LoginAttemptService loginAttemptService, UserService userService) { this.loginAttemptService = loginAttemptService; this.userService = userService; } - @Override @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException exception) + HttpServletRequest request, HttpServletResponse response, Throwable exception) throws IOException, ServletException { - if (exception instanceof DisabledException) { + // TODO: Migration required - replace Spring exception type checks below + // (DisabledException, LockedException, BadCredentialsException, + // UsernameNotFoundException, InternalAuthenticationServiceException) with + // the Quarkus authentication-failure type(s), and replace each + // getRedirectStrategy().sendRedirect(request, response, "...") call with + // a Quarkus redirect (e.g. response.sendRedirect(...) or building a 302 + // jakarta.ws.rs.core.Response from the auth mechanism). + + if (isDisabled(exception)) { log.error("User is deactivated: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); + // TODO: Migration required - sendRedirect("/logout?userIsDisabled=true") return; } String ip = request.getRemoteAddr(); log.error("Failed login attempt from IP: {}", ip); - if (exception instanceof LockedException) { - getRedirectStrategy().sendRedirect(request, response, "/login?error=locked"); + if (isLocked(exception)) { + // TODO: Migration required - sendRedirect("/login?error=locked") return; } @@ -68,24 +84,47 @@ public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationF username, loginAttemptService.getRemainingAttempts(username)); loginAttemptService.loginFailed(username); - if (loginAttemptService.isBlocked(username) || exception instanceof LockedException) { - getRedirectStrategy().sendRedirect(request, response, "/login?error=locked"); + if (loginAttemptService.isBlocked(username) || isLocked(exception)) { + // TODO: Migration required - sendRedirect("/login?error=locked") return; } } - if (exception instanceof BadCredentialsException - || exception instanceof UsernameNotFoundException) { - getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials"); + if (isBadCredentials(exception) || isUsernameNotFound(exception)) { + // TODO: Migration required - sendRedirect("/login?error=badCredentials") return; } - if (exception instanceof InternalAuthenticationServiceException + if (isInternalAuthError(exception) || "Password must not be null".equalsIgnoreCase(exception.getMessage())) { - getRedirectStrategy() - .sendRedirect(request, response, "/login?error=oauth2AuthenticationError"); + // TODO: Migration required - sendRedirect("/login?error=oauth2AuthenticationError") return; } - super.onAuthenticationFailure(request, response, exception); + // TODO: Migration required - default failure handling previously delegated + // to SimpleUrlAuthenticationFailureHandler.onAuthenticationFailure (redirect + // to the configured failure URL). + } + + // TODO: Migration required - these predicates stand in for Spring Security's + // exception type hierarchy and must be rewired to the Quarkus + // authentication-failure type(s) once the auth mechanism is chosen. + private boolean isDisabled(Throwable exception) { + return false; + } + + private boolean isLocked(Throwable exception) { + return false; + } + + private boolean isBadCredentials(Throwable exception) { + return false; + } + + private boolean isUsernameNotFound(Throwable exception) { + return false; + } + + private boolean isInternalAuthError(Throwable exception) { + return false; } private boolean isDemoUser(Optional user) { 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 028cee6850..058e1540f2 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 @@ -3,10 +3,8 @@ 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; -import org.springframework.security.web.savedrequest.SavedRequest; - +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -23,14 +21,31 @@ import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; +// TODO: Migration required - this class previously extended Spring Security's +// SavedRequestAwareAuthenticationSuccessHandler, which is part of the Spring Security +// form-login filter chain (RedirectStrategy + SavedRequest from the HttpSession). Quarkus +// has no direct equivalent: post-login redirects are handled by quarkus-oidc / form-auth +// (quarkus.http.auth.form.landing-page, .location-cookie) or by a custom +// jakarta.servlet.Filter / ContainerRequestFilter / HttpAuthenticationMechanism. The +// business logic below (disabled-user check, login-attempt tracking, JWT minting, and the +// static-resource redirect decision) is preserved and should be invoked from whatever +// Quarkus authentication-success hook replaces this handler. The Spring +// SavedRequestAwareAuthenticationSuccessHandler super.onAuthenticationSuccess(...) call and +// getRedirectStrategy() have been replaced with plain HttpServletResponse#sendRedirect. +// +// TODO: Migration required - the @Audited(USER_LOGIN) interception relied on the Spring AOP +// AuditAspect wrapping this Spring-managed handler bean. Ensure the migrated AuditAspect +// (CDI interceptor) still binds to this method, or audit the login event from the new +// authentication-success hook. @Slf4j -public class CustomAuthenticationSuccessHandler - extends SavedRequestAwareAuthenticationSuccessHandler { +@ApplicationScoped +public class CustomAuthenticationSuccessHandler { private final LoginAttemptService loginAttemptService; private final UserService userService; private final JwtServiceInterface jwtService; + @Inject public CustomAuthenticationSuccessHandler( LoginAttemptService loginAttemptService, UserService userService, @@ -40,43 +55,65 @@ public class CustomAuthenticationSuccessHandler this.jwtService = jwtService; } - @Override + // TODO: Migration required - signature changed from Spring's + // onAuthenticationSuccess(HttpServletRequest, HttpServletResponse, + // org.springframework.security.core.Authentication). The Spring Authentication parameter + // has been dropped here; JwtServiceInterface#generateToken(Authentication, ...) still + // requires it (JwtServiceInterface is a separate file that must be migrated to accept a + // Quarkus SecurityIdentity / principal). For now the username is read from the request + // parameter as before; wire the authenticated identity in once JwtServiceInterface is + // migrated. @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( - HttpServletRequest request, HttpServletResponse response, Authentication authentication) + HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String userName = request.getParameter("username"); if (userService.isUserDisabled(userName)) { - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); + response.sendRedirect("/logout?userIsDisabled=true"); return; } loginAttemptService.loginSucceeded(userName); if (jwtService.isJwtEnabled()) { + // TODO: Migration required - JwtServiceInterface#generateToken expected a Spring + // Authentication. Pass the migrated Quarkus identity once JwtServiceInterface is + // ported; generating the token by username for now. String jwt = jwtService.generateToken( - authentication, Map.of("authType", AuthenticationType.WEB)); + userName, Map.of("authType", AuthenticationType.WEB)); log.debug("JWT generated for user: {}", userName); - getRedirectStrategy().sendRedirect(request, response, "/"); + response.sendRedirect("/"); } else { // Get the saved request HttpSession session = request.getSession(false); - SavedRequest savedRequest = + // TODO: Migration required - "SPRING_SECURITY_SAVED_REQUEST" was populated by the + // Spring Security RequestCache. Without the Spring filter chain this attribute is + // never set, so this branch always falls through to the home-page redirect. The + // original-destination redirect must be reimplemented via the Quarkus form-auth + // location cookie or a custom request cache. + Object savedRequest = (session != null) - ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") + ? session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") : null; - if (savedRequest != null + String savedRedirectUrl = extractSavedRedirectUrl(savedRequest); + if (savedRedirectUrl != null && !RequestUriUtils.isStaticResource( - request.getContextPath(), savedRequest.getRedirectUrl())) { + request.getContextPath(), savedRedirectUrl)) { // Redirect to the original destination - super.onAuthenticationSuccess(request, response, authentication); + response.sendRedirect(savedRedirectUrl); } else { // No saved request or it's a static resource, redirect to home page - getRedirectStrategy().sendRedirect(request, response, "/"); + response.sendRedirect("/"); } } } + + // TODO: Migration required - placeholder for reading the redirect URL off whatever object + // the migrated request cache stores. The Spring SavedRequest#getRedirectUrl() is gone. + private String extractSavedRedirectUrl(Object savedRequest) { + return null; + } } 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 4bfef06c9b..3a37a3e02b 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 @@ -7,18 +7,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; -import org.springframework.core.io.Resource; -import org.springframework.security.authentication.AuthenticationTrustResolver; -import org.springframework.security.authentication.AuthenticationTrustResolverImpl; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; -import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; - 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; @@ -29,6 +22,7 @@ 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; @@ -36,13 +30,19 @@ 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.saml2.CustomSaml2AuthenticatedPrincipal; 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 -@RequiredArgsConstructor -public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { +@ApplicationScoped +@RequiredArgsConstructor(onConstructor_ = @Inject) +public class CustomLogoutSuccessHandler { public static final String LOGOUT_PATH = "/login?logout=true"; @@ -54,13 +54,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { private final AiUserDataService aiUserDataService; - private static final AuthenticationTrustResolver TRUST_RESOLVER = - new AuthenticationTrustResolverImpl(); + // 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. - @Override @Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC) public void onLogoutSuccess( - HttpServletRequest request, HttpServletResponse response, Authentication authentication) + HttpServletRequest request, HttpServletResponse response, Object authentication) throws IOException { String username = resolveUsername(request, authentication); @@ -70,75 +71,80 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { if (!response.isCommitted()) { if (authentication != null) { - if (authentication instanceof Saml2Authentication samlAuthentication) { - // Handle SAML2 logout redirection - getRedirect_saml2(request, response, samlAuthentication); - } else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) { - // Handle OAuth2 logout redirection - getRedirect_oauth2(request, response, oAuthToken); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - // Handle Username/Password logout - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } else { - // Handle unknown authentication types - log.error( - "Authentication class unknown: {}", - authentication.getClass().getSimpleName()); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } + // 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()) { - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + response.sendRedirect(LOGOUT_PATH); return; } } // Redirect to login page after logout String path = checkForErrors(request); - getRedirectStrategy().sendRedirect(request, response, path); + response.sendRedirect(path); } } } /** * Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through - * to whatever Spring handed us only when there's no cookie. Spring's anonymous principal is - * filtered out via {@link AuthenticationTrustResolver} so we don't purge under that - * pseudo-user. + * 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, Authentication authentication) { + private String resolveUsername(HttpServletRequest request, Object authentication) { if (jwtService != null) { String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request); if (fromCookie != null) { return fromCookie; } } - if (authentication == null || TRUST_RESOLVER.isAnonymous(authentication)) { + // 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; } - String name = authentication.getName(); - return (name != null && !name.isBlank()) ? name : 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, - Saml2Authentication samlAuthentication) + HttpServletRequest request, HttpServletResponse response, Object samlAuthentication) throws IOException { SAML2 samlConf = securityProperties.getSaml2(); String registrationId = samlConf.getRegistrationId(); - CustomSaml2AuthenticatedPrincipal principal = - (CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal(); - - String nameIdValue = principal.name(); + // 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<>(); @@ -166,22 +172,26 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { samlConf.getProvider(), nameIdValue, e); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + 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, - OAuth2AuthenticationToken oAuthToken) + 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; - registrationId = oAuthToken.getAuthorizedClientRegistrationId(); + // 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)) { 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 e0dabced4b..a9e27aa6e3 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 @@ -5,10 +5,10 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; +import org.eclipse.microprofile.config.inject.ConfigProperty; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,12 +25,12 @@ import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.service.UserLicenseSettingsService; @Slf4j -@Component +@ApplicationScoped @RequiredArgsConstructor public class InitialSecuritySetup { - @Value("${v2:false}") - private boolean v2Enabled = false; + @ConfigProperty(name = "v2", defaultValue = "false") + boolean v2Enabled; private final UserService userService; private final TeamService teamService; 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 index 479b544ad5..8cb72948b4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java @@ -2,20 +2,24 @@ 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.enterprise.context.ApplicationScoped; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -@Component -public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { - @Override +// TODO: Migration required - this was a Spring Security AuthenticationEntryPoint +// (org.springframework.security.web.AuthenticationEntryPoint). Quarkus has no direct +// AuthenticationEntryPoint SPI; unauthenticated-access handling is wired via +// quarkus.http.auth.* policies and an AuthenticationFailedException mapper / a +// jakarta.ws.rs.ext.ExceptionMapper (or a +// ContainerRequestFilter). The response-shaping logic below is preserved as a plain +// helper bean; the caller that previously registered this entry point must invoke +// commence(...) from the Quarkus failure-handling path. The AuthenticationException +// parameter was replaced with a generic Exception to drop the Spring dependency. +@ApplicationScoped +public class JwtAuthenticationEntryPoint { + public void commence( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException authException) + HttpServletRequest request, HttpServletResponse response, Exception authException) throws IOException { String contextPath = request.getContextPath(); String requestURI = request.getRequestURI(); @@ -30,7 +34,9 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { response.getWriter().write("{\"error\":\"" + message + "\"}"); } else { // For non-API requests, use default behavior - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + response.sendError( + HttpServletResponse.SC_UNAUTHORIZED, + authException != null ? authException.getMessage() : "Authentication required"); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java index 2c46242fdd..d0ec10beb5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java @@ -1,21 +1,28 @@ package stirling.software.proprietary.security; -import org.springframework.context.annotation.Profile; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; +import io.quarkus.arc.profile.UnlessBuildProfile; +import io.quarkus.scheduler.Scheduled; + +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import stirling.software.proprietary.security.filter.IPRateLimitingFilter; -@Component -@Profile("!saas") +// TODO: Migration required - Spring @Profile("!saas") gated this scheduler so it never ran in the +// "saas" profile. @io.quarkus.arc.profile.UnlessBuildProfile("saas") reproduces this when "saas" is +// a Quarkus BUILD profile; if "saas" is only a runtime profile, this annotation has no effect and +// the body of resetRateLimit() must instead short-circuit on a runtime profile check +// (org.eclipse.microprofile.config Config "quarkus.profile" / ProfileManager.getActiveProfile()). +@ApplicationScoped +@UnlessBuildProfile("saas") @RequiredArgsConstructor public class RateLimitResetScheduler { private final IPRateLimitingFilter rateLimitingFilter; - @Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}") + // Quarkus @Scheduled cron supports the "{property:default}" placeholder syntax (no '$'). + @Scheduled(cron = "{security.rate-limit.reset-schedule:0 0 0 * * MON}") public void resetRateLimit() { rateLimitingFilter.resetRequestCounts(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpointAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpointAspect.java index 8c390a93ef..49b9d56f3b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpointAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpointAspect.java @@ -1,30 +1,50 @@ package stirling.software.proprietary.security.config; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ResponseStatusException; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; -@Aspect -@Component +/** + * MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} {@code @Component} with + * {@code @Around} advice matching {@code @annotation(EnterpriseEndpoint)} / + * {@code @within(EnterpriseEndpoint)}. Reworked into a CDI {@link Interceptor} bound by the + * {@code @EnterpriseEndpoint} annotation (pattern: common/aop/AutoJobAspect). {@code @Around} + + * {@code ProceedingJoinPoint} became {@code @AroundInvoke} + {@link InvocationContext}; + * {@code joinPoint.proceed()} -> {@code ctx.proceed()}. The Spring + * {@code ResponseStatusException(HttpStatus.FORBIDDEN, ...)} became a JAX-RS + * {@link WebApplicationException} with {@link Response.Status#FORBIDDEN}. The + * {@code @Qualifier("runningEE")} ctor param became {@code @Inject @Named("runningEE")}. + * + *

TODO: Migration required - for this CDI interceptor to fire, the collaborator annotation + * {@code stirling.software.proprietary.security.config.EnterpriseEndpoint} must be made a CDI + * {@code @jakarta.interceptor.InterceptorBinding} (it is currently a plain runtime annotation), and + * the interceptor must be enabled (Quarkus enables {@code @Interceptor} beans automatically once the + * binding is an {@code @InterceptorBinding}; no beans.xml ordering change needed). It already + * targets METHOD and TYPE, matching the original {@code @annotation}/{@code @within} pointcut. + */ +@Interceptor +@EnterpriseEndpoint +@Priority(Interceptor.Priority.APPLICATION) public class EnterpriseEndpointAspect { private final boolean runningEE; - public EnterpriseEndpointAspect(@Qualifier("runningEE") boolean runningEE) { + @Inject + public EnterpriseEndpointAspect(@Named("runningEE") boolean runningEE) { this.runningEE = runningEE; } - @Around( - "@annotation(stirling.software.proprietary.security.config.EnterpriseEndpoint) || @within(stirling.software.proprietary.security.config.EnterpriseEndpoint)") - public Object checkEnterpriseAccess(ProceedingJoinPoint joinPoint) throws Throwable { + @AroundInvoke + public Object checkEnterpriseAccess(InvocationContext ctx) throws Exception { if (!runningEE) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "This endpoint requires an Enterprise license"); + throw new WebApplicationException( + "This endpoint requires an Enterprise license", Response.Status.FORBIDDEN); } - return joinPoint.proceed(); + return ctx.proceed(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java index 9b26e5b55c..6059d2f0ec 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java @@ -1,30 +1,47 @@ package stirling.software.proprietary.security.config; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ResponseStatusException; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; -@Aspect -@Component +/** + * MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} with {@code @Around} advice on + * the {@code @PremiumEndpoint} pointcut ({@code @annotation || @within}). Reworked into a CDI + * {@link Interceptor} bound by the {@code @PremiumEndpoint} {@code @InterceptorBinding}; + * {@code @Around}/{@code ProceedingJoinPoint} became {@code @AroundInvoke}/{@link InvocationContext}. + * The Spring {@code ResponseStatusException(HttpStatus.FORBIDDEN, ...)} became a JAX-RS + * {@link WebApplicationException} with {@link Response.Status#FORBIDDEN}. + * + *

TODO: Migration required - the {@code @PremiumEndpoint} annotation (collaborator file + * stirling.software.proprietary.security.config.PremiumEndpoint) must be annotated with + * {@code @jakarta.interceptor.InterceptorBinding} (and target METHOD + TYPE, retention RUNTIME) for + * this CDI interceptor to bind. Both method-level ({@code @annotation}) and type-level + * ({@code @within}) placement are already supported by CDI when the binding targets METHOD/TYPE. + */ +@Interceptor +@PremiumEndpoint +@Priority(Interceptor.Priority.APPLICATION) public class PremiumEndpointAspect { private final boolean runningProOrHigher; - public PremiumEndpointAspect(@Qualifier("runningProOrHigher") boolean runningProOrHigher) { + @Inject + public PremiumEndpointAspect(@Named("runningProOrHigher") boolean runningProOrHigher) { this.runningProOrHigher = runningProOrHigher; } - @Around( - "@annotation(stirling.software.proprietary.security.config.PremiumEndpoint) || @within(stirling.software.proprietary.security.config.PremiumEndpoint)") - public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable { + @AroundInvoke + public Object checkPremiumAccess(InvocationContext ctx) throws Exception { if (!runningProOrHigher) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "This endpoint requires a Server or Enterprise license"); + throw new WebApplicationException( + "This endpoint requires a Server or Enterprise license", + Response.Status.FORBIDDEN); } - return joinPoint.proceed(); + return ctx.proceed(); } } 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 index 501826a084..31150421f4 100644 --- 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 @@ -1,38 +1,39 @@ package stirling.software.proprietary.security.configuration; -import java.time.Duration; - -import org.springframework.beans.factory.annotation.Autowired; -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; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import stirling.software.common.model.ApplicationProperties; -@Configuration -@EnableCaching +// TODO: Migration required - Spring's @EnableCaching + a programmatic CaffeineCacheManager +// @Bean has no direct Quarkus equivalent. Quarkus caching is annotation-driven +// (io.quarkus.cache.@CacheResult / @CacheInvalidate / @CacheName) and configured +// declaratively in application.properties, e.g.: +// quarkus.cache.caffeine."".maximum-size=1000 +// quarkus.cache.caffeine."".expire-after-write=D +// quarkus.cache.caffeine."".metrics-enabled=true # was .recordStats() +// The expire-after-write here was derived at runtime from +// applicationProperties.getSecurity().getJwt().getKeyRetentionDays(); since Quarkus +// cache config is static, either pin a static value in application.properties or use +// io.quarkus.cache.CacheManager#getCache(...) programmatically to rebuild the cache +// with a runtime TTL. Annotate the relevant cached methods (previously relying on the +// Spring CacheManager) with @CacheResult(cacheName = ""). +@ApplicationScoped public class CacheConfig { private final ApplicationProperties applicationProperties; - @Autowired + @Inject public CacheConfig(ApplicationProperties applicationProperties) { this.applicationProperties = applicationProperties; } - @Bean - public CacheManager cacheManager() { - int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays(); - CaffeineCacheManager cacheManager = new CaffeineCacheManager(); - cacheManager.setCaffeine( - Caffeine.newBuilder() - .maximumSize(1000) // Make configurable? - .expireAfterWrite(Duration.ofDays(keyRetentionDays)) - .recordStats()); - return cacheManager; + /** + * Retained for reference: the JWT key retention window (in days) that previously + * drove Caffeine's expireAfterWrite. Used by the Quarkus cache migration described + * above to derive the TTL for the corresponding named cache. + */ + public int getKeyRetentionDays() { + return applicationProperties.getSecurity().getJwt().getKeyRetentionDays(); } } 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 454df5f67e..0e690544f0 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 @@ -1,19 +1,22 @@ package stirling.software.proprietary.security.configuration; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.util.Locale; +import java.util.Properties; +import java.util.logging.Logger; import javax.sql.DataSource; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; -import org.springframework.boot.jdbc.DataSourceBuilder; -import org.springframework.boot.jdbc.DatabaseDriver; -import org.springframework.boot.persistence.autoconfigure.EntityScan; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.context.annotation.Profile; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import io.quarkus.arc.profile.UnlessBuildProfile; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Named; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -22,27 +25,47 @@ import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; +/** + * MIGRATION NOTES (Spring -> Quarkus CDI): + * + *

    + *
  • {@code @Configuration} -> {@code @ApplicationScoped}; {@code @Bean} -> {@code @Produces}. + *
  • {@code @Qualifier("runningProOrHigher")} ctor param -> {@code @Inject} ctor with + * {@code @Named(...)} on the parameter (the producer lives in common {@code AppConfig}). + *
  • {@code @Profile("!saas")} on the producer -> {@code @UnlessBuildProfile("saas")} so the SaaS + * Postgres datasource shadows this H2 default exactly as the old profile override did. + *
  • {@code @Primary} dropped - the SaaS producer is selected by build profile instead of by + * primary/override semantics. + *
  • {@code @EnableJpaRepositories}/{@code @EntityScan} removed - Quarkus auto-discovers JPA + * entities and Panache repositories across the Jandex index; no explicit base-package wiring + * is needed. + *
  • Spring Boot {@code DataSourceBuilder}/{@code DatabaseDriver} (no Quarkus equivalent) -> + * replaced with a minimal {@link DriverManager}-backed {@link DataSource}. This preserves the + * original lazy-connect semantics of {@code DataSourceBuilder.build()} (no connection is opened + * until {@link DataSource#getConnection()} is called); the driver class-name strings are the + * same literals {@code DatabaseDriver.H2/POSTGRESQL.getDriverClassName()} returned. + *
+ * + *

TODO: Migration required - the idiomatic Quarkus approach is to drop this programmatic producer + * entirely and configure the datasource via {@code quarkus.datasource.*} (jdbc-url / username / + * password / db-kind), letting Agroal own the connection pool. This producer is retained to preserve + * the runtime "custom database" toggle (premium + {@code datasource.enableCustomDatabase}) that + * selects between the bundled H2 file DB and a user-supplied URL at startup - static config cannot + * express that branch on its own. The {@code DriverManager} datasource below is intentionally + * unpooled; if connection pooling is required it should be obtained from the Agroal-managed default + * datasource instead. + */ @Slf4j @Getter -@Configuration -@EnableJpaRepositories( - basePackages = { - "stirling.software.proprietary.security.database.repository", - "stirling.software.proprietary.security.repository", - "stirling.software.proprietary.repository", - "stirling.software.proprietary.storage.repository", - "stirling.software.proprietary.workflow.repository", - "stirling.software.proprietary.policy.store" - }) -@EntityScan({ - "stirling.software.proprietary.security.model", - "stirling.software.proprietary.model", - "stirling.software.proprietary.storage.model", - "stirling.software.proprietary.workflow.model", - "stirling.software.proprietary.policy.store" -}) +@ApplicationScoped public class DatabaseConfig { + /** {@code org.springframework.boot.jdbc.DatabaseDriver.H2.getDriverClassName()}. */ + private static final String H2_DRIVER_CLASS_NAME = "org.h2.Driver"; + + /** {@code org.springframework.boot.jdbc.DatabaseDriver.POSTGRESQL.getDriverClassName()}. */ + private static final String POSTGRESQL_DRIVER_CLASS_NAME = "org.postgresql.Driver"; + public final String DATASOURCE_DEFAULT_URL; public static final String DATASOURCE_URL_TEMPLATE = "jdbc:%s://%s:%4d/%s"; @@ -51,9 +74,10 @@ public class DatabaseConfig { private final ApplicationProperties.Datasource datasource; private final boolean runningProOrHigher; + @Inject public DatabaseConfig( ApplicationProperties.Datasource datasource, - @Qualifier("runningProOrHigher") boolean runningProOrHigher) { + @Named("runningProOrHigher") boolean runningProOrHigher) { DATASOURCE_DEFAULT_URL = "jdbc:h2:file:" + InstallationPathConfig.getConfigPath() @@ -71,21 +95,19 @@ public class DatabaseConfig { * @return a DataSource using the configuration settings in the settings.yml * @throws UnsupportedProviderException if the type of database selected is not supported */ - @Bean - @Qualifier("dataSource") - @Primary - @Profile("!saas") + @Produces + @ApplicationScoped + @Named("dataSource") + @UnlessBuildProfile("saas") public DataSource dataSource() throws UnsupportedProviderException { - DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create(); - if (!runningProOrHigher || !datasource.isEnableCustomDatabase()) { - return useDefaultDataSource(dataSourceBuilder); + return useDefaultDataSource(); } - return useCustomDataSource(dataSourceBuilder); + return useCustomDataSource(); } - private DataSource useDefaultDataSource(DataSourceBuilder dataSourceBuilder) { + private DataSource useDefaultDataSource() { // Support AOT training: override URL via system property to avoid H2 file lock // conflicts when the AOT RECORD phase starts a second Spring context String overrideUrl = System.getProperty("stirling.datasource.url"); @@ -96,38 +118,39 @@ public class DatabaseConfig { log.info("Using default H2 database"); - dataSourceBuilder - .url(url) - .driverClassName(DatabaseDriver.H2.getDriverClassName()) - .username(DEFAULT_USERNAME); - - return dataSourceBuilder.build(); + return new SimpleDriverDataSource(H2_DRIVER_CLASS_NAME, url, DEFAULT_USERNAME, null); } - @ConditionalOnBooleanProperty(name = "premium.enabled") - private DataSource useCustomDataSource(DataSourceBuilder dataSourceBuilder) - throws UnsupportedProviderException { + // TODO: Migration required - the Spring @ConditionalOnBooleanProperty(name = "premium.enabled") + // gate is not expressible on a private helper under CDI. The custom-database path is already + // guarded at runtime by the runningProOrHigher + datasource.enableCustomDatabase checks in + // dataSource(); if a separate premium.enabled toggle is still required, read it via + // org.eclipse.microprofile.config.Config (e.g. premium.enabled) inside dataSource() before + // calling this method. + private DataSource useCustomDataSource() throws UnsupportedProviderException { log.info("Using custom database configuration"); - if (!datasource.getCustomDatabaseUrl().isBlank()) { - if (datasource.getCustomDatabaseUrl().contains("postgresql")) { - dataSourceBuilder.driverClassName(DatabaseDriver.POSTGRESQL.getDriverClassName()); - } + String driverClassName; + String url; - dataSourceBuilder.url(datasource.getCustomDatabaseUrl()); + if (!datasource.getCustomDatabaseUrl().isBlank()) { + driverClassName = + datasource.getCustomDatabaseUrl().contains("postgresql") + ? POSTGRESQL_DRIVER_CLASS_NAME + : null; + url = datasource.getCustomDatabaseUrl(); } else { - dataSourceBuilder.driverClassName(getDriverClassName(datasource.getType())); - dataSourceBuilder.url( + driverClassName = getDriverClassName(datasource.getType()); + url = generateCustomDataSourceUrl( datasource.getType(), datasource.getHostName(), datasource.getPort(), - datasource.getName())); + datasource.getName()); } - dataSourceBuilder.username(datasource.getUsername()); - dataSourceBuilder.password(datasource.getPassword()); - return dataSourceBuilder.build(); + return new SimpleDriverDataSource( + driverClassName, url, datasource.getUsername(), datasource.getPassword()); } /** @@ -159,11 +182,11 @@ public class DatabaseConfig { return switch (driver) { case H2 -> { log.debug("H2 driver selected"); - yield DatabaseDriver.H2.getDriverClassName(); + yield H2_DRIVER_CLASS_NAME; } case POSTGRESQL -> { log.debug("Postgres driver selected"); - yield DatabaseDriver.POSTGRESQL.getDriverClassName(); + yield POSTGRESQL_DRIVER_CLASS_NAME; } default -> { log.warn("{} driver selected", driverName); @@ -176,4 +199,89 @@ public class DatabaseConfig { throw new UnsupportedProviderException(driverName + " is not currently supported"); } } + + /** + * Minimal unpooled {@link DataSource} backed by {@link DriverManager}, replacing Spring Boot's + * {@code DataSourceBuilder}. Connections are opened lazily on {@link #getConnection()} (matching + * {@code DataSourceBuilder.build()} semantics) and the optional driver class is loaded eagerly so + * it self-registers with {@link DriverManager}. + */ + private static final class SimpleDriverDataSource implements DataSource { + + private final String url; + private final String username; + private final String password; + private PrintWriter logWriter; + private int loginTimeout; + + SimpleDriverDataSource( + String driverClassName, String url, String username, String password) { + if (driverClassName != null && !driverClassName.isBlank()) { + try { + Class.forName(driverClassName); + } catch (ClassNotFoundException e) { + log.warn("JDBC driver {} not found on the classpath", driverClassName, e); + } + } + this.url = url; + this.username = username; + this.password = password; + } + + @Override + public Connection getConnection() throws SQLException { + return getConnection(username, password); + } + + @Override + public Connection getConnection(String user, String pass) throws SQLException { + Properties props = new Properties(); + if (user != null) { + props.setProperty("user", user); + } + if (pass != null) { + props.setProperty("password", pass); + } + return DriverManager.getConnection(url, props); + } + + @Override + public PrintWriter getLogWriter() { + return logWriter; + } + + @Override + public void setLogWriter(PrintWriter out) { + this.logWriter = out; + } + + @Override + public void setLoginTimeout(int seconds) { + this.loginTimeout = seconds; + } + + @Override + public int getLoginTimeout() { + return loginTimeout; + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException(); + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + throw new SQLException("DataSource of type [" + getClass().getName() + + "] cannot be unwrapped as [" + iface.getName() + "]"); + } + + @Override + public boolean isWrapperFor(Class iface) { + return iface.isInstance(this); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java index 6538b7ee97..5af7cab52e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/MailConfig.java @@ -2,13 +2,19 @@ package stirling.software.proprietary.security.configuration; import java.util.Properties; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; + +// TODO: Migration required - org.springframework.mail.javamail.* is Spring's mail abstraction, NOT +// Spring DI. There is no Quarkus equivalent that the EmailService collaborator can consume without +// also migrating EmailService (which uses MimeMessage/MimeMessageHelper). Quarkus ships +// quarkus-mailer (io.quarkus.mailer.Mailer / ReactiveMailer) with a different API. Keep the Spring +// Mail types here until EmailService is migrated together, then swap the producer to expose a +// Quarkus Mailer (configured via quarkus.mailer.* in application.properties). import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; @@ -18,15 +24,26 @@ import stirling.software.common.model.ApplicationProperties; * email server settings from the configuration (ApplicationProperties) and configures the mail * client (JavaMailSender). */ -@Configuration +@ApplicationScoped @Slf4j -@AllArgsConstructor -@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false) public class MailConfig { private final ApplicationProperties applicationProperties; - @Bean + @Inject + public MailConfig(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } + + // TODO: Migration required - the original bean was guarded by + // @ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false). + // There is no @ConditionalOnProperty in Quarkus. A build-time toggle could use + // @io.quarkus.arc.lookup.LookupIfProperty(name = "mail.enabled", stringValue = "true"), but + // mail.enabled is a runtime property (ApplicationProperties.Mail#isEnabled). Consumers already + // guard on applicationProperties.getMail().isEnabled() at call time, so the bean is always + // produced and the runtime guard remains the source of truth. + @Produces + @ApplicationScoped public JavaMailSender javaMailSender() { ApplicationProperties.Mail mailProperties = applicationProperties.getMail(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/PasswordEncoderConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/PasswordEncoderConfig.java index 40d04e39ef..038add0d91 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/PasswordEncoderConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/PasswordEncoderConfig.java @@ -1,16 +1,30 @@ package stirling.software.proprietary.security.configuration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; + import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -/** Standalone {@link PasswordEncoder} bean. */ -@Configuration +/** + * Standalone {@link PasswordEncoder} producer. + * + *

TODO: Migration required - spring-security-crypto is no longer on the Quarkus classpath. The + * {@link PasswordEncoder} / {@link BCryptPasswordEncoder} types must be replaced. Options: add a + * BCrypt library (e.g. at.favre.lib:bcrypt or org.mindrot:jbcrypt) and produce a thin local + * PasswordEncoder abstraction, or use io.quarkus.elytron.security.common.BcryptUtil. The + * org.springframework.security imports below are retained only so the bean shape/return type stays + * intact for the consuming services (UserService, SecurityConfiguration) until the encoder + * abstraction is ported across all three files together. + */ +@ApplicationScoped public class PasswordEncoderConfig { - @Bean + @Produces + @ApplicationScoped public PasswordEncoder passwordEncoder() { + // TODO: Migration required - replace BCryptPasswordEncoder once a Quarkus-compatible + // BCrypt implementation is wired in (see class-level note). return new BCryptPasswordEncoder(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java index 34e0116cf6..5ea44fe026 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java @@ -1,22 +1,34 @@ package stirling.software.proprietary.security.configuration; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor; -@Configuration +/** + * TODO: Migration required - Spring MVC's WebMvcConfigurer / InterceptorRegistry has no Quarkus + * (JAX-RS / RESTEasy Reactive) equivalent, so this registration class cannot be ported directly. + * + *

This class only existed to bind {@link ParticipantRateLimitInterceptor} to the path pattern + * "/api/v1/workflow/participant/**". In Quarkus the rate-limiting logic should instead live in a + * {@code jakarta.ws.rs.container.ContainerRequestFilter} annotated with {@code @Provider} (and + * scoped to the participant endpoints via a {@code @NameBinding} annotation or by inspecting + * {@code UriInfo.getPath()} inside the filter). Once {@link ParticipantRateLimitInterceptor} is + * converted to such a filter, the registration is automatic (Quarkus discovers @Provider filters) + * and this class can be deleted entirely. + * + *

Kept as an @ApplicationScoped bean (with no behavior) so the build still discovers the type; + * the collaborator file {@link ParticipantRateLimitInterceptor} must be migrated to complete this. + */ +@ApplicationScoped @RequiredArgsConstructor -public class ProprietaryWebMvcConfig implements WebMvcConfigurer { +public class ProprietaryWebMvcConfig { private final ParticipantRateLimitInterceptor participantRateLimitInterceptor; - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(participantRateLimitInterceptor) - .addPathPatterns("/api/v1/workflow/participant/**"); - } + // TODO: Migration required - the interceptor registration below was removed: + // registry.addInterceptor(participantRateLimitInterceptor) + // .addPathPatterns("/api/v1/workflow/participant/**"); + // Re-implement as a JAX-RS ContainerRequestFilter bound to that path (see class javadoc). } 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 3ca3841265..29866f4de3 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 @@ -3,75 +3,115 @@ package stirling.software.proprietary.security.configuration; import java.util.List; import java.util.regex.Pattern; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.DependsOn; -import org.springframework.context.annotation.Lazy; -import org.springframework.context.annotation.Profile; -import org.springframework.core.annotation.Order; -import org.springframework.security.authentication.ProviderManager; -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.CorsConfigurer; -import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; -import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider; -import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; -import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml5AuthenticationRequestResolver; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; -import org.springframework.security.web.firewall.HttpFirewall; -import org.springframework.security.web.firewall.StrictHttpFirewall; -import org.springframework.security.web.savedrequest.NullRequestCache; -import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Named; import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.util.RequestUriUtils; -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.IPRateLimitingFilter; import stirling.software.proprietary.security.filter.JwtAuthenticationFilter; import stirling.software.proprietary.security.filter.UserAuthenticationFilter; -import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler; -import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler; -import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestResolver; -import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler; -import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler; -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; +/** + * Security configuration migrated from a Spring {@code @Configuration}/{@code @EnableWebSecurity} + * class to a Quarkus CDI bean. + * + *

TODO: Migration required - This class was built entirely around the Spring Security + * {@code HttpSecurity} DSL and {@code SecurityFilterChain} beans, which have NO direct Quarkus + * equivalent. The HTTP security model must be re-expressed declaratively/imperatively: + * + *

    + *
  • HTTP path policies / authorization (the {@code authorizeHttpRequests} rules: permit + * static resources + public auth endpoints via {@code RequestUriUtils}, authenticate + * everything else; permit-all when login is disabled) -> configure {@code quarkus.http.auth.*} + * permission sets in {@code application.properties}, or implement a + * {@code jakarta.ws.rs.container.ContainerRequestFilter} that reuses + * {@link stirling.software.common.util.RequestUriUtils#isStaticResource} and + * {@code isPublicAuthEndpoint}. + *
  • Two ordered filter chains ({@code samlFilterChain} {@code @Order(1)} matching + * {@code /saml2/**} + {@code /login/saml2/**} with {@code IF_REQUIRED} sessions when SAML2 is + * active on pro+, and the catch-all {@code filterChain} {@code @Order(2)} STATELESS) -> Quarkus + * has a single request pipeline; path-specific behaviour must be keyed off the request path + * inside filters/policies. Session creation policy maps to {@code quarkus.http.auth.*} + + * {@code quarkus-undertow} session config. + *
  • CSRF disabled / CORS -> {@code quarkus.http.cors.*} (see + * {@link #buildCorsConfig()} which preserves the original origins/methods/headers values) and + * {@code quarkus.http.csrf} config. + *
  • X-Frame-Options (DENY / SAMEORIGIN / DISABLED driven by + * {@code securityProperties.getXFrameOptions()}, auto-disabled when login is off) -> a response + * filter or {@code quarkus.http.header."X-Frame-Options"} config; the decision logic is kept in + * {@link #resolveXFrameOptions()}. + *
  • Servlet filters ({@link UserAuthenticationFilter}, {@link JwtAuthenticationFilter}, + * {@link IPRateLimitingFilter}) -> register as {@code jakarta.servlet.Filter} via + * quarkus-undertow or convert to {@code ContainerRequestFilter}; ordering (userAuth before + * UsernamePasswordAuthenticationFilter, jwt before userAuth) must be reproduced via + * {@code @jakarta.annotation.Priority}. Note IPRateLimitingFilter was already disabled in the + * Spring chain (see original TODO about async-dispatch / StreamingResponseBody). + *
  • Form login / logout / remember-me ({@code formLogin} -> {@code /login} page + + * {@code /perform_login}, {@code CustomAuthenticationSuccessHandler}/{@code FailureHandler}, + * {@code logout} -> {@code CustomLogoutSuccessHandler} clearing JSESSIONID/remember-me/ + * stirling_jwt cookies, {@code rememberMe} -> {@link JPATokenRepositoryImpl} with 14-day + * validity) -> there is no Quarkus equivalent of the form-login/remember-me machinery. Since + * this is a v2 API-driven auth flow ({@code /api/v1/auth/login}), reimplement as custom JAX-RS + * endpoints + the existing handlers, or wire quarkus-oidc/custom IdentityProvider. + *
  • OAuth2 login ({@code oauth2Login} -> {@code TauriAuthorizationRequestResolver}, + * {@code CustomOAuth2UserService}, {@code CustomOAuth2Authentication*Handler}, + * {@code GrantedAuthoritiesMapper}, {@code ClientRegistrationRepository}) -> migrate to + * quarkus-oidc ({@code quarkus.oidc.*}, {@code @io.quarkus.oidc.IdToken}, + * {@code SecurityIdentityAugmentor}); keep the claim/user-mapping logic in the existing + * services. + *
  • SAML2 login ({@code saml2Login} -> {@code OpenSaml5AuthenticationProvider}, + * {@code CustomSaml2ResponseAuthenticationConverter}, + * {@code CustomSaml2Authentication*Handler}, {@code RelyingPartyRegistrationRepository}, + * {@code OpenSaml5AuthenticationRequestResolver}, {@code saml2Metadata}) -> there is NO Quarkus + * SAML extension. Keep all OpenSAML 5 logic and rehost the SP on a Jakarta {@code @WebServlet} + * (dnulnets/quarkus-saml pattern). The Spring {@code org.springframework.security.saml2.*} glue + * has been removed here. + *
  • HttpFirewall ({@code StrictHttpFirewall} relaxed to allow non-ASCII header/param + * values for reverse proxies like Authelia) -> Spring-Security-only; Quarkus/Vert.x performs + * its own request validation. The allowed-character patterns are preserved in + * {@link #HEADER_VALUE_PATTERN}/{@link #PARAM_VALUE_PATTERN} for reuse if a custom validator is + * added. + *
  • DaoAuthenticationProvider + {@code PasswordEncoder} ({@code @EnableMethodSecurity}, + * {@code ProviderManager}) -> replace with a Quarkus {@code IdentityProvider} backed by + * {@link CustomUserDetailsService}; method-level security maps to + * {@code jakarta.annotation.security.@RolesAllowed}. + *
+ * + *

The collaborators are still injected so the wiring is preserved for the reimplementation. The + * reusable, non-Spring helper logic (CORS values, X-Frame-Options decision, firewall char patterns, + * filter/repository factories) is retained as plain methods/producers below. + * + *

TODO: Migration required - this bean was {@code @DependsOn("runningProOrHigher")} and + * {@code @Profile("!saas")}. The dependency ordering is approximated by injecting the + * {@code runningProOrHigher} flag; the {@code !saas} profile gate maps to a Quarkus build profile - + * use {@code @io.quarkus.arc.profile.UnlessBuildProfile("saas")} or + * {@code @io.quarkus.arc.lookup.LookupIfProperty} (adjust to the actual saas profile/property + * toggle). + */ @Slf4j -@Configuration -@EnableWebSecurity -@EnableMethodSecurity -@DependsOn("runningProOrHigher") -@Profile("!saas") +@ApplicationScoped public class SecurityConfiguration { + // Allowed-character patterns preserved from the original StrictHttpFirewall relaxation + // (non-ASCII allowed for reverse proxies, control chars rejected). See class-level TODO. + static final Pattern HEADER_VALUE_PATTERN = + Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*"); + static final Pattern PARAM_VALUE_PATTERN = + Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]\\r\\n]*"); + private final CustomUserDetailsService userDetailsService; private final UserService userService; private final boolean loginEnabledValue; @@ -86,21 +126,25 @@ public class SecurityConfiguration { private final LoginAttemptService loginAttemptService; private final SessionPersistentRegistry sessionRegistry; private final PersistentLoginRepository persistentLoginRepository; - private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; - private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations; - private final OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver; private final stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService; - private final ClientRegistrationRepository clientRegistrationRepository; - private final PasswordEncoder passwordEncoder; private final stirling.software.proprietary.service.AiUserDataService aiUserDataService; + // TODO: Migration required - the following Spring-Security collaborators were injected as + // @Autowired(required=false) optional beans and consumed only inside the removed HttpSecurity + // DSL (GrantedAuthoritiesMapper, RelyingPartyRegistrationRepository, + // OpenSaml5AuthenticationRequestResolver, ClientRegistrationRepository, PasswordEncoder). They + // are dropped here because their types are Spring-Security-only; reintroduce equivalents + // (quarkus-oidc client config, OpenSAML 5 SP wiring, a CDI password hasher) during the + // OAuth2/SAML2/auth reimplementation described in the class javadoc. + + @Inject public SecurityConfiguration( PersistentLoginRepository persistentLoginRepository, CustomUserDetailsService userDetailsService, - @Lazy UserService userService, - @Qualifier("loginEnabled") boolean loginEnabledValue, - @Qualifier("runningProOrHigher") boolean runningProOrHigher, + UserService userService, + @Named("loginEnabled") boolean loginEnabledValue, + @Named("runningProOrHigher") boolean runningProOrHigher, AppConfig appConfig, ApplicationProperties applicationProperties, ApplicationProperties.Security securityProperties, @@ -109,14 +153,7 @@ public class SecurityConfiguration { JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, SessionPersistentRegistry sessionRegistry, - @Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper, - @Autowired(required = false) - RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations, - @Autowired(required = false) - OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver, - @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository, stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService, - PasswordEncoder passwordEncoder, stirling.software.proprietary.service.AiUserDataService aiUserDataService) { this.userDetailsService = userDetailsService; this.userService = userService; @@ -131,358 +168,135 @@ public class SecurityConfiguration { this.loginAttemptService = loginAttemptService; this.sessionRegistry = sessionRegistry; this.persistentLoginRepository = persistentLoginRepository; - this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper; - this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations; - this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver; - this.clientRegistrationRepository = clientRegistrationRepository; this.licenseSettingsService = licenseSettingsService; - this.passwordEncoder = passwordEncoder; this.aiUserDataService = aiUserDataService; } /** - * Configures HttpFirewall to allow non-ASCII characters in header values. This fixes issues - * with reverse proxies (like Authelia) that may set headers with non-ASCII characters (e.g., - * "Remote-User: Dvořák"). + * Reusable CORS settings preserved from the original {@code corsConfigurationSource()} bean. * - *

By default, StrictHttpFirewall rejects header values containing non-ASCII characters. This - * configuration allows valid UTF-8 encoded characters while maintaining security. + *

TODO: Migration required - the Spring {@code CorsConfigurationSource}/ + * {@code UrlBasedCorsConfigurationSource} types are removed. Apply these values via + * {@code quarkus.http.cors.*} in {@code application.properties} (origins, methods, headers, + * exposed-headers, access-control-allow-credentials=true, access-control-max-age=PT1H) or a + * {@code ContainerResponseFilter}. The origin resolution from + * {@code applicationProperties.getSystem().getCorsAllowedOrigins()} (defaulting to "*") is kept + * here so it can feed whichever mechanism is chosen. * - * @return Configured HttpFirewall that allows non-ASCII characters in headers + * @return the resolved allowed origin patterns ("*" when none configured) */ - @Bean - public HttpFirewall httpFirewall() { - StrictHttpFirewall firewall = new StrictHttpFirewall(); - // Allow non-ASCII characters but continue to reject control characters such as newlines. - // Pattern adapted from Spring Security's StrictHttpFirewall documentation. - Pattern allowedChars = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*"); - - firewall.setAllowedHeaderValues( - headerValue -> headerValue != null && allowedChars.matcher(headerValue).matches()); - - // Allow non-ASCII characters and newlines in parameter values. - Pattern allowedParamChars = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]\\r\\n]*"); - firewall.setAllowedParameterValues( - parameterValue -> - parameterValue != null - && allowedParamChars.matcher(parameterValue).matches()); - return firewall; - } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { + List buildCorsConfig() { List configuredOrigins = null; if (applicationProperties.getSystem() != null) { configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins(); } - CorsConfiguration cfg = new CorsConfiguration(); if (configuredOrigins != null && !configuredOrigins.isEmpty()) { - cfg.setAllowedOriginPatterns(configuredOrigins); log.debug( "CORS configured with allowed origin patterns from settings.yml: {}", configuredOrigins); - } else { - // Default to allowing all origins when nothing is configured - cfg.setAllowedOriginPatterns(List.of("*")); - log.info( - "No CORS allowed origins configured in settings.yml" - + " (system.corsAllowedOrigins); allowing all origins."); + return configuredOrigins; } - - // Explicitly configure supported HTTP methods (include OPTIONS for preflight) - cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - - cfg.setAllowedHeaders( - List.of( - "Authorization", - "Content-Type", - "X-Requested-With", - "Accept", - "Origin", - "X-API-KEY", - "X-CSRF-TOKEN", - "X-XSRF-TOKEN")); - - cfg.setExposedHeaders( - List.of( - "WWW-Authenticate", - "X-Total-Count", - "X-Page-Number", - "X-Page-Size", - "Content-Disposition", - "Content-Type")); - - cfg.setAllowCredentials(true); - cfg.setMaxAge(3600L); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", cfg); - return source; + // Default to allowing all origins when nothing is configured + log.info( + "No CORS allowed origins configured in settings.yml" + + " (system.corsAllowedOrigins); allowing all origins."); + return List.of("*"); } - @Bean - @Order(1) - public SecurityFilterChain samlFilterChain( - HttpSecurity http, - @Lazy IPRateLimitingFilter rateLimitingFilter, - @Lazy JwtAuthenticationFilter jwtAuthenticationFilter) - throws Exception { - http.securityMatcher("/saml2/**", "/login/saml2/**"); + // Preserved CORS value sets (apply via quarkus.http.cors.* - see buildCorsConfig() TODO). + static final List CORS_ALLOWED_METHODS = + List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"); + static final List CORS_ALLOWED_HEADERS = + List.of( + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "X-API-KEY", + "X-CSRF-TOKEN", + "X-XSRF-TOKEN"); + static final List CORS_EXPOSED_HEADERS = + List.of( + "WWW-Authenticate", + "X-Total-Count", + "X-Page-Number", + "X-Page-Size", + "Content-Disposition", + "Content-Type"); - SessionCreationPolicy sessionPolicy = - (securityProperties.isSaml2Active() && runningProOrHigher) - ? SessionCreationPolicy.IF_REQUIRED - : SessionCreationPolicy.STATELESS; - - return configureSecurity(http, rateLimitingFilter, jwtAuthenticationFilter, sessionPolicy); - } - - @Bean - @Order(2) - public SecurityFilterChain filterChain( - HttpSecurity http, - @Lazy IPRateLimitingFilter rateLimitingFilter, - @Lazy JwtAuthenticationFilter jwtAuthenticationFilter) - throws Exception { - SessionCreationPolicy sessionPolicy = SessionCreationPolicy.STATELESS; - return configureSecurity(http, rateLimitingFilter, jwtAuthenticationFilter, sessionPolicy); - } - - private SecurityFilterChain configureSecurity( - HttpSecurity http, - @Lazy IPRateLimitingFilter rateLimitingFilter, - @Lazy JwtAuthenticationFilter jwtAuthenticationFilter, - SessionCreationPolicy sessionPolicy) - throws Exception { - // Enable CORS only if we have configured origins - CorsConfigurationSource corsSource = corsConfigurationSource(); - if (corsSource != null) { - http.cors(cors -> cors.configurationSource(corsSource)); - } else { - // Explicitly disable CORS when no origins are configured - http.cors(CorsConfigurer::disable); - } - - http.csrf(CsrfConfigurer::disable); - - // Configure X-Frame-Options based on settings.yml configuration - // When login is disabled, automatically disable X-Frame-Options to allow embedding + /** + * Resolves the desired X-Frame-Options header value, preserving the original decision logic. + * + *

TODO: Migration required - apply the returned value via a response filter or + * {@code quarkus.http.header} config (Spring's {@code HeadersConfigurer} is gone). + * + * @return "DISABLED", "SAMEORIGIN" or "DENY" + */ + String resolveXFrameOptions() { + // When login is disabled, X-Frame-Options is disabled to allow embedding. if (!loginEnabledValue) { - http.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable)); - } else { - String xFrameOption = securityProperties.getXFrameOptions(); - if (xFrameOption != null) { - http.headers( - headers -> { - if ("DISABLED".equalsIgnoreCase(xFrameOption)) { - headers.frameOptions(FrameOptionsConfig::disable); - } else if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) { - headers.frameOptions(FrameOptionsConfig::sameOrigin); - } else { - // Default to DENY - headers.frameOptions(FrameOptionsConfig::deny); - } - }); - } else { - // If not configured, use default DENY - http.headers(headers -> headers.frameOptions(FrameOptionsConfig::deny)); - } + return "DISABLED"; } - - if (loginEnabledValue) { - - http.addFilterBefore( - userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - // TODO: IPRateLimitingFilter disabled (limit is 1M, no-op) and raw Filter - // impl causes Spring Security async dispatch bug (response already committed - // errors on StreamingResponseBody endpoints). Re-enable once converted to - // OncePerRequestFilter with proper config-driven limits. - // .addFilterBefore(rateLimitingFilter, - // UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class); - - http.sessionManagement( - sessionManagement -> sessionManagement.sessionCreationPolicy(sessionPolicy)); - http.authenticationProvider(daoAuthenticationProvider()); - http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); - - // Configure exception handling for API endpoints - http.exceptionHandling( - exceptions -> - exceptions.defaultAuthenticationEntryPointFor( - jwtAuthenticationEntryPoint, - request -> { - String contextPath = request.getContextPath(); - String requestURI = request.getRequestURI(); - return requestURI.startsWith(contextPath + "/api/"); - })); - - http.logout( - logout -> - logout.logoutRequestMatcher( - PathPatternRequestMatcher.withDefaults() - .matcher("/logout")) - .logoutSuccessHandler( - new CustomLogoutSuccessHandler( - securityProperties, - appConfig, - jwtService, - aiUserDataService)) - .clearAuthentication(true) - .invalidateHttpSession(true) - .deleteCookies("JSESSIONID", "remember-me", "stirling_jwt")); - http.rememberMe( - rememberMeConfigurer -> // Use the configurator directly - rememberMeConfigurer - .tokenRepository(persistentTokenRepository()) - .tokenValiditySeconds( // 14 days - 14 * 24 * 60 * 60) - .userDetailsService( // Your existing UserDetailsService - userDetailsService) - .useSecureCookie( // Enable secure cookie - true) - .rememberMeParameter( // Form parameter name - "remember-me") - .rememberMeCookieName( // Cookie name - "remember-me") - .alwaysRemember(false)); - http.authorizeHttpRequests( - authz -> - authz.requestMatchers( - req -> { - String uri = req.getRequestURI(); - String contextPath = req.getContextPath(); - // Check if it's a public auth endpoint or static - // resource - return RequestUriUtils.isStaticResource( - contextPath, uri) - || RequestUriUtils.isPublicAuthEndpoint( - uri, contextPath); - }) - .permitAll() - .anyRequest() - .authenticated()); - // Handle User/Password Logins - if (securityProperties.isUserPass()) { - // v2: Authentication is handled via API (/api/v1/auth/login), not form login - // We configure form login to handle Spring Security redirects, - // but use /perform_login as the processing URL so /login remains a React route - http.formLogin( - formLogin -> - formLogin - .loginPage("/login") // Redirect here when unauthenticated - .loginProcessingUrl( - "/perform_login") // Process form posts here (not - // /login) - .successHandler( - new CustomAuthenticationSuccessHandler( - loginAttemptService, - userService, - jwtService)) - .failureHandler( - new CustomAuthenticationFailureHandler( - loginAttemptService, userService)) - .permitAll()); - } - // Handle OAUTH2 Logins - if (securityProperties.isOauth2Active()) { - http.oauth2Login( - oauth2 -> { - oauth2.loginPage("/login") - .authorizationEndpoint( - authorizationEndpoint -> { - if (clientRegistrationRepository != null) { - authorizationEndpoint - .authorizationRequestResolver( - new TauriAuthorizationRequestResolver( - clientRegistrationRepository)); - } - }) - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService, - licenseSettingsService, - applicationProperties)) - .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties - .getOauth2(), - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll(); - }); - } - // Handle SAML - if (securityProperties.isSaml2Active() && runningProOrHigher) { - OpenSaml5AuthenticationProvider authenticationProvider = - new OpenSaml5AuthenticationProvider(); - authenticationProvider.setResponseAuthenticationConverter( - new CustomSaml2ResponseAuthenticationConverter(userService)); - http.authenticationProvider(authenticationProvider) - .saml2Login( - saml2 -> { - try { - saml2.loginPage("/login") - .relyingPartyRegistrationRepository( - saml2RelyingPartyRegistrations) - .authenticationManager( - new ProviderManager(authenticationProvider)) - .successHandler( - new CustomSaml2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getSaml2(), - userService, - jwtService, - licenseSettingsService, - applicationProperties)) - .failureHandler( - new CustomSaml2AuthenticationFailureHandler()) - .authenticationRequestResolver( - saml2AuthenticationRequestResolver); - } catch (Exception e) { - log.error("Error configuring SAML 2 login", e); - throw new RuntimeException(e); - } - }) - .saml2Metadata(metadata -> {}); - } - } else { - log.debug("Login is not enabled."); - http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); + String xFrameOption = securityProperties.getXFrameOptions(); + if (xFrameOption == null) { + return "DENY"; } - return http.build(); + if ("DISABLED".equalsIgnoreCase(xFrameOption)) { + return "DISABLED"; + } + if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) { + return "SAMEORIGIN"; + } + return "DENY"; } - public DaoAuthenticationProvider daoAuthenticationProvider() { - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); - provider.setPasswordEncoder(passwordEncoder); - return provider; - } + // TODO: Migration required - samlFilterChain/filterChain/configureSecurity built the Spring + // SecurityFilterChain instances. Their behaviour is summarised in the class javadoc and must be + // reimplemented via Quarkus HTTP auth config + filters/IdentityProviders. The full original DSL + // is preserved in version control. No fabricated SecurityFilterChain is produced here. - @Bean + /** + * Produces the IP rate-limiting filter (plain {@code jakarta.servlet.Filter}, not a + * Spring-specific type, so it remains a CDI producer). + * + *

TODO: Migration required - registration/ordering must be handled by quarkus-undertow + * ({@code @WebFilter}) or a {@code ContainerRequestFilter}. This filter was already disabled in + * the original chain (limit is effectively a no-op at 1,000,000) pending conversion. + */ + @Produces + @ApplicationScoped public IPRateLimitingFilter rateLimitingFilter() { // Example limit TODO add config level int maxRequestsPerIp = 1000000; return new IPRateLimitingFilter(maxRequestsPerIp, maxRequestsPerIp); } - @Bean - public PersistentTokenRepository persistentTokenRepository() { + /** + * Produces the persistent remember-me token repository. + * + *

TODO: Migration required - {@link JPATokenRepositoryImpl} implements the Spring Security + * {@code PersistentTokenRepository} interface (collaborator not yet migrated). The remember-me + * feature itself has no Quarkus equivalent (see class javadoc); the repository is still produced + * so the persistence logic is available to the reimplementation. Producer return type narrowed + * to the concrete class to avoid importing the Spring interface here. + */ + @Produces + @ApplicationScoped + public JPATokenRepositoryImpl persistentTokenRepository() { return new JPATokenRepositoryImpl(persistentLoginRepository); } - @Bean + /** + * Produces the JWT authentication filter. + * + *

TODO: Migration required - registration/ordering (must run before the user-auth filter) is + * no longer expressible via the Spring DSL; register via quarkus-undertow or convert to a + * {@code ContainerRequestFilter} with an explicit {@code @Priority}. + */ + @Produces + @ApplicationScoped public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter( jwtService, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/DynamicLicenseService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/DynamicLicenseService.java index 849c15c889..4459d63898 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/DynamicLicenseService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/DynamicLicenseService.java @@ -1,6 +1,6 @@ package stirling.software.proprietary.security.configuration.ee; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -18,7 +18,7 @@ import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVeri * changes in production typically warrant a restart anyway 3. UI reflects changes immediately * (banner disappears, license status updates) */ -@Service +@ApplicationScoped @RequiredArgsConstructor public class DynamicLicenseService implements LicenseServiceInterface { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java index 4fb6a0de36..51a0f5250f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java @@ -2,52 +2,93 @@ package stirling.software.proprietary.security.configuration.ee; import static stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Named; + +import io.quarkus.arc.profile.IfBuildProfile; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.EnterpriseEdition; import stirling.software.common.model.ApplicationProperties.Premium; -@Configuration -@Order(Ordered.HIGHEST_PRECEDENCE) +/** + * Enterprise/Premium CDI producers (migrated from a Spring {@code @Configuration} class). + * + *

MIGRATION NOTES (Spring -> Quarkus CDI): + * + *

    + *
  • {@code @Configuration} -> {@code @ApplicationScoped}; {@code @Bean(name="x")} -> + * {@code @Produces @Named("x")}. These producers deliberately omit {@code @DefaultBean} so + * they OVERRIDE the {@code @DefaultBean} producers declared in + * {@code stirling.software.common.configuration.AppConfig} whenever the :proprietary module is + * on the classpath - this is the Quarkus idiom for Spring's profile-based bean override. + *
  • {@code @Profile("security & !saas")} -> {@code @IfBuildProfile("security")}. Spring's + * composite expression {@code security & !saas} cannot be expressed directly; the build-time + * profile gates "security". TODO: Migration required - the {@code & !saas} half of the + * expression is NOT honoured here. In :saas mode the (still to be migrated) + * {@code SaasLicenseOverride} producers must take precedence, and these enterprise producers + * must be suppressed, otherwise CDI will see two producers for the same {@code @Named} + * qualifier. Re-evaluate once :saas is migrated (e.g. gate on a runtime "saas" flag or split + * into separate build profiles). + *
  • {@code @Order(Ordered.HIGHEST_PRECEDENCE)} dropped - CDI has no ordered configuration + * classes; ordering was only used by Spring to win the bean override race, which {@code + * @DefaultBean}/no-{@code @DefaultBean} now handles. + *
  • Constructor-side {@code migrateEnterpriseSettingsToPremium(...)} call moved to a + * {@code @PostConstruct} method so it still runs once when the bean is created. + *
  • {@code boolean} producers marked {@code @Dependent}: a CDI normal scope (default + * {@code @ApplicationScoped} on a producer) requires a client proxy which is impossible for a + * primitive {@code boolean}, so {@code @Dependent} is used to recompute the value at each + * injection point. + *
+ */ +@ApplicationScoped +@IfBuildProfile("security") public class EEAppConfig { private final ApplicationProperties applicationProperties; private final LicenseKeyChecker licenseKeyChecker; + @Inject public EEAppConfig( ApplicationProperties applicationProperties, LicenseKeyChecker licenseKeyChecker) { this.applicationProperties = applicationProperties; this.licenseKeyChecker = licenseKeyChecker; + } + + @PostConstruct + void init() { migrateEnterpriseSettingsToPremium(this.applicationProperties); } - @Profile("security & !saas") - @Bean(name = "runningProOrHigher") + @Produces + @Dependent + @Named("runningProOrHigher") public boolean runningProOrHigher() { License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); return license == License.SERVER || license == License.ENTERPRISE; } - @Profile("security & !saas") - @Bean(name = "license") + @Produces + @Named("license") public String licenseType() { return licenseKeyChecker.getPremiumLicenseEnabledResult().name(); } - @Profile("security & !saas") - @Bean(name = "runningEE") + @Produces + @Dependent + @Named("runningEE") public boolean runningEnterprise() { return licenseKeyChecker.getPremiumLicenseEnabledResult() == License.ENTERPRISE; } - @Profile("security & !saas") - @Bean(name = "SSOAutoLogin") + @Produces + @Dependent + @Named("SSOAutoLogin") public boolean ssoAutoLogin() { boolean enabled = applicationProperties.getPremium().getProFeatures().isSsoAutoLogin(); if (enabled) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java index 1680aa9285..2a378d62b3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java @@ -10,7 +10,8 @@ import java.util.Locale; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.bouncycastle.crypto.signers.Ed25519Signer; import org.bouncycastle.util.encoders.Hex; -import org.springframework.stereotype.Service; + +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,7 +23,7 @@ import stirling.software.common.util.RegexPatternUtils; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; -@Service +@ApplicationScoped @Slf4j @RequiredArgsConstructor public class KeygenLicenseVerifier { 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 af9ac192d7..8a759533ff 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 @@ -5,13 +5,13 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.annotation.Lazy; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; +import io.quarkus.runtime.StartupEvent; +import io.quarkus.scheduler.Scheduled; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; @@ -21,7 +21,7 @@ import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVeri import stirling.software.proprietary.service.UserLicenseSettingsService; @Slf4j -@Component +@ApplicationScoped public class LicenseKeyChecker { private static final String FILE_PREFIX = "file:"; @@ -37,10 +37,11 @@ public class LicenseKeyChecker { // the latest tier rather than a stale cached value. private volatile License premiumEnabledResult = License.NORMAL; + @Inject public LicenseKeyChecker( KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties, - @Lazy UserLicenseSettingsService licenseSettingsService) { + UserLicenseSettingsService licenseSettingsService) { this.licenseService = licenseService; this.applicationProperties = applicationProperties; this.licenseSettingsService = licenseSettingsService; @@ -51,12 +52,15 @@ public class LicenseKeyChecker { evaluateLicense(); } - @EventListener(ApplicationReadyEvent.class) - public void onApplicationReady() { + public void onApplicationReady(@Observes StartupEvent event) { synchronizeLicenseSettings(); } - @Scheduled(initialDelay = 604800000, fixedRate = 604800000) // 7 days in milliseconds + // TODO: Migration required - Spring used initialDelay=fixedRate=7d. Quarkus @Scheduled has no + // initialDelay equivalent for fixed-rate; "every=7d" fires the first run 7 days after start, + // which preserves the original initial-delay semantics. delayed="..." could add an extra offset + // if needed. + @Scheduled(every = "7d") public void checkLicensePeriodically() { try { evaluateLicense(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java index e9cd65f7ca..10e650a883 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -9,17 +9,19 @@ import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.Map; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -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.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -28,6 +30,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.common.util.GeneralUtils; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; @@ -37,20 +41,22 @@ import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker * Admin controller for license management. Provides installation ID for Stripe checkout metadata * and endpoints for managing license keys. */ -@RestController +@ApplicationScoped @Slf4j -@RequestMapping("/api/v1/admin") -@PreAuthorize("hasRole('ADMIN')") +@jakarta.ws.rs.Path("/api/v1/admin") +@RolesAllowed("ADMIN") @Tag(name = "Admin License Management", description = "Admin-only License Management APIs") public class AdminLicenseController { - @Autowired(required = false) - private LicenseKeyChecker licenseKeyChecker; + @Inject Instance licenseKeyCheckerInstance; - @Autowired(required = false) - private KeygenLicenseVerifier keygenLicenseVerifier; + @Inject Instance keygenLicenseVerifierInstance; - @Autowired private ApplicationProperties applicationProperties; + @Inject ApplicationProperties applicationProperties; + + private LicenseKeyChecker licenseKeyChecker() { + return licenseKeyCheckerInstance.isResolvable() ? licenseKeyCheckerInstance.get() : null; + } /** * Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used @@ -58,21 +64,24 @@ public class AdminLicenseController { * * @return Map containing the installation ID */ - @GetMapping("/installation-id") + @GET + @jakarta.ws.rs.Path("/installation-id") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Get installation ID", description = "Returns the unique installation ID (MAC-based fingerprint) for this" + " self-hosted instance") - public ResponseEntity> getInstallationId() { + public Response getInstallationId() { try { String installationId = GeneralUtils.generateMachineFingerprint(); log.info("Admin requested installation ID: {}", installationId); - return ResponseEntity.ok(Map.of("installationId", installationId)); + return Response.ok(Map.of("installationId", installationId)).build(); } catch (Exception e) { log.error("Failed to generate installation ID", e); - return ResponseEntity.internalServerError() - .body(Map.of("error", "Failed to generate installation ID")); + return Response.serverError() + .entity(Map.of("error", "Failed to generate installation ID")) + .build(); } } @@ -83,26 +92,31 @@ public class AdminLicenseController { * @param request Map containing the license key * @return Response with success status, license type, and whether restart is required */ - @PostMapping("/license-key") + @POST + @jakarta.ws.rs.Path("/license-key") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Save and activate license key", description = "Accepts a license key and activates it on the backend. Returns the activated" + " license type.") - public ResponseEntity> saveLicenseKey( - @RequestBody Map request) { + public Response saveLicenseKey(Map request) { String licenseKey = request.get("licenseKey"); // Reject null but allow empty string to clear license if (licenseKey == null) { - return ResponseEntity.badRequest() - .body(Map.of("success", false, "error", "License key is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("success", false, "error", "License key is required")) + .build(); } try { + LicenseKeyChecker licenseKeyChecker = licenseKeyChecker(); if (licenseKeyChecker == null) { - return ResponseEntity.internalServerError() - .body(Map.of("success", false, "error", "License checker not available")); + return Response.serverError() + .entity(Map.of("success", false, "error", "License checker not available")) + .build(); } // assume premium enabled when setting license key applicationProperties.getPremium().setEnabled(true); @@ -139,16 +153,17 @@ public class AdminLicenseController { log.info("License key saved and activated: type={}", license.name()); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } catch (Exception e) { log.error("Failed to save license key", e); - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "success", false, "error", - "Failed to activate license: " + e.getMessage())); + "Failed to activate license: " + e.getMessage())) + .build(); } } @@ -159,23 +174,28 @@ public class AdminLicenseController { * * @return Response with updated license information */ - @PostMapping("/license/resync") + @POST + @jakarta.ws.rs.Path("/license/resync") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Resync license with Keygen", description = "Re-validates the existing license key with Keygen and updates local settings." + " Used after subscription upgrades.") - public ResponseEntity> resyncLicense() { + public Response resyncLicense() { try { + LicenseKeyChecker licenseKeyChecker = licenseKeyChecker(); if (licenseKeyChecker == null) { - return ResponseEntity.internalServerError() - .body(Map.of("success", false, "error", "License checker not available")); + return Response.serverError() + .entity(Map.of("success", false, "error", "License checker not available")) + .build(); } String currentKey = applicationProperties.getPremium().getKey(); if (currentKey == null || currentKey.trim().isEmpty()) { - return ResponseEntity.badRequest() - .body(Map.of("success", false, "error", "No license key configured")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("success", false, "error", "No license key configured")) + .build(); } log.info("Resyncing license with Keygen"); @@ -199,16 +219,17 @@ public class AdminLicenseController { license.name(), premium.getMaxUsers()); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } catch (Exception e) { log.error("Failed to resync license", e); - return ResponseEntity.internalServerError() - .body( + return Response.serverError() + .entity( Map.of( "success", false, "error", - "Failed to resync license: " + e.getMessage())); + "Failed to resync license: " + e.getMessage())) + .build(); } } @@ -218,16 +239,19 @@ public class AdminLicenseController { * * @return Map containing license information */ - @GetMapping("/license-info") + @GET + @jakarta.ws.rs.Path("/license-info") + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Get license information", description = "Returns information about the current license including type, enabled status," + " and max users") - public ResponseEntity> getLicenseInfo() { + public Response getLicenseInfo() { try { Map response = new HashMap<>(); + LicenseKeyChecker licenseKeyChecker = licenseKeyChecker(); if (licenseKeyChecker != null) { License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); response.put("licenseType", license.name()); @@ -245,11 +269,12 @@ public class AdminLicenseController { response.put("licenseKey", premium.getKey()); } - return ResponseEntity.ok(response); + return Response.ok(response).build(); } catch (Exception e) { log.error("Failed to get license info", e); - return ResponseEntity.internalServerError() - .body(Map.of("error", "Failed to retrieve license information")); + return Response.serverError() + .entity(Map.of("error", "Failed to retrieve license information")) + .build(); } } @@ -257,55 +282,64 @@ public class AdminLicenseController { * Upload a license certificate file for offline activation. Accepts .lic or .cert files, * validates the certificate format, saves to configs directory, and activates the license. * - * @param file The license certificate file to upload + * @param fileUpload The license certificate file to upload * @return Response with success status, license type, and file information */ - @PostMapping(value = "/license-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @POST + @jakarta.ws.rs.Path("/license-file") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Upload license certificate file", description = "Upload a license certificate file (.lic, .cert) for offline activation." + " Validates the file format and activates the license.") - public ResponseEntity> uploadLicenseFile( - @RequestParam("file") MultipartFile file) { + public Response uploadLicenseFile(@RestForm("file") FileUpload fileUpload) { + + MultipartFile file = FileUploadMultipartFile.of(fileUpload); // Validate file exists if (file == null || file.isEmpty()) { - return ResponseEntity.badRequest() - .body(Map.of("success", false, "error", "File is empty")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("success", false, "error", "File is empty")) + .build(); } String filename = file.getOriginalFilename(); if (filename == null || filename.trim().isEmpty()) { - return ResponseEntity.badRequest() - .body(Map.of("success", false, "error", "Invalid filename")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("success", false, "error", "Invalid filename")) + .build(); } // Prevent path traversal and enforce single filename component if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) { - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "success", false, "error", - "Filename must not contain path separators or '..'")); + "Filename must not contain path separators or '..'")) + .build(); } // Validate file extension if (!isValidLicenseFile(filename)) { - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "success", false, "error", - "Invalid file type. Expected .lic or .cert")); + "Invalid file type. Expected .lic or .cert")) + .build(); } // Check file size (max 1MB for license files) if (file.getSize() > 1_048_576) { - return ResponseEntity.badRequest() - .body(Map.of("success", false, "error", "File too large. Maximum 1MB allowed")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("success", false, "error", "File too large. Maximum 1MB allowed")) + .build(); } try { @@ -319,13 +353,14 @@ public class AdminLicenseController { String content = new String(fileBytes, StandardCharsets.UTF_8); if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) { log.warn("License upload rejected: invalid certificate header"); - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "success", false, "error", - "Invalid license certificate format")); + "Invalid license certificate format")) + .build(); } // Get config directory and target path @@ -339,8 +374,9 @@ public class AdminLicenseController { // Prevent directory traversal: ensure targetPath is inside configPath if (!targetPath.startsWith(configPathAbs)) { log.warn("License upload rejected: target path outside config path"); - return ResponseEntity.badRequest() - .body(Map.of("success", false, "error", "Invalid file path")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("success", false, "error", "Invalid file path")) + .build(); } // Backup existing file if present @@ -364,6 +400,7 @@ public class AdminLicenseController { // Update settings with file reference (relative path) String fileReference = "file:configs/" + filename; + LicenseKeyChecker licenseKeyChecker = licenseKeyChecker(); licenseKeyChecker.updateLicenseKey(fileReference); // Get license status after activation @@ -383,26 +420,28 @@ public class AdminLicenseController { filename, license.name()); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } catch (IOException e) { log.error("Failed to save license file", e); - return ResponseEntity.internalServerError() - .body( + return Response.serverError() + .entity( Map.of( "success", false, "error", - "Failed to save license file: " + e.getMessage())); + "Failed to save license file: " + e.getMessage())) + .build(); } catch (Exception e) { log.error("Failed to activate license from file", e); - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "success", false, "error", - "Failed to activate license: " + e.getMessage())); + "Failed to activate license: " + e.getMessage())) + .build(); } } 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 06fc80c751..429f416f75 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 @@ -14,26 +14,24 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; -import org.springframework.boot.SpringApplication; -import org.springframework.context.ApplicationContext; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.util.HtmlUtils; +import io.quarkus.runtime.Quarkus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.validation.Valid; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.AdminApi; @@ -49,15 +47,18 @@ import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequ import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; +// @AdminApi carries only the OpenAPI @Tag under JAX-RS; the @Path the removed @RequestMapping +// supplied must be declared explicitly. Fully-qualified @jakarta.ws.rs.Path is used to avoid a +// clash with the java.nio.file.Path import. @AdminApi -@RequiredArgsConstructor -@PreAuthorize("hasRole('ADMIN')") +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/admin/settings") +@RolesAllowed("ADMIN") @Slf4j public class AdminSettingsController { - private final ApplicationProperties applicationProperties; - private final ObjectMapper objectMapper; - private final ApplicationContext applicationContext; + @Inject ApplicationProperties applicationProperties; + @Inject ObjectMapper objectMapper; // Track settings that have been modified but not yet applied (require restart) private static final ConcurrentHashMap pendingChanges = @@ -86,7 +87,7 @@ public class AdminSettingsController { "enterprisekey", "licensekey")); - @GetMapping + @GET @Operation( summary = "Get all application settings", description = @@ -99,9 +100,8 @@ public class AdminSettingsController { responseCode = "403", description = "Access denied - Admin role required") }) - public ResponseEntity getSettings( - @RequestParam(value = "includePending", defaultValue = "false") - boolean includePending) { + public Response getSettings( + @QueryParam("includePending") @DefaultValue("false") boolean includePending) { log.debug("Admin requested all application settings (includePending={})", includePending); // Convert ApplicationProperties to Map @@ -117,10 +117,11 @@ public class AdminSettingsController { // Mask sensitive fields after merging Map maskedSettings = maskSensitiveFields(settings); - return ResponseEntity.ok(maskedSettings); + return Response.ok(maskedSettings).build(); } - @GetMapping("/delta") + @GET + @jakarta.ws.rs.Path("/delta") @Operation( summary = "Get pending settings changes", description = @@ -135,7 +136,7 @@ public class AdminSettingsController { responseCode = "403", description = "Access denied - Admin role required") }) - public ResponseEntity getSettingsDelta() { + public Response getSettingsDelta() { Map response = new HashMap<>(); // Mask sensitive fields in pending changes response.put("pendingChanges", maskSensitiveFields(new HashMap<>(pendingChanges))); @@ -143,10 +144,10 @@ public class AdminSettingsController { response.put("count", pendingChanges.size()); log.debug("Admin requested pending changes - found {} settings", pendingChanges.size()); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } - @PutMapping + @PUT @Operation( summary = "Update application settings (delta updates)", description = @@ -163,13 +164,13 @@ public class AdminSettingsController { responseCode = "500", description = "Failed to save settings to configuration file") }) - public ResponseEntity> updateSettings( - @Valid @RequestBody UpdateSettingsRequest request) { + public Response updateSettings(@Valid UpdateSettingsRequest request) { try { Map settings = request.getSettings(); if (settings == null || settings.isEmpty()) { - return ResponseEntity.badRequest() - .body(Map.of("error", "No settings provided to update")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "No settings provided to update")) + .build(); } // Validate all settings first before applying any changes @@ -178,19 +179,20 @@ public class AdminSettingsController { Object value = entry.getValue(); if (!isValidSettingKey(key)) { - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", - "Invalid setting key format: " - + HtmlUtils.htmlEscape(key))); + "Invalid setting key format: " + htmlEscape(key))) + .build(); } // Validate pipeline path settings String validationError = validatePipelinePathSetting(key, value); if (validationError != null) { - return ResponseEntity.badRequest() - .body(Map.of("error", HtmlUtils.htmlEscape(validationError))); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", htmlEscape(validationError))) + .build(); } } @@ -206,31 +208,36 @@ public class AdminSettingsController { pendingChanges.put(key, value != null ? value : ""); } - return ResponseEntity.ok( - Map.of( - "message", - String.format( - "Successfully updated %d setting(s). Changes will take effect on" - + " application restart.", - settings.size()))); + return Response.ok( + Map.of( + "message", + String.format( + "Successfully updated %d setting(s). Changes will take effect on" + + " application restart.", + settings.size()))) + .build(); } catch (IOException e) { log.error("Failed to save settings to file: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", GENERIC_FILE_ERROR)); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", GENERIC_FILE_ERROR)) + .build(); } catch (IllegalArgumentException e) { log.error("Invalid setting key or value: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", GENERIC_INVALID_SETTING)); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", GENERIC_INVALID_SETTING)) + .build(); } catch (Exception e) { log.error("Unexpected error while updating settings: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", GENERIC_SERVER_ERROR)); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", GENERIC_SERVER_ERROR)) + .build(); } } - @GetMapping("/section/{sectionName}") + @GET + @jakarta.ws.rs.Path("/section/{sectionName}") @Operation( summary = "Get specific settings section", description = @@ -246,18 +253,19 @@ public class AdminSettingsController { responseCode = "403", description = "Access denied - Admin role required") }) - public ResponseEntity getSettingsSection( - @PathVariable String sectionName, - @RequestParam(defaultValue = "true") boolean includePending) { + public Response getSettingsSection( + @PathParam("sectionName") String sectionName, + @QueryParam("includePending") @DefaultValue("true") boolean includePending) { try { Object sectionData = getSectionData(sectionName); if (sectionData == null) { - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( "Invalid section name: " - + HtmlUtils.htmlEscape(sectionName) + + htmlEscape(sectionName) + ". Valid sections: " - + String.join(", ", VALID_SECTION_NAMES)); + + String.join(", ", VALID_SECTION_NAMES)) + .build(); } // Convert to Map for manipulation @@ -279,19 +287,22 @@ public class AdminSettingsController { "Admin requested settings section: {} (includePending={})", sectionName, includePending); - return ResponseEntity.ok(sectionMap); + return Response.ok(sectionMap).build(); } catch (IllegalArgumentException e) { log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body("Invalid section name: " + HtmlUtils.htmlEscape(sectionName)); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Invalid section name: " + htmlEscape(sectionName)) + .build(); } catch (Exception e) { log.error("Error retrieving section {}: {}", sectionName, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Failed to retrieve section."); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Failed to retrieve section.") + .build(); } } - @PutMapping("/section/{sectionName}") + @PUT + @jakarta.ws.rs.Path("/section/{sectionName}") @Operation( summary = "Update specific settings section", description = "Update all settings within a specific section. Admin access required.") @@ -306,23 +317,26 @@ public class AdminSettingsController { description = "Access denied - Admin role required"), @ApiResponse(responseCode = "500", description = "Failed to save settings") }) - public ResponseEntity> updateSettingsSection( - @PathVariable String sectionName, @Valid @RequestBody Map sectionData) { + public Response updateSettingsSection( + @PathParam("sectionName") String sectionName, + @Valid Map sectionData) { try { if (sectionData == null || sectionData.isEmpty()) { - return ResponseEntity.badRequest() - .body(Map.of("error", "No section data provided to update")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "No section data provided to update")) + .build(); } if (!isValidSectionName(sectionName)) { - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "Invalid section name: " - + HtmlUtils.htmlEscape(sectionName) + + htmlEscape(sectionName) + ". Valid sections: " - + String.join(", ", VALID_SECTION_NAMES))); + + String.join(", ", VALID_SECTION_NAMES))) + .build(); } // Auto-enable premium features if license key is provided @@ -342,12 +356,12 @@ public class AdminSettingsController { Object value = entry.getValue(); if (!isValidSettingKey(fullKey)) { - return ResponseEntity.badRequest() - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", - "Invalid setting key format: " - + HtmlUtils.htmlEscape(fullKey))); + "Invalid setting key format: " + htmlEscape(fullKey))) + .build(); } log.info("Admin updating section setting: {} = {}", fullKey, value); @@ -359,31 +373,36 @@ public class AdminSettingsController { updatedCount++; } - String escapedSectionName = HtmlUtils.htmlEscape(sectionName); - return ResponseEntity.ok( - Map.of( - "message", - String.format( - "Successfully updated %d setting(s) in section '%s'. Changes will take" - + " effect on application restart.", - updatedCount, escapedSectionName))); + String escapedSectionName = htmlEscape(sectionName); + return Response.ok( + Map.of( + "message", + String.format( + "Successfully updated %d setting(s) in section '%s'. Changes will take" + + " effect on application restart.", + updatedCount, escapedSectionName))) + .build(); } catch (IOException e) { log.error("Failed to save section settings to file: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", GENERIC_FILE_ERROR)); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", GENERIC_FILE_ERROR)) + .build(); } catch (IllegalArgumentException e) { log.error("Invalid section data: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", GENERIC_INVALID_SECTION)); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", GENERIC_INVALID_SECTION)) + .build(); } catch (Exception e) { log.error("Unexpected error while updating section settings: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", GENERIC_SERVER_ERROR)); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", GENERIC_SERVER_ERROR)) + .build(); } } - @GetMapping("/key/{key}") + @GET + @jakarta.ws.rs.Path("/key/{key}") @Operation( summary = "Get specific setting value", description = @@ -399,17 +418,19 @@ public class AdminSettingsController { responseCode = "403", description = "Access denied - Admin role required") }) - public ResponseEntity getSettingValue(@PathVariable String key) { + public Response getSettingValue(@PathParam("key") String key) { try { if (!isValidSettingKey(key)) { - return ResponseEntity.badRequest() - .body("Invalid setting key format: " + HtmlUtils.htmlEscape(key)); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Invalid setting key format: " + htmlEscape(key)) + .build(); } Object value = getSettingByKey(key); if (value == null) { - return ResponseEntity.badRequest() - .body("Setting key not found: " + HtmlUtils.htmlEscape(key)); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Setting key not found: " + htmlEscape(key)) + .build(); } // Mask sensitive values before returning @@ -419,19 +440,22 @@ public class AdminSettingsController { } log.debug("Admin requested setting: {}", key); - return ResponseEntity.ok(new SettingValueResponse(key, value)); + return Response.ok(new SettingValueResponse(key, value)).build(); } catch (IllegalArgumentException e) { log.error("Invalid setting key {}: {}", key, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body("Invalid setting key: " + HtmlUtils.htmlEscape(key)); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Invalid setting key: " + htmlEscape(key)) + .build(); } catch (Exception e) { log.error("Error retrieving setting {}: {}", key, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Failed to retrieve setting."); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Failed to retrieve setting.") + .build(); } } - @PutMapping("/key/{key}") + @PUT + @jakarta.ws.rs.Path("/key/{key}") @Operation( summary = "Update specific setting value", description = @@ -446,12 +470,13 @@ public class AdminSettingsController { description = "Access denied - Admin role required"), @ApiResponse(responseCode = "500", description = "Failed to save setting") }) - public ResponseEntity updateSettingValue( - @PathVariable String key, @Valid @RequestBody UpdateSettingValueRequest request) { + public Response updateSettingValue( + @PathParam("key") String key, @Valid UpdateSettingValueRequest request) { try { if (!isValidSettingKey(key)) { - return ResponseEntity.badRequest() - .body("Invalid setting key format: " + HtmlUtils.htmlEscape(key)); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Invalid setting key format: " + htmlEscape(key)) + .build(); } Object value = request.getValue(); @@ -463,9 +488,10 @@ public class AdminSettingsController { log.warn( "Admin attempted to save masked value for sensitive field: {}. This operation is blocked to prevent data loss.", key); - return ResponseEntity.badRequest() - .body( - "Cannot save masked values for sensitive settings. Please provide the actual value."); + return Response.status(Response.Status.BAD_REQUEST) + .entity( + "Cannot save masked values for sensitive settings. Please provide the actual value.") + .build(); } } @@ -475,31 +501,38 @@ public class AdminSettingsController { // Track this as a pending change pendingChanges.put(key, value); - String escapedKey = HtmlUtils.htmlEscape(key); - return ResponseEntity.ok( - String.format( - "Successfully updated setting '%s'. Changes will take effect on" - + " application restart.", - escapedKey)); + String escapedKey = htmlEscape(key); + return Response.ok( + String.format( + "Successfully updated setting '%s'. Changes will take effect on" + + " application restart.", + escapedKey)) + .build(); } catch (IOException e) { log.error("Failed to save setting to file: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(GENERIC_FILE_ERROR) + .build(); } catch (IllegalArgumentException e) { log.error("Invalid setting key or value: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING); + return Response.status(Response.Status.BAD_REQUEST) + .entity(GENERIC_INVALID_SETTING) + .build(); } catch (Exception e) { log.error("Unexpected error while updating setting: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(GENERIC_SERVER_ERROR); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(GENERIC_SERVER_ERROR) + .build(); } } - @PostMapping("/restart") + @POST + @jakarta.ws.rs.Path("/restart") @Operation( summary = "Restart the application", description = - "Triggers a graceful restart of the Spring Boot application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.") + "Triggers a graceful restart of the application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.") @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "Restart initiated successfully"), @@ -508,7 +541,7 @@ public class AdminSettingsController { description = "Access denied - Admin role required"), @ApiResponse(responseCode = "500", description = "Failed to initiate restart") }) - public ResponseEntity> restartApplication() { + public Response restartApplication() { try { log.warn("Admin initiated application restart"); @@ -518,20 +551,22 @@ public class AdminSettingsController { if (appJar == null) { log.error("Cannot restart: not running from JAR (likely development mode)"); - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body( + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity( Map.of( "error", - "Restart not available in development mode. Please restart the application manually.")); + "Restart not available in development mode. Please restart the application manually.")) + .build(); } if (helperJar == null || !Files.isRegularFile(helperJar)) { log.error("Cannot restart: restart-helper.jar not found at expected location"); - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body( + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity( Map.of( "error", - "Restart helper not found. Cannot perform application restart.")); + "Restart helper not found. Cannot perform application restart.")) + .build(); } // Get current application arguments @@ -577,26 +612,29 @@ public class AdminSettingsController { try { Thread.sleep(1000); log.info("Shutting down for restart..."); - SpringApplication.exit(applicationContext, () -> 0); - System.exit(0); + // Trigger a graceful Quarkus shutdown (fires ShutdownEvent / + // @PreDestroy); equivalent to SpringApplication.exit(context). + Quarkus.asyncExit(0); } catch (InterruptedException e) { log.error("Restart interrupted: {}", e.getMessage(), e); Thread.currentThread().interrupt(); } }); - return ResponseEntity.ok( - Map.of( - "message", - "Application restart initiated. The server will be back online shortly.")); + return Response.ok( + Map.of( + "message", + "Application restart initiated. The server will be back online shortly.")) + .build(); } catch (Exception e) { log.error("Failed to initiate restart: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body( + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity( Map.of( "error", - "Failed to initiate application restart: " + e.getMessage())); + "Failed to initiate application restart: " + e.getMessage())) + .build(); } } @@ -953,4 +991,26 @@ public class AdminSettingsController { // Set the final value current.put(parts[parts.length - 1], value); } + + // Replacement for Spring's org.springframework.web.util.HtmlUtils.htmlEscape (no + // Quarkus/Jakarta equivalent and commons-text is not a dependency). Mirrors the subset of + // behavior required to escape user-supplied keys/section names echoed into error messages. + private static String htmlEscape(String input) { + if (input == null) { + return ""; + } + StringBuilder sb = new StringBuilder(input.length()); + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + switch (c) { + case '&' -> sb.append("&"); + case '<' -> sb.append("<"); + case '>' -> sb.append(">"); + case '"' -> sb.append("""); + case '\'' -> sb.append("'"); + default -> sb.append(c); + } + } + return sb.toString(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 490dea2556..e4f4740fb8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -3,27 +3,31 @@ package stirling.software.proprietary.security.controller.api; import java.util.HashMap; import java.util.Map; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; + +// TODO: Migration required - CustomUserDetailsService (a collaborator not yet migrated) still +// returns org.springframework.security.core.userdetails.UserDetails and throws +// UsernameNotFoundException; UserService.isPasswordCorrect path may surface a Spring +// AuthenticationException. These Spring-security types are kept until those collaborators migrate +// (e.g. to a Quarkus IdentityProvider / plain user-loading service). Remove these imports then. import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; + +import io.quarkus.security.identity.SecurityIdentity; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.constants.JwtConstants; @@ -47,23 +51,24 @@ import stirling.software.proprietary.security.util.DesktopClientUtils; import stirling.software.proprietary.service.AiUserDataService; /** REST API Controller for authentication operations. */ -@RestController -@RequestMapping("/api/v1/auth") -@RequiredArgsConstructor +@ApplicationScoped +@Path("/api/v1/auth") @Slf4j @Tag(name = "Authentication", description = "Endpoints for user authentication and registration") public class AuthController { - private final UserService userService; - private final JwtServiceInterface jwtService; - private final CustomUserDetailsService userDetailsService; - private final LoginAttemptService loginAttemptService; - private final MfaService mfaService; - private final TotpService totpService; - private final RefreshRateLimitService refreshRateLimitService; - private final ApplicationProperties.Security securityProperties; - private final ApplicationProperties applicationProperties; - private final AiUserDataService aiUserDataService; + @Inject UserService userService; + @Inject JwtServiceInterface jwtService; + @Inject CustomUserDetailsService userDetailsService; + @Inject LoginAttemptService loginAttemptService; + @Inject MfaService mfaService; + @Inject TotpService totpService; + @Inject RefreshRateLimitService refreshRateLimitService; + @Inject ApplicationProperties.Security securityProperties; + @Inject ApplicationProperties applicationProperties; + @Inject AiUserDataService aiUserDataService; + + @Inject SecurityIdentity securityIdentity; /** * Login endpoint - replaces Supabase signInWithPassword @@ -72,38 +77,45 @@ public class AuthController { * @param response HTTP response to set JWT cookie * @return User and session information */ - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/login") + // TODO: Migration required - Spring @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") was a + // negated SpEL authority check with no direct JAX-RS @RolesAllowed equivalent. Enforce the + // "not a demo user" rule via a SecurityIdentity check in-method, a SecurityIdentityAugmentor, + // or a quarkus.http.auth.* policy. + @POST + @Path("/login") @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) - public ResponseEntity login( - @RequestBody UsernameAndPassMfa request, - HttpServletRequest httpRequest, - HttpServletResponse response) { + public Response login( + UsernameAndPassMfa request, + @Context HttpServletRequest httpRequest, + @Context HttpServletResponse response) { try { // Check if username/password authentication is allowed if (!securityProperties.isUserPass()) { log.warn( "Username/password login attempted but not allowed by current login method configuration"); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body( + return Response.status(Response.Status.FORBIDDEN) + .entity( Map.of( "error", - "Username/password authentication is not enabled. Please use the configured authentication method.")); + "Username/password authentication is not enabled. Please use the configured authentication method.")) + .build(); } // Validate input parameters if (request.getUsername() == null || request.getUsername().trim().isEmpty()) { log.warn("Login attempt with null or empty username"); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Username is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Username is required")) + .build(); } if (request.getPassword() == null || request.getPassword().isEmpty()) { log.warn( "Login attempt with null or empty password for user: {}", request.getUsername()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Password is required")) + .build(); } String username = request.getUsername().trim(); @@ -112,8 +124,9 @@ public class AuthController { // Check if account is blocked due to too many failed attempts if (loginAttemptService.isBlocked(username)) { log.warn("Blocked account login attempt for user: {} from IP: {}", username, ip); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Account is locked due to too many failed attempts")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Account is locked due to too many failed attempts")) + .build(); } log.debug("Login attempt for user: {} from IP: {}", username, ip); @@ -124,14 +137,16 @@ public class AuthController { if (!userService.isPasswordCorrect(user, request.getPassword())) { log.warn("Invalid password for user: {} from IP: {}", username, ip); loginAttemptService.loginFailed(username); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid username or password")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Invalid username or password")) + .build(); } if (!user.isEnabled()) { log.warn("Disabled user attempted login: {} from IP: {}", username, ip); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "User account is disabled")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "User account is disabled")) + .build(); } if (mfaService.isMfaEnabled(user)) { @@ -142,36 +157,40 @@ public class AuthController { username, ip); // loginAttemptService.loginFailed(username); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body( + return Response.status(Response.Status.UNAUTHORIZED) + .entity( Map.of( "error", "mfa_required", - "message", "Two-factor code required")); + "message", "Two-factor code required")) + .build(); } String secret = mfaService.getSecret(user); if (secret == null || secret.isBlank()) { log.error("MFA enabled but no secret stored for user: {}", username); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "MFA configuration error")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "MFA configuration error")) + .build(); } Long timeStep = totpService.getValidTimeStep(secret, code); if (timeStep == null) { log.warn("Invalid MFA code for user: {} from IP: {}", username, ip); loginAttemptService.loginFailed(username); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body( + return Response.status(Response.Status.UNAUTHORIZED) + .entity( Map.of( "error", "invalid_mfa_code", - "message", "Invalid two-factor code")); + "message", "Invalid two-factor code")) + .build(); } if (!mfaService.markTotpStepUsed(user, timeStep)) { log.warn("Replay MFA code detected for user: {} from IP: {}", username, ip); loginAttemptService.loginFailed(username); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body( + return Response.status(Response.Status.UNAUTHORIZED) + .entity( Map.of( "error", "invalid_mfa_code", - "message", "Invalid two-factor code")); + "message", "Invalid two-factor code")) + .build(); } } @@ -216,32 +235,36 @@ public class AuthController { ip, isDesktopClient); - return ResponseEntity.ok( - Map.of( - "user", buildUserResponse(user), - "session", - Map.of( - "access_token", - token, - "expires_in", - getTokenExpirySeconds(isDesktopClient)))); + return Response.ok( + Map.of( + "user", buildUserResponse(user), + "session", + Map.of( + "access_token", + token, + "expires_in", + getTokenExpirySeconds(isDesktopClient)))) + .build(); } catch (UsernameNotFoundException e) { String username = request.getUsername(); log.warn("User not found: {}", username); loginAttemptService.loginFailed(username); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid username or password")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Invalid username or password")) + .build(); } catch (AuthenticationException e) { String username = request.getUsername(); log.error("Authentication failed for user: {}", username, e); loginAttemptService.loginFailed(username); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid credentials")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Invalid credentials")) + .build(); } catch (Exception e) { log.error("Login error for user: {}", request.getUsername(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Internal server error")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Internal server error")) + .build(); } } @@ -250,28 +273,35 @@ public class AuthController { * * @return Current authenticated user information */ - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @GetMapping("/me") - public ResponseEntity getCurrentUser() { + // TODO: Migration required - Spring @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") negated + // authority check has no direct @RolesAllowed equivalent; enforce via SecurityIdentity/policy. + @GET + @Path("/me") + public Response getCurrentUser() { try { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - - if (auth == null - || !auth.isAuthenticated() - || "anonymousUser".equals(auth.getPrincipal())) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Not authenticated")); + // TODO: Migration required - was SecurityContextHolder.getContext().getAuthentication(). + // Quarkus SecurityIdentity has no Spring UserDetails principal; loading the full User + // here requires a SecurityIdentityAugmentor that attaches the User (or re-loading via + // userDetailsService by name). Until then we re-load the user from the identity name. + if (securityIdentity == null + || securityIdentity.isAnonymous() + || securityIdentity.getPrincipal() == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Not authenticated")) + .build(); } - UserDetails userDetails = (UserDetails) auth.getPrincipal(); + String username = securityIdentity.getPrincipal().getName(); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); User user = (User) userDetails; - return ResponseEntity.ok(Map.of("user", buildUserResponse(user))); + return Response.ok(Map.of("user", buildUserResponse(user))).build(); } catch (Exception e) { log.error("Get current user error", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Internal server error")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Internal server error")) + .build(); } } @@ -281,22 +311,28 @@ public class AuthController { * @param response HTTP response * @return Success message */ - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + // TODO: Migration required - Spring @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") negated + // authority check has no direct @RolesAllowed equivalent; enforce via SecurityIdentity/policy. + @POST + @Path("/logout") + public Response logout( + @Context HttpServletRequest request, @Context HttpServletResponse response) { try { String username = jwtService.extractUsernameFromRequestAllowExpired(request); - SecurityContextHolder.clearContext(); + // TODO: Migration required - SecurityContextHolder.clearContext() has no Quarkus + // equivalent; SecurityIdentity is request-scoped and not cleared imperatively. Cookie/ + // token invalidation is handled by the JWT cookie being dropped by the client/filter. aiUserDataService.purgeUserDocuments(username); log.debug("User logged out successfully (username={})", username); - return ResponseEntity.ok(Map.of("message", "Logged out successfully")); + return Response.ok(Map.of("message", "Logged out successfully")).build(); } catch (Exception e) { log.error("Logout error", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Internal server error")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Internal server error")) + .build(); } } @@ -307,15 +343,19 @@ public class AuthController { * @param response HTTP response to set new JWT cookie * @return New token information */ - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/refresh") - public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { + // TODO: Migration required - Spring @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") negated + // authority check has no direct @RolesAllowed equivalent; enforce via SecurityIdentity/policy. + @POST + @Path("/refresh") + public Response refresh( + @Context HttpServletRequest request, @Context HttpServletResponse response) { try { String token = jwtService.extractToken(request); if (token == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "No token found")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "No token found")) + .build(); } // Generate token hash for rate limiting (avoid storing actual tokens) @@ -324,8 +364,9 @@ public class AuthController { Map claims = jwtService.extractClaimsAllowExpired(token); if (!isRefreshWithinGrace(claims)) { log.warn("Token refresh rejected: token expired beyond configured grace window"); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Token refresh failed")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Token refresh failed")) + .build(); } // Only apply rate limiting if token is actually expired (not for valid tokens) @@ -338,21 +379,24 @@ public class AuthController { log.warn( "Token refresh rejected: rate limit exceeded (max {} attempts allowed)", JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE); - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) - .body( + // HTTP 429 TOO_MANY_REQUESTS is not in JAX-RS Response.Status enum; use numeric code + return Response.status(429) + .entity( Map.of( "error", "Too many refresh attempts", "max_attempts", - JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE)); + JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE)) + .build(); } Object usernameClaim = claims.get("sub"); String username = usernameClaim != null ? usernameClaim.toString() : null; if (username == null || username.isBlank()) { log.warn("Token refresh rejected: missing subject claim"); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Token refresh failed")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Token refresh failed")) + .build(); } UserDetails userDetails = userDetailsService.loadUserByUsername(username); @@ -390,48 +434,57 @@ public class AuthController { log.debug("Token refreshed for user: {}", username); - return ResponseEntity.ok( - Map.of( - "user", buildUserResponse(user), - "session", - Map.of( - "access_token", - newToken, - "expires_in", - getTokenExpirySeconds(isDesktopClient)))); + return Response.ok( + Map.of( + "user", buildUserResponse(user), + "session", + Map.of( + "access_token", + newToken, + "expires_in", + getTokenExpirySeconds(isDesktopClient)))) + .build(); } catch (AuthenticationFailureException e) { log.warn("Token refresh failed: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Token refresh failed")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Token refresh failed")) + .build(); } catch (Exception e) { log.error("Token refresh error", e); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Token refresh failed")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Token refresh failed")) + .build(); } } - @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") - @GetMapping("/mfa/setup") - public ResponseEntity setupMfa(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Not authenticated")); + // TODO: Migration required - Spring @PreAuthorize("isAuthenticated() && + // !hasAuthority('ROLE_DEMO_USER')") combined an authenticated check with a negated authority. + // The authenticated portion is enforced below via securityIdentity; the "not demo user" + // portion needs a SecurityIdentity check/augmentor or quarkus.http.auth.* policy. + @GET + @Path("/mfa/setup") + public Response setupMfa() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Not authenticated")) + .build(); } - String username = authentication.getName(); + String username = securityIdentity.getPrincipal().getName(); User user = userService .findByUsernameIgnoreCaseWithSettings(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - ResponseEntity authTypeResponse = ensureWebAuth(user); + Response authTypeResponse = ensureWebAuth(user); if (authTypeResponse != null) { return authTypeResponse; } if (mfaService.isMfaEnabled(user)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "MFA already enabled")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "MFA already enabled")) + .build(); } try { @@ -439,147 +492,170 @@ public class AuthController { mfaService.setSecret(user, secret); String otpAuthUri = totpService.buildOtpAuthUri(username, secret); - return ResponseEntity.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri)); + return Response.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri)).build(); } catch (Exception e) { log.error("Failed to setup MFA for user: {}", username, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to setup MFA")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to setup MFA")) + .build(); } } - @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/mfa/enable") - public ResponseEntity enableMfa( - @RequestBody MfaCodeRequest request, Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Not authenticated")); + // TODO: Migration required - Spring @PreAuthorize("isAuthenticated() && + // !hasAuthority('ROLE_DEMO_USER')") - authenticated check enforced via securityIdentity below; + // the "not demo user" portion needs a SecurityIdentity check/augmentor or quarkus.http.auth.*. + @POST + @Path("/mfa/enable") + public Response enableMfa(MfaCodeRequest request) { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Not authenticated")) + .build(); } - String username = authentication.getName(); + String username = securityIdentity.getPrincipal().getName(); User user = userService .findByUsernameIgnoreCaseWithSettings(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - ResponseEntity authTypeResponse = ensureWebAuth(user); + Response authTypeResponse = ensureWebAuth(user); if (authTypeResponse != null) { return authTypeResponse; } String secret = mfaService.getSecret(user); if (secret == null || secret.isBlank()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "MFA setup required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "MFA setup required")) + .build(); } if (request == null || request.getCode() == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "MFA code is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "MFA code is required")) + .build(); } Long timeStep = totpService.getValidTimeStep(secret, request.getCode()); if (timeStep == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid two-factor code")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Invalid two-factor code")) + .build(); } try { if (!mfaService.isTotpStepUsable(user, timeStep)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid two-factor code")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Invalid two-factor code")) + .build(); } mfaService.enableMfa(user); mfaService.markTotpStepUsed(user, timeStep); mfaService.setMfaRequired(user, false); - return ResponseEntity.ok(Map.of("enabled", true)); + return Response.ok(Map.of("enabled", true)).build(); } catch (Exception e) { log.error("Failed to enable MFA for user: {}", username, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to enable MFA")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to enable MFA")) + .build(); } } - @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/mfa/disable") - public ResponseEntity disableMfa( - @RequestBody MfaCodeRequest request, Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Not authenticated")); + // TODO: Migration required - Spring @PreAuthorize("isAuthenticated() && + // !hasAuthority('ROLE_DEMO_USER')") - authenticated check enforced via securityIdentity below; + // the "not demo user" portion needs a SecurityIdentity check/augmentor or quarkus.http.auth.*. + @POST + @Path("/mfa/disable") + public Response disableMfa(MfaCodeRequest request) { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Not authenticated")) + .build(); } - String username = authentication.getName(); + String username = securityIdentity.getPrincipal().getName(); User user = userService .findByUsernameIgnoreCaseWithSettings(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - ResponseEntity authTypeResponse = ensureWebAuth(user); + Response authTypeResponse = ensureWebAuth(user); if (authTypeResponse != null) { return authTypeResponse; } if (!mfaService.isMfaEnabled(user)) { - return ResponseEntity.ok(Map.of("enabled", false)); + return Response.ok(Map.of("enabled", false)).build(); } String secret = mfaService.getSecret(user); if (secret == null || secret.isBlank()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "MFA configuration missing")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "MFA configuration missing")) + .build(); } if (request == null || request.getCode() == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "MFA code is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "MFA code is required")) + .build(); } Long timeStep = totpService.getValidTimeStep(secret, request.getCode()); if (timeStep == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid two-factor code")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Invalid two-factor code")) + .build(); } try { if (!mfaService.isTotpStepUsable(user, timeStep)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid two-factor code")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Invalid two-factor code")) + .build(); } mfaService.disableMfa(user); mfaService.markTotpStepUsed(user, timeStep); - return ResponseEntity.ok(Map.of("enabled", false)); + return Response.ok(Map.of("enabled", false)).build(); } catch (Exception e) { log.error("Failed to disable MFA for user: {}", username, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to disable MFA")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to disable MFA")) + .build(); } } - @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/mfa/setup/cancel") - public ResponseEntity cancelMfaSetup(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Not authenticated")); + // TODO: Migration required - Spring @PreAuthorize("isAuthenticated() && + // !hasAuthority('ROLE_DEMO_USER')") - authenticated check enforced via securityIdentity below; + // the "not demo user" portion needs a SecurityIdentity check/augmentor or quarkus.http.auth.*. + @POST + @Path("/mfa/setup/cancel") + public Response cancelMfaSetup() { + if (securityIdentity == null || securityIdentity.isAnonymous()) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity(Map.of("error", "Not authenticated")) + .build(); } - String username = authentication.getName(); + String username = securityIdentity.getPrincipal().getName(); User user = userService .findByUsernameIgnoreCaseWithSettings(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); if (mfaService.isMfaEnabled(user)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "MFA already enabled")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "MFA already enabled")) + .build(); } try { mfaService.clearPendingSecret(user); - return ResponseEntity.ok(Map.of("cleared", true)); + return Response.ok(Map.of("cleared", true)).build(); } catch (Exception e) { log.error("Failed to clear MFA setup for user: {}", username, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to clear MFA setup")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to clear MFA setup")) + .build(); } } @@ -589,9 +665,10 @@ public class AuthController { * @param username Username of the user to disable MFA for * @return Response indicating success or failure */ - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/mfa/disable/admin/{username}") - public ResponseEntity disableMfaByAdmin(@PathVariable String username) { + @RolesAllowed("ADMIN") + @POST + @Path("/mfa/disable/admin/{username}") + public Response disableMfaByAdmin(@PathParam("username") String username) { try { User user = userService @@ -599,19 +676,21 @@ public class AuthController { .orElseThrow(() -> new UsernameNotFoundException("User not found")); if (!mfaService.isMfaEnabled(user)) { - return ResponseEntity.ok(Map.of("enabled", false)); + return Response.ok(Map.of("enabled", false)).build(); } mfaService.disableMfa(user); - return ResponseEntity.ok(Map.of("enabled", false)); + return Response.ok(Map.of("enabled", false)).build(); } catch (UsernameNotFoundException e) { log.warn("User not found for MFA disable: {}", username); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "User not found")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "User not found")) + .build(); } catch (Exception e) { log.error("Failed to disable MFA for user: {}", username, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to disable MFA")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to disable MFA")) + .build(); } } @@ -734,10 +813,11 @@ public class AuthController { } } - private ResponseEntity ensureWebAuth(User user) { + private Response ensureWebAuth(User user) { if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "MFA settings are only available for web accounts")); + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "MFA settings are only available for web accounts")) + .build(); } return null; } 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 f560b65fe5..141930bdbe 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 @@ -3,34 +3,44 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.StandardCopyOption; -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; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.DatabaseApi; -import stirling.software.proprietary.security.database.H2SQLCondition; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.proprietary.security.service.DatabaseService; @Slf4j +@ApplicationScoped @DatabaseApi -@PreAuthorize("hasRole('ADMIN')") -@Conditional(H2SQLCondition.class) +// DatabaseApi carries only @Tag; JAX-RS does not inherit @Path from meta-annotations, so the base +// path must be declared explicitly here. +@jakarta.ws.rs.Path("/api/v1/database") +@RolesAllowed("ADMIN") +// TODO: Migration required - @Conditional(H2SQLCondition.class) gated this controller on the +// datasource being H2 (driver/url inspection of the Spring Environment). Quarkus has no +// @Conditional equivalent; this must be re-expressed either as a build-time @IfBuildProfile, a +// runtime @LookupIfProperty on a datasource property, or a runtime guard inside DatabaseService +// that no-ops/returns 404 when the active datasource is not H2. @RequiredArgsConstructor public class DatabaseController { @@ -39,51 +49,58 @@ public class DatabaseController { @Operation( summary = "Import a database backup file", description = "Uploads and imports a database backup SQL file.") - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "import-database") - public ResponseEntity importDatabase( - @Parameter(description = "SQL file to import", required = true) - @RequestParam("fileInput") - MultipartFile file) + @POST + @jakarta.ws.rs.Path("import-database") + @jakarta.ws.rs.Consumes(MediaType.MULTIPART_FORM_DATA) + public Response importDatabase( + @Parameter(description = "SQL file to import", required = true) @RestForm("fileInput") + FileUpload fileInput) throws IOException { + stirling.software.common.model.MultipartFile file = + fileInput == null ? null : FileUploadMultipartFile.of(fileInput); if (file == null || file.isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( java.util.Map.of( "error", "fileNullOrEmpty", "message", - "File is null or empty")); + "File is null or empty")) + .build(); } log.info("Received file: {}", file.getOriginalFilename()); - Path tempTemplatePath = Files.createTempFile("backup_", ".sql"); + java.nio.file.Path tempTemplatePath = Files.createTempFile("backup_", ".sql"); try (InputStream in = file.getInputStream()) { Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING); boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath); if (importSuccess) { - return ResponseEntity.ok( - java.util.Map.of( - "message", - "importIntoDatabaseSuccessed", - "description", - "Database imported successfully")); + return Response.ok( + java.util.Map.of( + "message", + "importIntoDatabaseSuccessed", + "description", + "Database imported successfully")) + .build(); } else { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body( + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity( java.util.Map.of( "error", "failedImportFile", "message", - "Failed to import database file")); + "Failed to import database file")) + .build(); } } catch (Exception e) { log.error("Error importing database: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body( + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity( java.util.Map.of( "error", "failedImportFile", "message", - "Failed to import database: " + e.getMessage())); + "Failed to import database: " + e.getMessage())) + .build(); } } @@ -91,18 +108,20 @@ public class DatabaseController { @Operation( summary = "Import database backup by filename", description = "Imports a database backup file from the server using its file name.") - @GetMapping("/import-database-file/{fileName}") - public ResponseEntity importDatabaseFromBackupUI( - @Parameter(description = "Name of the file to import", required = true) @PathVariable + @GET + @jakarta.ws.rs.Path("/import-database-file/{fileName}") + public Response importDatabaseFromBackupUI( + @Parameter(description = "Name of the file to import", required = true) @PathParam("fileName") String fileName) { if (fileName == null || fileName.isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( java.util.Map.of( "error", "fileNullOrEmpty", "message", - "File name is null or empty")); + "File name is null or empty")) + .build(); } // Check if the file exists in the backup list boolean fileExists = @@ -110,73 +129,80 @@ public class DatabaseController { .anyMatch(backup -> backup.getFileName().equals(fileName)); if (!fileExists) { log.error("File {} not found in backup list", fileName); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body( + return Response.status(Response.Status.NOT_FOUND) + .entity( java.util.Map.of( "error", "fileNotFound", "message", - "File not found in backup list")); + "File not found in backup list")) + .build(); } log.info("Received file: {}", fileName); if (databaseService.importDatabaseFromUI(fileName)) { log.info("File {} imported to database", fileName); - return ResponseEntity.ok( - java.util.Map.of( - "message", - "importIntoDatabaseSuccessed", - "description", - "Database backup imported successfully")); + return Response.ok( + java.util.Map.of( + "message", + "importIntoDatabaseSuccessed", + "description", + "Database backup imported successfully")) + .build(); } - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body( + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity( java.util.Map.of( "error", "failedImportFile", "message", - "Failed to import database file")); + "Failed to import database file")) + .build(); } @Hidden @Operation( summary = "Delete a database backup file", description = "Deletes a specified database backup file from the server.") - @GetMapping("/delete/{fileName}") - public ResponseEntity deleteFile( - @Parameter(description = "Name of the file to delete", required = true) @PathVariable + @GET + @jakarta.ws.rs.Path("/delete/{fileName}") + public Response deleteFile( + @Parameter(description = "Name of the file to delete", required = true) @PathParam("fileName") String fileName) { if (fileName == null || fileName.isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( java.util.Map.of( "error", "invalidFileName", "message", - "File must not be null or empty")); + "File must not be null or empty")) + .build(); } try { if (databaseService.deleteBackupFile(fileName)) { log.info("Deleted file: {}", fileName); - return ResponseEntity.ok(java.util.Map.of("message", "File deleted successfully")); + return Response.ok(java.util.Map.of("message", "File deleted successfully")).build(); } else { log.error("Failed to delete file: {}", fileName); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body( + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity( java.util.Map.of( "error", "failedToDeleteFile", "message", - "Failed to delete backup file")); + "Failed to delete backup file")) + .build(); } } catch (IOException e) { log.error("Error deleting file: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body( + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity( java.util.Map.of( "error", "deleteError", "message", - "Error deleting file: " + e.getMessage())); + "Error deleting file: " + e.getMessage())) + .build(); } } @@ -184,9 +210,10 @@ public class DatabaseController { @Operation( summary = "Download a database backup file", description = "Downloads the specified database backup file from the server.") - @GetMapping("/download/{fileName}") - public ResponseEntity downloadFile( - @Parameter(description = "Name of the file to download", required = true) @PathVariable + @GET + @jakarta.ws.rs.Path("/download/{fileName}") + public Response downloadFile( + @Parameter(description = "Name of the file to download", required = true) @PathParam("fileName") String fileName) { if (fileName == null || fileName.isEmpty()) { throw new IllegalArgumentException("File must not be null or empty"); @@ -196,48 +223,58 @@ public class DatabaseController { // Only allow files matching the backup naming pattern if (!fileName.startsWith("backup_") || !fileName.endsWith(".sql")) { log.warn("Attempted download of non-backup file: {}", fileName); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( java.util.Map.of( "error", "invalidFileName", "message", - "Only backup files are allowed")); + "Only backup files are allowed")) + .build(); } try { - Path filePath = databaseService.getBackupFilePath(fileName); - InputStreamResource resource = new InputStreamResource(Files.newInputStream(filePath)); - return ResponseEntity.ok() + java.nio.file.Path filePath = databaseService.getBackupFilePath(fileName); + long contentLength = Files.size(filePath); + StreamingOutput stream = + output -> { + try (InputStream in = Files.newInputStream(filePath)) { + in.transferTo(output); + } + }; + return Response.ok(stream) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + fileName) - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .contentLength(Files.size(filePath)) - .body(resource); + .type(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_LENGTH, contentLength) + .build(); } catch (IOException e) { log.error("Error downloading file: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body( + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity( java.util.Map.of( "error", "downloadFailed", "message", - "Failed to download file: " + e.getMessage())); + "Failed to download file: " + e.getMessage())) + .build(); } } @Operation( summary = "Create a database backup", description = "This endpoint triggers the creation of a database backup.") - @GetMapping("/createDatabaseBackup") - public ResponseEntity createDatabaseBackup() { + @GET + @jakarta.ws.rs.Path("/createDatabaseBackup") + public Response createDatabaseBackup() { log.info("Starting database backup creation..."); databaseService.exportDatabase(); log.info("Database backup successfully created."); - return ResponseEntity.ok( - java.util.Map.of( - "message", - "backupCreated", - "description", - "Database backup created successfully")); + return Response.ok( + java.util.Map.of( + "message", + "backupCreated", + "description", + "Database backup created successfully")) + .build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java index 213e08ea0c..65b6fa37e2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/EmailController.java @@ -1,16 +1,18 @@ package stirling.software.proprietary.security.controller.api; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.mail.MailSendException; -import org.springframework.web.bind.annotation.ModelAttribute; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.swagger.v3.oas.annotations.Operation; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.mail.MessagingException; -import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,6 +20,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.proprietary.security.model.api.Email; import stirling.software.proprietary.security.service.EmailService; @@ -25,23 +28,34 @@ import stirling.software.proprietary.security.service.EmailService; * Controller for handling email-related API requests. This controller exposes an endpoint for * sending emails with attachments. */ +// TODO: Migration required - Spring @ConditionalOnProperty(mail.enabled) gated bean creation. CDI +// has no direct runtime-toggle equivalent; this controller is always registered and instead guards +// at request time via the injected mail.enabled config below. If the endpoint must be fully absent +// when mail is disabled, wire this with @io.quarkus.arc.lookup.LookupIfProperty or a build-time +// @io.quarkus.arc.profile.IfBuildProfile once a build/runtime decision is made. @GeneralApi +@Path("/api/v1/general") +@ApplicationScoped @RequiredArgsConstructor @Slf4j -@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false) public class EmailController { + private final EmailService emailService; + @ConfigProperty(name = "mail.enabled", defaultValue = "false") + boolean mailEnabled; + /** * Endpoint to send an email with an attachment. This method consumes a multipart/form-data * request containing the email details and attachment. * - * @param email The Email object containing recipient address, subject, body, and file - * attachment. - * @return ResponseEntity with success or error message. + * @return Response with success or error message. */ + @POST + @Path("/send-email") + @Consumes(MediaType.MULTIPART_FORM_DATA) @AutoJobPostMapping( - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + consumes = MediaType.MULTIPART_FORM_DATA, value = "/send-email", resourceWeight = ResourceWeight.SMALL_WEIGHT) @Operation( @@ -49,23 +63,42 @@ public class EmailController { description = "This endpoint sends an email with an attachment. Input:PDF" + " Output:Success/Failure Type:MISO") - public ResponseEntity sendEmailWithAttachment(@Valid @ModelAttribute Email email) { + public Response sendEmailWithAttachment( + @RestForm("fileInput") FileUpload fileUpload, + @RestForm("to") String to, + @RestForm("subject") String subject, + @RestForm("body") String body) { + // Rebuild the request model from multipart form fields. Email/GeneralFile are not annotated + // for JAX-RS multipart @BeanParam binding, so we populate them explicitly. + Email email = new Email(); + if (fileUpload != null) { + email.setFileInput(FileUploadMultipartFile.of(fileUpload)); + } + email.setTo(to); + email.setSubject(subject); + email.setBody(body); + + if (!mailEnabled) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Email sending is disabled") + .build(); + } + log.info("Sending email to: {}", email.toString()); try { // Calls the service to send the email with attachment emailService.sendEmailWithAttachment(email); - return ResponseEntity.ok("Email sent successfully"); - } catch (MailSendException ex) { - // handles your "Invalid Addresses" case - String errorMsg = ex.getMessage(); - log.error("MailSendException: {}", errorMsg, ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorMsg); + return Response.ok("Email sent successfully").build(); } catch (MessagingException e) { - // Catches any messaging exception (e.g., invalid email address, SMTP server issues) + // Catches any messaging exception (e.g., invalid email address, SMTP server issues). + // TODO: Migration required - the Spring-specific org.springframework.mail.MailSendException + // ("Invalid Addresses" case) was previously handled separately. Once EmailService is + // migrated off Spring's JavaMailSender that branch can be reintroduced with the + // replacement exception type. String errorMsg = "Failed to send email: " + e.getMessage(); log.error(errorMsg, e); // Logging the detailed error // Returns an error response with status 500 (Internal Server Error) - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorMsg); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(errorMsg).build(); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java index 07ec1c9736..4e117f09c0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java @@ -5,14 +5,25 @@ import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.eclipse.microprofile.config.inject.ConfigProperty; -import jakarta.servlet.http.HttpServletRequest; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; + +import org.jboss.resteasy.reactive.RestForm; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.InviteApi; @@ -29,16 +40,17 @@ import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.service.UserLicenseSettingsService; @InviteApi +@jakarta.ws.rs.Path("/api/v1/invite") +@ApplicationScoped @Slf4j -@RequiredArgsConstructor public class InviteLinkController { - private final InviteTokenRepository inviteTokenRepository; - private final TeamRepository teamRepository; - private final UserService userService; - private final ApplicationProperties applicationProperties; - private final Optional emailService; - private final UserLicenseSettingsService userLicenseSettingsService; + @Inject InviteTokenRepository inviteTokenRepository; + @Inject TeamRepository teamRepository; + @Inject UserService userService; + @Inject ApplicationProperties applicationProperties; + @Inject Instance emailService; + @Inject UserLicenseSettingsService userLicenseSettingsService; /** * Generate a new invite link (admin only) @@ -48,54 +60,66 @@ public class InviteLinkController { * @param teamId The team to assign (optional, uses default team if not provided) * @param expiryHours Custom expiry hours (optional, uses default from config) * @param sendEmail Whether to send the invite link via email (default: false) - * @param principal The authenticated admin user - * @param request The HTTP request - * @return ResponseEntity with the invite link or error + * @param securityContext The authenticated admin user + * @param uriInfo The request URI info + * @return Response with the invite link or error */ - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/generate") - public ResponseEntity generateInviteLink( - @RequestParam(name = "email", required = false) String email, - @RequestParam(name = "role", defaultValue = "ROLE_USER") String role, - @RequestParam(name = "teamId", required = false) Long teamId, - @RequestParam(name = "expiryHours", required = false) Integer expiryHours, - @RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail, - @RequestParam(name = "frontendBaseUrl", required = false) String frontendBaseUrl, - Principal principal, - HttpServletRequest request) { + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/generate") + public Response generateInviteLink( + @RestForm("email") String email, + @RestForm("role") String role, + @RestForm("teamId") Long teamId, + @RestForm("expiryHours") Integer expiryHours, + @RestForm("sendEmail") Boolean sendEmail, + @RestForm("frontendBaseUrl") String frontendBaseUrl, + @Context SecurityContext securityContext, + @Context UriInfo uriInfo) { + + // @RequestParam defaults applied manually (JAX-RS @RestForm has no defaultValue) + if (role == null) { + role = "ROLE_USER"; + } + boolean sendEmailFlag = sendEmail != null && sendEmail; + Principal principal = securityContext.getUserPrincipal(); try { // Check if email invites are enabled if (!applicationProperties.getMail().isEnableInvites()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Email invites are not enabled")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Email invites are not enabled")) + .build(); } // If email is provided, validate and check for conflicts if (email != null && !email.trim().isEmpty()) { // Validate email format if (!email.contains("@")) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid email address")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid email address")) + .build(); } email = email.trim().toLowerCase(); // Check if user already exists if (userService.usernameExistsIgnoreCase(email)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "User already exists")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "User already exists")) + .build(); } // Check if there's already an active invite for this email Optional existingInvite = inviteTokenRepository.findByEmail(email); if (existingInvite.isPresent() && existingInvite.get().isValid()) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body( + return Response.status(Response.Status.CONFLICT) + .entity( Map.of( "error", "An active invite already exists for this email" - + " address")); + + " address")) + .build(); } } else { @@ -103,9 +127,10 @@ public class InviteLinkController { email = null; // Ensure it's null, not empty string // Cannot send email if no email address provided - if (sendEmail) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot send email without an email address")); + if (sendEmailFlag) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot send email without an email address")) + .build(); } } @@ -116,8 +141,8 @@ public class InviteLinkController { int maxUsers = userLicenseSettingsService.calculateMaxAllowedUsers(); if (currentUserCount + activeInvites >= maxUsers) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "License limit reached (" @@ -125,7 +150,8 @@ public class InviteLinkController { + "/" + maxUsers + " users). Contact your administrator to" - + " upgrade your license.")); + + " upgrade your license.")) + .build(); } } @@ -133,12 +159,14 @@ public class InviteLinkController { try { Role roleEnum = Role.fromString(role); if (roleEnum == Role.INTERNAL_API_USER) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot assign INTERNAL_API_USER role")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot assign INTERNAL_API_USER role")) + .build(); } } catch (IllegalArgumentException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid role specified")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid role specified")) + .build(); } // Determine team @@ -153,8 +181,9 @@ public class InviteLinkController { Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); if (selectedTeam != null && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot assign users to Internal team")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot assign users to Internal team")) + .build(); } } @@ -191,13 +220,14 @@ public class InviteLinkController { } else if (configuredBackendUrl != null && !configuredBackendUrl.trim().isEmpty()) { baseUrl = configuredBackendUrl.trim(); } else { + // Derive from the incoming request via JAX-RS UriInfo + java.net.URI requestUri = uriInfo.getRequestUri(); + int port = requestUri.getPort(); baseUrl = - request.getScheme() + requestUri.getScheme() + "://" - + request.getServerName() - + (request.getServerPort() != 80 && request.getServerPort() != 443 - ? ":" + request.getServerPort() - : ""); + + requestUri.getHost() + + (port != -1 && port != 80 && port != 443 ? ":" + port : ""); } if (baseUrl.endsWith("/")) { baseUrl = baseUrl.substring(0, baseUrl.length() - 1); @@ -209,8 +239,8 @@ public class InviteLinkController { // Optionally send email boolean emailSent = false; String emailError = null; - if (sendEmail) { - if (!emailService.isPresent()) { + if (sendEmailFlag) { + if (!emailService.isResolvable()) { emailError = "Email service is not configured"; log.warn("Cannot send invite email: Email service not configured"); } else { @@ -236,19 +266,20 @@ public class InviteLinkController { response.put("email", email); response.put("expiresAt", expiresAt.toString()); response.put("expiryHours", effectiveExpiryHours); - if (sendEmail) { + if (sendEmailFlag) { response.put("emailSent", emailSent); if (emailError != null) { response.put("emailError", emailError); } } - return ResponseEntity.ok(response); + return Response.ok(response, MediaType.APPLICATION_JSON).build(); } catch (Exception e) { log.error("Failed to generate invite link: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to generate invite link: " + e.getMessage())); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to generate invite link: " + e.getMessage())) + .build(); } } @@ -257,9 +288,10 @@ public class InviteLinkController { * * @return List of active invite tokens */ - @PreAuthorize("hasRole('ADMIN')") - @GetMapping("/list") - public ResponseEntity listInviteLinks() { + @RolesAllowed("ADMIN") + @GET + @jakarta.ws.rs.Path("/list") + public Response listInviteLinks() { try { List activeInvites = inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now()); @@ -282,12 +314,13 @@ public class InviteLinkController { }) .collect(Collectors.toList()); - return ResponseEntity.ok(Map.of("invites", inviteList)); + return Response.ok(Map.of("invites", inviteList), MediaType.APPLICATION_JSON).build(); } catch (Exception e) { log.error("Failed to list invite links: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to list invite links")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to list invite links")) + .build(); } } @@ -297,25 +330,30 @@ public class InviteLinkController { * @param inviteId The invite token ID to revoke * @return Success or error response */ - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/revoke/{inviteId}") - public ResponseEntity revokeInviteLink(@PathVariable Long inviteId) { + @RolesAllowed("ADMIN") + @DELETE + @jakarta.ws.rs.Path("/revoke/{inviteId}") + public Response revokeInviteLink(@PathParam("inviteId") Long inviteId) { try { Optional inviteOpt = inviteTokenRepository.findById(inviteId); if (inviteOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "Invite not found")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Invite not found")) + .build(); } inviteTokenRepository.deleteById(inviteId); log.info("Revoked invite link ID: {}", inviteId); - return ResponseEntity.ok(Map.of("message", "Invite link revoked successfully")); + return Response.ok(Map.of("message", "Invite link revoked successfully"), + MediaType.APPLICATION_JSON) + .build(); } catch (Exception e) { log.error("Failed to revoke invite link: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to revoke invite link")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to revoke invite link")) + .build(); } } @@ -324,9 +362,10 @@ public class InviteLinkController { * * @return Number of deleted tokens */ - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/cleanup") - public ResponseEntity cleanupExpiredInvites() { + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/cleanup") + public Response cleanupExpiredInvites() { try { List expiredInvites = inviteTokenRepository.findAll().stream() @@ -338,12 +377,13 @@ public class InviteLinkController { log.info("Cleaned up {} expired invite tokens", count); - return ResponseEntity.ok(Map.of("deletedCount", count)); + return Response.ok(Map.of("deletedCount", count), MediaType.APPLICATION_JSON).build(); } catch (Exception e) { log.error("Failed to cleanup expired invites: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to cleanup expired invites")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to cleanup expired invites")) + .build(); } } @@ -353,8 +393,9 @@ public class InviteLinkController { * @param token The invite token to validate * @return Invite details if valid, error otherwise */ - @GetMapping("/validate/{token}") - public ResponseEntity validateInviteToken(@PathVariable String token) { + @GET + @jakarta.ws.rs.Path("/validate/{token}") + public Response validateInviteToken(@PathParam("token") String token) { try { Optional inviteOpt = inviteTokenRepository.findByToken(token); @@ -384,7 +425,7 @@ public class InviteLinkController { response.put("expiresAt", invite.getExpiresAt().toString()); response.put("emailRequired", invite.getEmail() == null); - return ResponseEntity.ok(response); + return Response.ok(response, MediaType.APPLICATION_JSON).build(); } catch (Exception e) { log.error("Failed to validate invite token: {}", e.getMessage(), e); @@ -400,16 +441,18 @@ public class InviteLinkController { * @param password The password to set for the new account * @return Success or error response */ - @PostMapping("/accept/{token}") - public ResponseEntity acceptInvite( - @PathVariable String token, - @RequestParam(name = "email", required = false) String email, - @RequestParam(name = "password") String password) { + @POST + @jakarta.ws.rs.Path("/accept/{token}") + public Response acceptInvite( + @PathParam("token") String token, + @RestForm("email") String email, + @RestForm("password") String password) { try { // Validate password if (password == null || password.isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Password is required")) + .build(); } Optional inviteOpt = inviteTokenRepository.findByToken(token); @@ -433,14 +476,16 @@ public class InviteLinkController { if (effectiveEmail == null) { // Email not pre-set, must be provided by user if (email == null || email.trim().isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Email address is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Email address is required")) + .build(); } // Validate email format if (!email.contains("@")) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid email address")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid email address")) + .build(); } effectiveEmail = email.trim().toLowerCase(); @@ -470,18 +515,26 @@ public class InviteLinkController { effectiveEmail, invite.getRole()); - return ResponseEntity.ok( - Map.of("message", "Account created successfully", "username", effectiveEmail)); + return Response.ok( + Map.of( + "message", + "Account created successfully", + "username", + effectiveEmail), + MediaType.APPLICATION_JSON) + .build(); } catch (Exception e) { log.error("Failed to accept invite: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Failed to create account")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to create account")) + .build(); } } - private ResponseEntity> invalidInviteResponse() { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "Invalid invite link")); + private Response invalidInviteResponse() { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Invalid invite link")) + .build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java index 52d77e40c8..a74be8968c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java @@ -1,144 +1,169 @@ package stirling.software.proprietary.security.controller.api; -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.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; + +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.annotations.api.AdminServerCertificateApi; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.common.service.ServerCertificateServiceInterface; -@RestController -@RequestMapping("/api/v1/admin/server-certificate") +@AdminServerCertificateApi +@Path("/api/v1/admin/server-certificate") +@ApplicationScoped @Slf4j -@Tag( - name = "Admin - Server Certificate", - description = "Admin APIs for server certificate management") @RequiredArgsConstructor -@PreAuthorize("hasRole('ADMIN')") +@RolesAllowed("ADMIN") public class ServerCertificateController { private final ServerCertificateServiceInterface serverCertificateService; - @GetMapping("/info") + @GET + @Path("/info") @Operation( summary = "Get server certificate information", description = "Returns information about the current server certificate") - public ResponseEntity - getServerCertificateInfo() { + public Response getServerCertificateInfo() { try { ServerCertificateServiceInterface.ServerCertificateInfo info = serverCertificateService.getServerCertificateInfo(); - return ResponseEntity.ok(info); + return Response.ok(info).build(); } catch (Exception e) { log.error("Failed to get server certificate info", e); - return ResponseEntity.internalServerError().build(); + return Response.serverError().build(); } } - @PostMapping("/upload") + @POST + @Path("/upload") + @Consumes(MediaType.MULTIPART_FORM_DATA) @Operation( summary = "Upload server certificate", description = "Upload a new PKCS12 certificate file to be used as the server certificate") - public ResponseEntity uploadServerCertificate( + public Response uploadServerCertificate( @Parameter(description = "PKCS12 certificate file", required = true) - @RequestParam("file") - MultipartFile file, + @RestForm("file") + FileUpload fileUpload, @Parameter(description = "Certificate password", required = true) - @RequestParam("password") + @RestForm("password") String password) { - if (file.isEmpty()) { - return ResponseEntity.badRequest().body("Certificate file cannot be empty"); + MultipartFile file = FileUploadMultipartFile.of(fileUpload); + + if (file == null || file.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("Certificate file cannot be empty") + .build(); } if (!file.getOriginalFilename().toLowerCase().endsWith(".p12") && !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) { - return ResponseEntity.badRequest() - .body("Only PKCS12 (.p12 or .pfx) files are supported"); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Only PKCS12 (.p12 or .pfx) files are supported") + .build(); } try { serverCertificateService.uploadServerCertificate(file.getInputStream(), password); - return ResponseEntity.ok("Server certificate uploaded successfully"); + return Response.ok("Server certificate uploaded successfully").build(); } catch (IllegalArgumentException e) { log.warn("Invalid certificate upload: {}", e.getMessage()); - return ResponseEntity.badRequest().body("Invalid certificate or password."); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Invalid certificate or password.") + .build(); } catch (Exception e) { log.error("Failed to upload server certificate", e); - return ResponseEntity.internalServerError().body("Failed to upload server certificate"); + return Response.serverError() + .entity("Failed to upload server certificate") + .build(); } } - @DeleteMapping + @DELETE @Operation( summary = "Delete server certificate", description = "Delete the current server certificate") - public ResponseEntity deleteServerCertificate() { + public Response deleteServerCertificate() { try { serverCertificateService.deleteServerCertificate(); - return ResponseEntity.ok("Server certificate deleted successfully"); + return Response.ok("Server certificate deleted successfully").build(); } catch (Exception e) { log.error("Failed to delete server certificate", e); - return ResponseEntity.internalServerError().body("Failed to delete server certificate"); + return Response.serverError() + .entity("Failed to delete server certificate") + .build(); } } - @PostMapping("/generate") + @POST + @Path("/generate") @Operation( summary = "Generate new server certificate", description = "Generate a new self-signed server certificate") - public ResponseEntity generateServerCertificate() { + public Response generateServerCertificate() { try { serverCertificateService.deleteServerCertificate(); // Remove existing if any serverCertificateService.initializeServerCertificate(); // Generate new - return ResponseEntity.ok("New server certificate generated successfully"); + return Response.ok("New server certificate generated successfully").build(); } catch (Exception e) { log.error("Failed to generate server certificate", e); - return ResponseEntity.internalServerError() - .body("Failed to generate server certificate"); + return Response.serverError() + .entity("Failed to generate server certificate") + .build(); } } - @GetMapping("/certificate") + @GET + @Path("/certificate") @Operation( summary = "Download server certificate", description = "Download the server certificate in DER format for validation purposes") - public ResponseEntity getServerCertificate() { + public Response getServerCertificate() { try { if (!serverCertificateService.hasServerCertificate()) { - return ResponseEntity.notFound().build(); + return Response.status(Response.Status.NOT_FOUND).build(); } byte[] certificate = serverCertificateService.getServerCertificatePublicKey(); - return ResponseEntity.ok() + return Response.ok(certificate, MediaType.valueOf("application/pkix-cert")) .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"server-cert.cer\"") - .contentType(MediaType.valueOf("application/pkix-cert")) - .body(certificate); + .build(); } catch (Exception e) { log.error("Failed to get server certificate", e); - return ResponseEntity.internalServerError().build(); + return Response.serverError().build(); } } - @GetMapping("/enabled") + @GET + @Path("/enabled") @Operation( summary = "Check if server certificate feature is enabled", description = "Returns whether the server certificate feature is enabled in configuration") - public ResponseEntity isServerCertificateEnabled() { - return ResponseEntity.ok(serverCertificateService.isEnabled()); + public Response isServerCertificateEnabled() { + return Response.ok(serverCertificateService.isEnabled()).build(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index d9e43fa848..e7e2fe2798 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -3,12 +3,15 @@ package stirling.software.proprietary.security.controller.api; import java.util.Map; import java.util.Optional; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +25,8 @@ import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; @TeamApi +@Path("/api/v1/team") +@ApplicationScoped @Slf4j @RequiredArgsConstructor @PremiumEndpoint @@ -30,115 +35,140 @@ public class TeamController { private final TeamRepository teamRepository; private final UserRepository userRepository; - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/create") - public ResponseEntity createTeam(@RequestParam("name") String name) { + @RolesAllowed("ADMIN") + @POST + @Path("/create") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response createTeam(@QueryParam("name") String name) { if (teamRepository.existsByNameIgnoreCase(name)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "Team name already exists.")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "Team name already exists.")) + .build(); } Team team = new Team(); team.setName(name); teamRepository.save(team); - return ResponseEntity.ok(Map.of("message", "Team created successfully")); + return Response.ok(Map.of("message", "Team created successfully")).build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/rename") - public ResponseEntity renameTeam( - @RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) { + @RolesAllowed("ADMIN") + @POST + @Path("/rename") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response renameTeam( + @QueryParam("teamId") Long teamId, @QueryParam("newName") String newName) { Optional existing = teamRepository.findById(teamId); if (existing.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "Team not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Team not found.")) + .build(); } if (teamRepository.existsByNameIgnoreCase(newName)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "Team name already exists.")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "Team name already exists.")) + .build(); } Team team = existing.get(); // Prevent renaming the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot rename Internal team.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot rename Internal team.")) + .build(); } team.setName(newName); teamRepository.save(team); - return ResponseEntity.ok(Map.of("message", "Team renamed successfully")); + return Response.ok(Map.of("message", "Team renamed successfully")).build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/delete") + @RolesAllowed("ADMIN") + @POST + @Path("/delete") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Transactional - public ResponseEntity deleteTeam(@RequestParam("teamId") Long teamId) { + public Response deleteTeam(@QueryParam("teamId") Long teamId) { Optional teamOpt = teamRepository.findById(teamId); if (teamOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "Team not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Team not found.")) + .build(); } Team team = teamOpt.get(); // Prevent deleting the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot delete Internal team.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot delete Internal team.")) + .build(); } long memberCount = userRepository.countByTeam(team); if (memberCount > 0) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", - "Team must be empty before deletion. Please remove all members first.")); + "Team must be empty before deletion. Please remove all members first.")) + .build(); } teamRepository.delete(team); - return ResponseEntity.ok(Map.of("message", "Team deleted successfully")); + return Response.ok(Map.of("message", "Team deleted successfully")).build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/addUser") + @RolesAllowed("ADMIN") + @POST + @Path("/addUser") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Transactional - public ResponseEntity addUserToTeam( - @RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) { + public Response addUserToTeam( + @QueryParam("teamId") Long teamId, @QueryParam("userId") Long userId) { // Find the team Optional teamOpt = teamRepository.findById(teamId); if (teamOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "Team not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Team not found.")) + .build(); } Team team = teamOpt.get(); // Prevent adding users to the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot add users to Internal team.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot add users to Internal team.")) + .build(); } // Find the user Optional userOpt = userRepository.findById(userId); if (userOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "User not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "User not found.")) + .build(); } User user = userOpt.get(); // Check if user is in the Internal team - prevent moving them if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot move users from Internal team.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot move users from Internal team.")) + .build(); } // Assign user to team user.setTeam(team); userRepository.save(user); - return ResponseEntity.ok(Map.of("message", "User added to team successfully")); + return Response.ok(Map.of("message", "User added to team successfully")).build(); } + + // TODO: Migration required - teamRepository/userRepository still extend Spring Data + // JpaRepository. Once they are migrated to Panache, findById(...) returns the entity + // directly (not Optional); update the Optional handling above accordingly. Likewise + // save(...) -> persist(...), delete(...) -> delete(...)/deleteById(...). Derived finders + // existsByNameIgnoreCase / countByTeam must be reimplemented as Panache default methods. } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java index 743425aec8..8b9fdd4d61 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java @@ -11,18 +11,15 @@ import java.nio.file.StandardCopyOption; import java.util.*; import java.util.regex.Pattern; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -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.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.core.Response; + import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -33,8 +30,8 @@ import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; @Slf4j -@RestController -@RequestMapping("/api/v1/ui-data") +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/ui-data") @RequiredArgsConstructor @Tag(name = "UI Data") public class UIDataTessdataController { @@ -46,25 +43,27 @@ public class UIDataTessdataController { private static volatile long cachedRemoteTessdataExpiry = 0L; private static final long REMOTE_TESSDATA_TTL_MS = 10 * 60 * 1000; // 10 minutes - @GetMapping("/tessdata-languages") - @PreAuthorize("hasRole('ADMIN')") + @GET + @jakarta.ws.rs.Path("/tessdata-languages") + @RolesAllowed("ADMIN") @Operation(summary = "List installed and remotely available tessdata languages") - public ResponseEntity getTessdataLanguages() { + public Response getTessdataLanguages() { TessdataLanguagesResponse response = new TessdataLanguagesResponse(); response.setInstalled(getAvailableTesseractLanguages()); response.setAvailable(getRemoteTessdataLanguages()); response.setWritable(isWritableDirectory(Paths.get(runtimePathConfig.getTessDataPath()))); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } - @PostMapping("/tessdata/download") - @PreAuthorize("hasRole('ADMIN')") + @POST + @jakarta.ws.rs.Path("/tessdata/download") + @RolesAllowed("ADMIN") @Operation(summary = "Download selected tessdata languages from the official repository") - public ResponseEntity> downloadTessdataLanguages( - @RequestBody TessdataDownloadRequest request) { + public Response downloadTessdataLanguages(TessdataDownloadRequest request) { if (request.getLanguages() == null || request.getLanguages().isEmpty()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "No languages provided for download")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "No languages provided for download")) + .build(); } Path tessdataDir = Paths.get(runtimePathConfig.getTessDataPath()); @@ -72,13 +71,15 @@ public class UIDataTessdataController { Files.createDirectories(tessdataDir); } catch (IOException e) { log.error("Failed to create tessdata directory {}", tessdataDir, e); - return ResponseEntity.internalServerError() - .body(Map.of("message", "Failed to prepare tessdata directory")); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("message", "Failed to prepare tessdata directory")) + .build(); } if (!isWritableDirectory(tessdataDir)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("message", tessdataDir.toString())); + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("message", tessdataDir.toString())) + .build(); } List downloaded = new ArrayList<>(); @@ -139,11 +140,11 @@ public class UIDataTessdataController { "tessdataDir", tessdataDir.toString()); if (!downloaded.isEmpty() && failed.isEmpty()) { - return ResponseEntity.ok(response); + return Response.ok(response).build(); } else if (!downloaded.isEmpty()) { - return ResponseEntity.status(207).body(response); // Multi-Status for partial success + return Response.status(207).entity(response).build(); // Multi-Status for partial success } else { - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(response); + return Response.status(Response.Status.BAD_GATEWAY).entity(response).build(); } } 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 90abd5f067..fdfe2a3c03 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 @@ -1,7 +1,6 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; -import java.security.Principal; import java.sql.SQLException; import java.util.HashMap; import java.util.List; @@ -10,24 +9,31 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; +// TODO: Migration required - SessionPersistentRegistry (a not-yet-migrated collaborator) still +// exposes Spring Security session types (SessionInformation) and Spring principal types +// (UserDetails, OAuth2User) through its API. These imports are kept until that collaborator is +// migrated; the principal-type instanceof checks below must be revisited once the session registry +// returns Quarkus SecurityIdentity-based principals. import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; +import jakarta.annotation.security.RolesAllowed; import jakarta.mail.MessagingException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.transaction.Transactional; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; + +import org.jboss.resteasy.reactive.RestForm; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -56,6 +62,8 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.service.UserLicenseSettingsService; @UserApi +@jakarta.ws.rs.Path("/api/v1/user") +@jakarta.enterprise.context.ApplicationScoped @Slf4j @RequiredArgsConstructor public class UserController { @@ -66,13 +74,22 @@ public class UserController { private final ApplicationProperties applicationProperties; private final TeamRepository teamRepository; private final UserRepository userRepository; - private final Optional emailService; + // @Autowired(required=false) Optional -> CDI Instance. + private final jakarta.enterprise.inject.Instance emailService; private final UserLicenseSettingsService licenseSettingsService; private final LoginAttemptService loginAttemptService; - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/register") - public ResponseEntity register(@RequestBody UsernameAndPass usernameAndPass) + // JAX-RS injects the current security context; replaces Spring's Principal/Authentication + // method parameters. securityContext.getUserPrincipal() is null when unauthenticated. + @Context SecurityContext securityContext; + + // TODO: Migration required - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") is not a simple + // role check, so it cannot be expressed via @RolesAllowed. Re-implement the DEMO_USER exclusion + // as a runtime check against the current identity's roles (e.g. via SecurityIdentity). + @POST + @jakarta.ws.rs.Path("/register") + @Consumes(MediaType.APPLICATION_JSON) + public Response register(UsernameAndPass usernameAndPass) throws SQLException, UnsupportedProviderException { String username = usernameAndPass.getUsername(); String password = usernameAndPass.getPassword(); @@ -81,32 +98,36 @@ public class UserController { if (userService.usernameExistsIgnoreCase(username)) { log.warn("Registration failed: username already exists: {}", username); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "User already exists")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "User already exists")) + .build(); } if (!userService.isUsernameValid(username)) { log.warn("Registration failed: invalid username format: {}", username); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid username format")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid username format")) + .build(); } if (password == null || password.isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Password is required")) + .build(); } if (licenseSettingsService.wouldExceedLimit(1)) { long availableSlots = licenseSettingsService.getAvailableUserSlots(); int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers(); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "Maximum number of users reached. Allowed: " + maxAllowed + ", Available slots: " - + availableSlots)); + + availableSlots)) + .build(); } Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); SaveUserRequest.Builder builder = @@ -119,22 +140,25 @@ public class UserController { log.info("User registered successfully: {}", username); - return ResponseEntity.status(HttpStatus.CREATED) - .body( + return Response.status(Response.Status.CREATED) + .entity( Map.of( "user", buildUserResponse(user), "message", - "Account created successfully. Please log in.")); + "Account created successfully. Please log in.")) + .build(); } catch (IllegalArgumentException e) { log.error("Registration validation error: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", e.getMessage())); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", e.getMessage())) + .build(); } catch (Exception e) { log.error("Registration error for user: {}", username, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Registration failed: " + e.getMessage())); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Registration failed: " + e.getMessage())) + .build(); } } @@ -160,85 +184,111 @@ public class UserController { return userMap; } - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/change-username") + // TODO: Migration required - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") needs a runtime + // DEMO_USER guard (see note on register()). + @POST + @jakarta.ws.rs.Path("/change-username") @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) - public ResponseEntity changeUsername( - Principal principal, - @RequestParam(name = "currentPasswordChangeUsername") String currentPassword, - @RequestParam(name = "newUsername") String newUsername, - HttpServletRequest request, - HttpServletResponse response) + public Response changeUsername( + @RestForm(value = "currentPasswordChangeUsername") String currentPassword, + @RestForm(value = "newUsername") String newUsername, + @Context HttpServletRequest request, + @Context HttpServletResponse response) throws IOException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(newUsername)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "invalidUsername", "message", "Invalid username format")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "invalidUsername", "message", "Invalid username format")) + .build(); } - if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); + if (securityContext.getUserPrincipal() == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity( + Map.of( + "error", + "notAuthenticated", + "message", + "User not authenticated")) + .build(); } // The username MUST be unique when renaming - Optional userOpt = userService.findByUsername(principal.getName()); + Optional userOpt = + userService.findByUsername(securityContext.getUserPrincipal().getName()); if (userOpt == null || userOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "userNotFound", "message", "User not found")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "userNotFound", "message", "User not found")) + .build(); } User user = userOpt.get(); if (user.getUsername().equals(newUsername)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "usernameExists", "message", "Username already in use")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "usernameExists", "message", "Username already in use")) + .build(); } if (!userService.isPasswordCorrect(user, currentPassword)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity( + Map.of("error", "incorrectPassword", "message", "Incorrect password")) + .build(); } if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "usernameExists", "message", "Username already exists")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "usernameExists", "message", "Username already exists")) + .build(); } if (newUsername != null && newUsername.length() > 0) { try { userService.changeUsername(user, newUsername); } catch (IllegalArgumentException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "invalidUsername", "message", - "Invalid username format")); + "Invalid username format")) + .build(); } } - // Logout using Spring's utility - new SecurityContextLogoutHandler().logout(request, response, null); - return ResponseEntity.ok( - Map.of( - "message", - "credsUpdated", - "description", - "Username changed successfully. Please log in again.")); + // TODO: Migration required - Spring's SecurityContextLogoutHandler has no Quarkus + // equivalent. Session/logout handling must be re-implemented via the migrated session + // registry (expire the current session) and/or quarkus auth config. + return Response.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Username changed successfully. Please log in again.")) + .build(); } - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/change-password-on-login") + // TODO: Migration required - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") needs a runtime + // DEMO_USER guard (see note on register()). + @POST + @jakarta.ws.rs.Path("/change-password-on-login") @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) - public ResponseEntity changePasswordOnLogin( - Principal principal, - @RequestParam(name = "currentPassword") String currentPassword, - @RequestParam(name = "newPassword") String newPassword, - @RequestParam(name = "confirmPassword") String confirmPassword, - HttpServletRequest request, - HttpServletResponse response) + public Response changePasswordOnLogin( + @RestForm(value = "currentPassword") String currentPassword, + @RestForm(value = "newPassword") String newPassword, + @RestForm(value = "confirmPassword") String confirmPassword, + @Context HttpServletRequest request, + @Context HttpServletResponse response) throws SQLException, UnsupportedProviderException { - if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); + if (securityContext.getUserPrincipal() == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity( + Map.of( + "error", + "notAuthenticated", + "message", + "User not authenticated")) + .build(); } - Optional userOpt = userService.findByUsernameIgnoreCase(principal.getName()); + Optional userOpt = + userService.findByUsernameIgnoreCase(securityContext.getUserPrincipal().getName()); if (userOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "userNotFound", "message", "User not found")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "userNotFound", "message", "User not found")) + .build(); } if (currentPassword == null @@ -247,92 +297,108 @@ public class UserController { || newPassword.isEmpty() || confirmPassword == null || confirmPassword.isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "missingParameters", "message", "Current password, new password, and confirmation are" - + " required")); + + " required")) + .build(); } if (!newPassword.equals(confirmPassword)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "passwordMismatch", "message", - "New password and confirmation do not match")); + "New password and confirmation do not match")) + .build(); } if (newPassword.equals(currentPassword)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "passwordUnchanged", "message", - "New password must be different from the current password")); + "New password must be different from the current password")) + .build(); } User user = userOpt.get(); if (!userService.isPasswordCorrect(user, currentPassword)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity( + Map.of("error", "incorrectPassword", "message", "Incorrect password")) + .build(); } // Set flags before changing password so they're saved together user.setForcePasswordChange(false); userService.changePassword(user, newPassword); userService.changeFirstUse(user, false); - // Logout using Spring's utility - new SecurityContextLogoutHandler().logout(request, response, null); - return ResponseEntity.ok( - Map.of( - "message", - "credsUpdated", - "description", - "Password changed successfully. Please log in again.")); + // TODO: Migration required - Spring's SecurityContextLogoutHandler has no Quarkus + // equivalent. Re-implement logout via the migrated session registry / quarkus auth config. + return Response.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Password changed successfully. Please log in again.")) + .build(); } - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/change-password") + // TODO: Migration required - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") needs a runtime + // DEMO_USER guard (see note on register()). + @POST + @jakarta.ws.rs.Path("/change-password") @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) - public ResponseEntity changePassword( - Principal principal, - @RequestParam(name = "currentPassword") String currentPassword, - @RequestParam(name = "newPassword") String newPassword, - HttpServletRequest request, - HttpServletResponse response) + public Response changePassword( + @RestForm(value = "currentPassword") String currentPassword, + @RestForm(value = "newPassword") String newPassword, + @Context HttpServletRequest request, + @Context HttpServletResponse response) throws SQLException, UnsupportedProviderException { - if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); + if (securityContext.getUserPrincipal() == null) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity( + Map.of( + "error", + "notAuthenticated", + "message", + "User not authenticated")) + .build(); } - Optional userOpt = userService.findByUsernameIgnoreCase(principal.getName()); + Optional userOpt = + userService.findByUsernameIgnoreCase(securityContext.getUserPrincipal().getName()); if (userOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "userNotFound", "message", "User not found")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "userNotFound", "message", "User not found")) + .build(); } User user = userOpt.get(); if (!userService.isPasswordCorrect(user, currentPassword)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); + return Response.status(Response.Status.UNAUTHORIZED) + .entity( + Map.of("error", "incorrectPassword", "message", "Incorrect password")) + .build(); } userService.changePassword(user, newPassword); - // Logout using Spring's utility - new SecurityContextLogoutHandler().logout(request, response, null); - return ResponseEntity.ok( - Map.of( - "message", - "credsUpdated", - "description", - "Password changed successfully. Please log in again.")); + // TODO: Migration required - Spring's SecurityContextLogoutHandler has no Quarkus + // equivalent. Re-implement logout via the migrated session registry / quarkus auth config. + return Response.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Password changed successfully. Please log in again.")) + .build(); } - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/updateUserSettings") /** * Updates the user settings based on the provided JSON payload. * @@ -345,78 +411,86 @@ public class UserController { * "en", "fr"). * * Keys not listed above will be ignored. - * @param principal The currently authenticated user. - * @return A ResponseEntity with success or error information. + * @return A Response with success or error information. * @throws SQLException If a database error occurs. * @throws UnsupportedProviderException If the operation is not supported for the user's * provider. */ - public ResponseEntity updateUserSettings( - @RequestBody Map updates, Principal principal) + // TODO: Migration required - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") needs a runtime + // DEMO_USER guard (see note on register()). + @POST + @jakarta.ws.rs.Path("/updateUserSettings") + @Consumes(MediaType.APPLICATION_JSON) + public Response updateUserSettings(Map updates) throws SQLException, UnsupportedProviderException { log.debug("Processed updates: {}", updates); // Assuming you have a method in userService to update the settings for a user - userService.updateUserSettings(principal.getName(), updates); - return ResponseEntity.ok(Map.of("message", "Settings updated successfully")); + userService.updateUserSettings(securityContext.getUserPrincipal().getName(), updates); + return Response.ok(Map.of("message", "Settings updated successfully")).build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/saveUser") - public ResponseEntity saveUser( - @RequestParam(name = "username", required = true) String username, - @RequestParam(name = "password", required = false) String password, - @RequestParam(name = "role") String role, - @RequestParam(name = "teamId", required = false) Long teamId, - @RequestParam(name = "authType") String authType, - @RequestParam(name = "forceChange", required = false, defaultValue = "false") - boolean forceChange, - @RequestParam(name = "forceMFA", required = false, defaultValue = "false") - boolean forceMFA) + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/admin/saveUser") + public Response saveUser( + @RestForm(value = "username") String username, + @RestForm(value = "password") String password, + @RestForm(value = "role") String role, + @RestForm(value = "teamId") Long teamId, + @RestForm(value = "authType") String authType, + @RestForm(value = "forceChange") boolean forceChange, + @RestForm(value = "forceMFA") boolean forceMFA) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(username)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", - "Invalid username format. Username must be 3-50 characters.")); + "Invalid username format. Username must be 3-50 characters.")) + .build(); } if (licenseSettingsService.wouldExceedLimit(1)) { long availableSlots = licenseSettingsService.getAvailableUserSlots(); int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers(); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "Maximum number of users reached. Allowed: " + maxAllowed + ", Available slots: " - + availableSlots)); + + availableSlots)) + .build(); } Optional userOpt = userService.findByUsernameIgnoreCase(username); User user = null; if (userOpt.isPresent()) { user = userOpt.get(); if (user.getUsername().equalsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "Username already exists.")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "Username already exists.")) + .build(); } } if (userService.usernameExistsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "Username already exists.")); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "Username already exists.")) + .build(); } try { // Validate the role Role roleEnum = Role.fromString(role); if (roleEnum == Role.INTERNAL_API_USER) { // If the role is INTERNAL_API_USER, reject the request - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot assign INTERNAL_API_USER role.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot assign INTERNAL_API_USER role.")) + .build(); } } catch (IllegalArgumentException e) { // If the role ID is not valid, return error - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid role specified.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid role specified.")) + .build(); } // Use teamId if provided, otherwise use default team @@ -432,8 +506,9 @@ public class UserController { Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); if (selectedTeam != null && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot assign users to Internal team.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot assign users to Internal team.")) + .build(); } } @@ -447,65 +522,77 @@ public class UserController { try { requestedAuthType = AuthenticationType.valueOf(authType.toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid authentication type specified.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid authentication type specified.")) + .build(); } } builder.authenticationType(requestedAuthType); if (requestedAuthType == AuthenticationType.WEB) { if (password == null || password.isBlank()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password is required.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Password is required.")) + .build(); } if (password.length() < 6) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password must be at least 6 characters.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Password must be at least 6 characters.")) + .build(); } builder.password(password).firstLogin(forceChange).requireMfa(forceMFA); } userService.saveUserCore(builder.build()); - return ResponseEntity.ok(Map.of("message", "User created successfully")); + return Response.ok(Map.of("message", "User created successfully")).build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/inviteUsers") - public ResponseEntity inviteUsers( - @RequestParam(name = "emails", required = true) String emails, - @RequestParam(name = "role", defaultValue = "ROLE_USER") String role, - @RequestParam(name = "teamId", required = false) Long teamId, - HttpServletRequest request) + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/admin/inviteUsers") + public Response inviteUsers( + @RestForm(value = "emails") String emails, + @RestForm(value = "role") String role, + @RestForm(value = "teamId") Long teamId, + @Context HttpServletRequest request) throws SQLException, UnsupportedProviderException { + // Default role when not supplied (was @RequestParam defaultValue = "ROLE_USER"). + if (role == null || role.isEmpty()) { + role = "ROLE_USER"; + } + // Check if email invites are enabled if (!applicationProperties.getMail().isEnableInvites()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Email invites are not enabled")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Email invites are not enabled")) + .build(); } // Check if email service is available - if (!emailService.isPresent()) { - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body( + if (!emailService.isResolvable()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity( Map.of( "error", "Email service is not configured. Please configure SMTP" - + " settings.")); + + " settings.")) + .build(); } // Parse comma-separated email addresses String[] emailArray = emails.split(","); if (emailArray.length == 0) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "At least one email address is required")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "At least one email address is required")) + .build(); } // Check license limits if (licenseSettingsService.wouldExceedLimit(emailArray.length)) { long availableSlots = licenseSettingsService.getAvailableUserSlots(); int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers(); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "Not enough user slots available. Allowed: " @@ -513,19 +600,22 @@ public class UserController { + ", Available: " + availableSlots + ", Requested: " - + emailArray.length)); + + emailArray.length)) + .build(); } // Validate role try { Role roleEnum = Role.fromString(role); if (roleEnum == Role.INTERNAL_API_USER) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot assign INTERNAL_API_USER role")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot assign INTERNAL_API_USER role")) + .build(); } } catch (IllegalArgumentException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid role specified")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid role specified")) + .build(); } // Determine team @@ -540,8 +630,9 @@ public class UserController { Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); if (selectedTeam != null && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot assign users to Internal team")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot assign users to Internal team")) + .build(); } } @@ -578,50 +669,55 @@ public class UserController { if (successCount > 0) { response.put("message", successCount + " user(s) invited successfully"); - return ResponseEntity.ok(response); + return Response.ok(response).build(); } else { response.put("error", "Failed to invite any users"); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + return Response.status(Response.Status.BAD_REQUEST).entity(response).build(); } } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/changeRole") + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/admin/changeRole") @Transactional - public ResponseEntity changeRole( - @RequestParam(name = "username") String username, - @RequestParam(name = "role") String role, - @RequestParam(name = "teamId", required = false) Long teamId, - Authentication authentication) + public Response changeRole( + @RestForm(value = "username") String username, + @RestForm(value = "role") String role, + @RestForm(value = "teamId") Long teamId) throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (!userOpt.isPresent()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "User not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "User not found.")) + .build(); } if (!userService.usernameExistsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "User not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "User not found.")) + .build(); } // Get the currently authenticated username - String currentUsername = authentication.getName(); + String currentUsername = securityContext.getUserPrincipal().getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot change your own role.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot change your own role.")) + .build(); } try { // Validate the role Role roleEnum = Role.fromString(role); if (roleEnum == Role.INTERNAL_API_USER) { // If the role is INTERNAL_API_USER, reject the request - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot assign INTERNAL_API_USER role.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot assign INTERNAL_API_USER role.")) + .build(); } } catch (IllegalArgumentException e) { // If the role ID is not valid, return error - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid role specified.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid role specified.")) + .build(); } User user = userOpt.get(); @@ -631,15 +727,17 @@ public class UserController { if (team != null) { // Prevent assigning to Internal team if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot assign users to Internal team.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot assign users to Internal team.")) + .build(); } // Prevent moving users from Internal team if (user.getTeam() != null && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot move users from Internal team.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot move users from Internal team.")) + .build(); } user.setTeam(team); @@ -648,32 +746,33 @@ public class UserController { } userService.changeRole(user, role); - return ResponseEntity.ok(Map.of("message", "User role updated successfully")); + return Response.ok(Map.of("message", "User role updated successfully")).build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/changePasswordForUser") - public ResponseEntity changePasswordForUser( - @RequestParam(name = "username") String username, - @RequestParam(name = "newPassword", required = false) String newPassword, - @RequestParam(name = "generateRandom", defaultValue = "false") boolean generateRandom, - @RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail, - @RequestParam(name = "includePassword", defaultValue = "false") boolean includePassword, - @RequestParam(name = "forcePasswordChange", defaultValue = "false") - boolean forcePasswordChange, - HttpServletRequest request, - Authentication authentication) + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/admin/changePasswordForUser") + public Response changePasswordForUser( + @RestForm(value = "username") String username, + @RestForm(value = "newPassword") String newPassword, + @RestForm(value = "generateRandom") boolean generateRandom, + @RestForm(value = "sendEmail") boolean sendEmail, + @RestForm(value = "includePassword") boolean includePassword, + @RestForm(value = "forcePasswordChange") boolean forcePasswordChange, + @Context HttpServletRequest request) throws SQLException, UnsupportedProviderException, MessagingException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "User not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "User not found.")) + .build(); } - String currentUsername = authentication.getName(); + String currentUsername = securityContext.getUserPrincipal().getName(); if (currentUsername.equalsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot change your own password.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot change your own password.")) + .build(); } User user = userOpt.get(); @@ -684,8 +783,9 @@ public class UserController { } if (finalPassword == null || finalPassword.trim().isEmpty()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "New password is required.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "New password is required.")) + .build(); } // Set force password change flag before changing password so both are saved together @@ -696,20 +796,22 @@ public class UserController { userService.invalidateUserSessions(username); if (sendEmail) { - if (emailService.isEmpty() || !applicationProperties.getMail().isEnabled()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Email is not configured.")); + if (!emailService.isResolvable() || !applicationProperties.getMail().isEnabled()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Email is not configured.")) + .build(); } String userEmail = user.getUsername(); // Check if username is a valid email format if (userEmail == null || userEmail.isBlank() || !userEmail.contains("@")) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( + return Response.status(Response.Status.BAD_REQUEST) + .entity( Map.of( "error", "User's email is not a valid email address. Notifications" - + " are disabled.")); + + " are disabled.")) + .build(); } String loginUrl = buildLoginUrl(request); @@ -722,31 +824,33 @@ public class UserController { loginUrl); } - return ResponseEntity.ok(Map.of("message", "User password updated successfully")); + return Response.ok(Map.of("message", "User password updated successfully")).build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/changeUserEnabled/{username}") - public ResponseEntity changeUserEnabled( - @PathVariable("username") String username, - @RequestParam("enabled") boolean enabled, - Authentication authentication) + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/admin/changeUserEnabled/{username}") + public Response changeUserEnabled( + @PathParam("username") String username, @RestForm(value = "enabled") boolean enabled) throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "User not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "User not found.")) + .build(); } if (!userService.usernameExistsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "User not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "User not found.")) + .build(); } // Get the currently authenticated username - String currentUsername = authentication.getName(); + String currentUsername = securityContext.getUserPrincipal().getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot disable your own account.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot disable your own account.")) + .build(); } User user = userOpt.get(); userService.changeUserEnabled(user, enabled); @@ -773,33 +877,39 @@ public class UserController { } } } - return ResponseEntity.ok( - Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully")); + return Response.ok( + Map.of( + "message", + "User " + (enabled ? "enabled" : "disabled") + " successfully")) + .build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/unlockUser/{username}") + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/admin/unlockUser/{username}") @Audited(type = AuditEventType.SETTINGS_CHANGED, level = AuditLevel.BASIC) - public ResponseEntity unlockUser(@PathVariable("username") String username) { + public Response unlockUser(@PathParam("username") String username) { loginAttemptService.resetAttempts(username); - return ResponseEntity.ok(Map.of("message", "User account unlocked successfully")); + return Response.ok(Map.of("message", "User account unlocked successfully")).build(); } - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/deleteUser/{username}") + @RolesAllowed("ADMIN") + @POST + @jakarta.ws.rs.Path("/admin/deleteUser/{username}") @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) - public ResponseEntity deleteUser( - @PathVariable("username") String username, Authentication authentication) { + public Response deleteUser(@PathParam("username") String username) { if (!userService.usernameExistsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "User not found.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "User not found.")) + .build(); } // Get the currently authenticated username - String currentUsername = authentication.getName(); + String currentUsername = securityContext.getUserPrincipal().getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Cannot delete your own account.")); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Cannot delete your own account.")) + .build(); } // Invalidate all sessions before deleting the user List sessionsInformations = @@ -809,40 +919,48 @@ public class UserController { sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId()); } userService.deleteUser(username); - return ResponseEntity.ok(Map.of("message", "User deleted successfully")); + return Response.ok(Map.of("message", "User deleted successfully")).build(); } - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/get-api-key") - public ResponseEntity> getApiKey(Principal principal) { - if (principal == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "User not authenticated.")); + // TODO: Migration required - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") needs a runtime + // DEMO_USER guard (see note on register()). + @POST + @jakarta.ws.rs.Path("/get-api-key") + public Response getApiKey() { + if (securityContext.getUserPrincipal() == null) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "User not authenticated.")) + .build(); } - String username = principal.getName(); + String username = securityContext.getUserPrincipal().getName(); String apiKey = userService.getApiKeyForUser(username); if (apiKey == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "API key not found for user.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "API key not found for user.")) + .build(); } - return ResponseEntity.ok(Map.of("apiKey", apiKey)); + return Response.ok(Map.of("apiKey", apiKey)).build(); } - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") - @PostMapping("/update-api-key") - public ResponseEntity> updateApiKey(Principal principal) { - if (principal == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "User not authenticated.")); + // TODO: Migration required - @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") needs a runtime + // DEMO_USER guard (see note on register()). + @POST + @jakarta.ws.rs.Path("/update-api-key") + public Response updateApiKey() { + if (securityContext.getUserPrincipal() == null) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "User not authenticated.")) + .build(); } - String username = principal.getName(); + String username = securityContext.getUserPrincipal().getName(); User user = userService.refreshApiKeyForUser(username); String apiKey = user.getApiKey(); if (apiKey == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("error", "API key not found for user.")); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "API key not found for user.")) + .build(); } - return ResponseEntity.ok(Map.of("apiKey", apiKey)); + return Response.ok(Map.of("apiKey", apiKey)).build(); } /** @@ -951,18 +1069,20 @@ public class UserController { } } - @PostMapping("/complete-initial-setup") - public ResponseEntity completeInitialSetup() { + @POST + @jakarta.ws.rs.Path("/complete-initial-setup") + public Response completeInitialSetup() { try { String username = userService.getCurrentUsername(); if (username == null || "anonymousUser".equalsIgnoreCase(username)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body("User not authenticated"); + return Response.status(Response.Status.UNAUTHORIZED) + .entity("User not authenticated") + .build(); } Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found"); + return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); } User user = userOpt.get(); @@ -970,24 +1090,26 @@ public class UserController { userRepository.save(user); log.info("User {} completed initial setup", username); - return ResponseEntity.ok().body(Map.of("success", true)); + return Response.ok(Map.of("success", true)).build(); } catch (Exception e) { log.error("Error completing initial setup", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Failed to complete initial setup"); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Failed to complete initial setup") + .build(); } } /** * List all enabled users for selection in signing workflows. * - * @param principal The authenticated user * @return List of user summaries */ - @GetMapping("/users") - public ResponseEntity> listUsers(Principal principal) { - if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + @GET + @jakarta.ws.rs.Path("/users") + @Produces(MediaType.APPLICATION_JSON) + public Response listUsers() { + if (securityContext.getUserPrincipal() == null) { + return Response.status(Response.Status.UNAUTHORIZED).build(); } List users = @@ -996,7 +1118,7 @@ public class UserController { .map(this::toUserSummaryDTO) .collect(java.util.stream.Collectors.toList()); - return ResponseEntity.ok(users); + return Response.ok(users).build(); } private UserSummaryDTO toUserSummaryDTO(User user) { 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 index 27302a1f76..2e9fefb480 100644 --- 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 @@ -2,13 +2,13 @@ package stirling.software.proprietary.security.controller.api.enterprise; import java.util.List; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + 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; @@ -18,15 +18,20 @@ 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; +// TODO: Migration required - @Conditional(H2SQLCondition.class) had no direct Quarkus equivalent. +// H2SQLCondition is an org.springframework.context.annotation.Condition that inspects active +// profiles and datasource URL/type at bean-registration time. Quarkus has no equivalent for an +// arbitrary runtime Condition deciding whether to register a JAX-RS resource. Options: gate the +// endpoints with @io.quarkus.arc.lookup.LookupIfProperty / @io.quarkus.arc.profile.IfBuildProfile +// if the H2 check can be reduced to a build/config property, or add a runtime guard in each method +// that returns 404/disabled when the active datasource is not H2. @Slf4j -@Controller -@RequestMapping("/api/v1/database") -@PreAuthorize("hasRole('ADMIN')") +@ApplicationScoped +@Path("/api/v1/database") +@RolesAllowed("ADMIN") @EnterpriseEndpoint -@Conditional(H2SQLCondition.class) @Tag(name = "Database", description = "Database APIs for backup, import, and management") @RequiredArgsConstructor public class DatabaseControllerEnterprise { @@ -37,8 +42,9 @@ public class DatabaseControllerEnterprise { summary = "Delete the last database backup file", description = "Only Enterprise - Deletes the last database backup file from the server.") - @DeleteMapping("/deleteLast") - public ResponseEntity deleteLastFile() { + @DELETE + @Path("/deleteLast") + public Response deleteLastFile() { log.info("Deleting last database backup file..."); List> results = databaseService.deleteLastBackup(); return getDeleteAllResults(results); @@ -47,17 +53,18 @@ public class DatabaseControllerEnterprise { @Operation( summary = "Delete all database backup files", description = "Only Enterprise - Deletes all database backup files from the server.") - @DeleteMapping("/deleteAll") - public ResponseEntity deleteAllFiles() { + @DELETE + @Path("/deleteAll") + public Response deleteAllFiles() { log.info("Deleting all database backup files..."); List> results = databaseService.deleteAllBackups(); return getDeleteAllResults(results); } - private ResponseEntity getDeleteAllResults(List> results) { + private Response getDeleteAllResults(List> results) { if (results.isEmpty()) { log.info("No backup files found to delete."); - return ResponseEntity.ok(new DeleteAllResult(List.of(), List.of(), "noContent")); + return Response.ok(new DeleteAllResult(List.of(), List.of(), "noContent")).build(); } List deleted = @@ -75,8 +82,9 @@ public class DatabaseControllerEnterprise { 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")); + return Response.status(207) // MULTI_STATUS + .entity(new DeleteAllResult(deleted, failed, "partialFailure")) + .build(); } DeleteAllResult result = new DeleteAllResult(deleted, failed, "ok"); log.debug( @@ -84,7 +92,7 @@ public class DatabaseControllerEnterprise { result.deleted, result.failed, result.status); - return ResponseEntity.ok(result); // 200 + return Response.ok(result).build(); // 200 } private static final class DeleteAllResult { 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 cf41aefa34..e1535c4db3 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 @@ -2,30 +2,49 @@ package stirling.software.proprietary.security.database; import java.util.Arrays; -import org.springframework.context.annotation.Condition; -import org.springframework.context.annotation.ConditionContext; -import org.springframework.core.type.AnnotatedTypeMetadata; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.Config; + +// TODO: Migration required - this was an org.springframework.context.annotation.Condition used via +// @Conditional(H2SQLCondition.class) to gate bean/controller registration at startup. Quarkus has no +// runtime @Conditional equivalent (@io.quarkus.arc.profile.IfBuildProfile / @LookupIfProperty are +// build-time/property-name based and cannot replicate this composite logic). The decision logic has +// been preserved as a runtime-evaluable CDI bean; callers that previously used @Conditional must +// inject this bean and guard their behavior at runtime via matches() instead. /** Returns {@code true} when the active deployment is genuinely on H2. */ -public class H2SQLCondition implements Condition { +@ApplicationScoped +public class H2SQLCondition { - @Override - public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { - var env = context.getEnvironment(); + @Inject Config config; - if (Arrays.asList(env.getActiveProfiles()).contains("saas")) { + public H2SQLCondition() {} + + public H2SQLCondition(Config config) { + this.config = config; + } + + /** Evaluates the H2 deployment decision against the active configuration. */ + public boolean matches() { + // Quarkus exposes active profiles via the "quarkus.profile" config property (comma separated). + String activeProfiles = config.getOptionalValue("quarkus.profile", String.class).orElse(""); + if (Arrays.asList(activeProfiles.split(",")).contains("saas")) { return false; } // Legacy custom-DB block, if explicitly enabled, is authoritative. boolean enableCustomDatabase = - env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false); + config.getOptionalValue("system.datasource.enableCustomDatabase", Boolean.class) + .orElse(false); if (enableCustomDatabase) { - String dataSourceType = env.getProperty("system.datasource.type", String.class, ""); + String dataSourceType = + config.getOptionalValue("system.datasource.type", String.class).orElse(""); return "h2".equalsIgnoreCase(dataSourceType); } - String springDsUrl = env.getProperty("spring.datasource.url", String.class, ""); + String springDsUrl = + config.getOptionalValue("spring.datasource.url", String.class).orElse(""); if (springDsUrl == null || springDsUrl.isBlank()) { return true; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/ScheduledTasks.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/ScheduledTasks.java index 68c34c31a3..5871c726ca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/ScheduledTasks.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/ScheduledTasks.java @@ -2,24 +2,39 @@ package stirling.software.proprietary.security.database; import java.sql.SQLException; -import org.springframework.context.annotation.Conditional; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.quarkus.scheduler.Scheduled; import lombok.RequiredArgsConstructor; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.proprietary.security.service.DatabaseServiceInterface; -@Component -@Conditional(H2SQLCondition.class) +@ApplicationScoped @RequiredArgsConstructor public class ScheduledTasks { private final DatabaseServiceInterface databaseService; - @Scheduled(cron = "#{applicationProperties.system.databaseBackup.cron}") + @Inject H2SQLCondition h2SQLCondition; + + // TODO: Migration required - the original bean used @Conditional(H2SQLCondition.class) to skip + // registration entirely when not running on H2. Quarkus has no runtime @Conditional, so the gate + // is evaluated at runtime here via h2SQLCondition.matches() and the backup is short-circuited + // when false. The schedule still fires on the configured cron but becomes a no-op off H2. + // + // TODO: Migration required - the Spring cron was a SpEL expression + // "#{applicationProperties.system.databaseBackup.cron}". Quarkus @Scheduled resolves config + // expressions of the form "{config.key}", so this assumes the value is exposed under the config + // key "system.databaseBackup.cron" (default disabled). Verify the ApplicationProperties binding + // exposes that key (or adjust the key) once ApplicationProperties is rebound via @ConfigMapping. + @Scheduled(cron = "{system.databaseBackup.cron:off}") public void performBackup() throws SQLException, UnsupportedProviderException { + if (!h2SQLCondition.matches()) { + return; + } databaseService.exportDatabase(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/AuthorityRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/AuthorityRepository.java index e8d74ec01f..3aad4faeb6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/AuthorityRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/AuthorityRepository.java @@ -2,15 +2,27 @@ package stirling.software.proprietary.security.database.repository; import java.util.Set; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import stirling.software.proprietary.security.model.Authority; -@Repository -public interface AuthorityRepository extends JpaRepository { - // Set findByUsername(String username); - Set findByUser_Username(String username); +/** + * Quarkus Panache repository for {@link Authority}. + * + *

Migrated from a Spring Data {@code JpaRepository}. The derived finders are + * reimplemented as Panache queries: {@code findByUser_Username} traverses the {@code user.username} + * association path and {@code findByUserId} matches on the {@code user.id} foreign key. + */ +@ApplicationScoped +public class AuthorityRepository implements PanacheRepositoryBase { - Authority findByUserId(long user_id); + public Set findByUser_Username(String username) { + return new java.util.HashSet<>(list("user.username", username)); + } + + public Authority findByUserId(long userId) { + return find("user.id", userId).firstResult(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JPATokenRepositoryImpl.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JPATokenRepositoryImpl.java index fe92f07bea..a9ed90c504 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JPATokenRepositoryImpl.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JPATokenRepositoryImpl.java @@ -2,16 +2,30 @@ package stirling.software.proprietary.security.database.repository; import java.util.Date; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +// TODO: Migration required - this class implements Spring Security's +// org.springframework.security.web.authentication.rememberme.PersistentTokenRepository +// (remember-me persistent login) and exchanges +// org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken +// objects. Quarkus has no direct remember-me equivalent. The OpenSAML/JWT logic here is +// trivial token persistence, so the body is preserved unchanged. Once the remember-me +// mechanism is rehosted (custom Quarkus form-auth + persistent token store, or quarkus-oidc +// session), re-implement the interface against the new abstraction. The Spring Security +// imports below are intentionally KEPT until that abstraction exists. import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; -import org.springframework.transaction.annotation.Transactional; import stirling.software.proprietary.security.model.PersistentLogin; +@ApplicationScoped public class JPATokenRepositoryImpl implements PersistentTokenRepository { private final PersistentLoginRepository persistentLoginRepository; + @Inject public JPATokenRepositoryImpl(PersistentLoginRepository persistentLoginRepository) { this.persistentLoginRepository = persistentLoginRepository; } @@ -24,6 +38,9 @@ public class JPATokenRepositoryImpl implements PersistentTokenRepository { newToken.setUsername(token.getUsername()); newToken.setToken(token.getTokenValue()); newToken.setLastUsed(token.getDate().toInstant()); + // TODO: Migration required - PersistentLoginRepository is a collaborator that is not + // yet migrated to Quarkus Panache. Once it extends PanacheRepositoryBase, replace + // save(...) with persist(...). persistentLoginRepository.save(newToken); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/PersistentLoginRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/PersistentLoginRepository.java index 2ab9566763..ba01b9d1a0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/PersistentLoginRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/PersistentLoginRepository.java @@ -1,11 +1,18 @@ package stirling.software.proprietary.security.database.repository; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import stirling.software.proprietary.security.model.PersistentLogin; -@Repository -public interface PersistentLoginRepository extends JpaRepository { - void deleteByUsername(String username); +@ApplicationScoped +public class PersistentLoginRepository + implements PanacheRepositoryBase { + + @Transactional + public void deleteByUsername(String username) { + delete("username", username); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java index db5d5a9b33..09de57d7cb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java @@ -3,46 +3,67 @@ package stirling.software.proprietary.security.database.repository; import java.time.Instant; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - +import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; + import stirling.software.proprietary.security.model.SessionEntity; -@Repository -public interface SessionRepository extends JpaRepository { - List findByPrincipalName(String principalName); +/** + * Quarkus Panache repository for {@link SessionEntity}. + * + *

Migrated from a Spring Data {@code JpaRepository}. Derived finders are + * reimplemented as Panache queries; the {@code @Query}-annotated methods preserve their original + * JPQL strings via {@code find(...)} / {@code update(...)}. + */ +@ApplicationScoped +public class SessionRepository implements PanacheRepositoryBase { - List findByExpired(boolean expired); + public List findByPrincipalName(String principalName) { + return list("principalName", principalName); + } - SessionEntity findBySessionId(String sessionId); + public List findByExpired(boolean expired) { + return list("expired", expired); + } + + public SessionEntity findBySessionId(String sessionId) { + return find("sessionId", sessionId).firstResult(); + } - @Modifying @Transactional - @Query( - "UPDATE SessionEntity s SET s.expired = :expired, s.lastRequest = :lastRequest WHERE s.principalName = :principalName") - void saveByPrincipalName( - @Param("expired") boolean expired, - @Param("lastRequest") Instant lastRequest, - @Param("principalName") String principalName); + public void saveByPrincipalName(boolean expired, Instant lastRequest, String principalName) { + update( + "expired = :expired, lastRequest = :lastRequest WHERE principalName = :principalName", + Parameters.with("expired", expired) + .and("lastRequest", lastRequest) + .and("principalName", principalName)); + } - @Query( - "SELECT t.id as teamId, MAX(s.lastRequest) as lastActivity " - + "FROM stirling.software.proprietary.model.Team t " - + "LEFT JOIN t.users u " - + "LEFT JOIN SessionEntity s ON u.username = s.principalName " - + "GROUP BY t.id") - List findLatestActivityByTeam(); + public List findLatestActivityByTeam() { + return getEntityManager() + .createQuery( + "SELECT t.id as teamId, MAX(s.lastRequest) as lastActivity " + + "FROM stirling.software.proprietary.model.Team t " + + "LEFT JOIN t.users u " + + "LEFT JOIN SessionEntity s ON u.username = s.principalName " + + "GROUP BY t.id", + Object[].class) + .getResultList(); + } - @Query( - "SELECT u.username as username, MAX(s.lastRequest) as lastRequest " - + "FROM stirling.software.proprietary.security.model.User u " - + "LEFT JOIN SessionEntity s ON u.username = s.principalName " - + "WHERE u.team.id = :teamId " - + "GROUP BY u.username") - List findLatestSessionByTeamId(@Param("teamId") Long teamId); + public List findLatestSessionByTeamId(Long teamId) { + return getEntityManager() + .createQuery( + "SELECT u.username as username, MAX(s.lastRequest) as lastRequest " + + "FROM stirling.software.proprietary.security.model.User u " + + "LEFT JOIN SessionEntity s ON u.username = s.principalName " + + "WHERE u.team.id = :teamId " + + "GROUP BY u.username", + Object[].class) + .setParameter("teamId", teamId) + .getResultList(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 6cdfb57ea8..b086df2fb3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -6,116 +6,179 @@ import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; -@Repository -public interface UserRepository extends JpaRepository { - Optional findByUsernameIgnoreCase(String username); +/** + * Quarkus Panache repository for {@link User}. + * + *

Migrated from a Spring Data {@code JpaRepository}. Derived finders are reimplemented + * as Panache queries and the {@code @Query} methods keep their original JPQL/native strings passed to + * Panache {@code find}/{@code getEntityManager().createNativeQuery(...)}. + */ +@ApplicationScoped +public class UserRepository implements PanacheRepositoryBase { - @Query("FROM User u LEFT JOIN FETCH u.settings where upper(u.username) = upper(:username)") - Optional findByUsernameIgnoreCaseWithSettings(@Param("username") String username); + public Optional findByUsernameIgnoreCase(String username) { + return find("upper(username) = upper(?1)", username).firstResultOptional(); + } - @Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id") - Optional findByIdWithSettings(@Param("id") Long id); + public Optional findByUsernameIgnoreCaseWithSettings(String username) { + return find( + "FROM User u LEFT JOIN FETCH u.settings where upper(u.username) = upper(?1)", + username) + .firstResultOptional(); + } - Optional findByUsername(String username); + public Optional findByIdWithSettings(Long id) { + return find("FROM User u LEFT JOIN FETCH u.settings where u.id = ?1", id) + .firstResultOptional(); + } - Optional findByApiKey(String apiKey); + public Optional findByUsername(String username) { + return find("username", username).firstResultOptional(); + } - Optional findByEmail(String email); + public Optional findByApiKey(String apiKey) { + return find("apiKey", apiKey).firstResultOptional(); + } - Optional findBySupabaseId(UUID supabaseId); + public Optional findByEmail(String email) { + return find("email", email).firstResultOptional(); + } - Optional findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId); + public Optional findBySupabaseId(UUID supabaseId) { + return find("supabaseId", supabaseId).firstResultOptional(); + } - List findByAuthenticationTypeIgnoreCase(String authenticationType); + public Optional findBySsoProviderAndSsoProviderId( + String ssoProvider, String ssoProviderId) { + return find("ssoProvider = ?1 and ssoProviderId = ?2", ssoProvider, ssoProviderId) + .firstResultOptional(); + } - @Query("SELECT u FROM User u WHERE u.team IS NULL") - List findAllWithoutTeam(); + public List findByAuthenticationTypeIgnoreCase(String authenticationType) { + return list("upper(authenticationType) = upper(?1)", authenticationType); + } - @Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team") - List findAllWithTeam(); + public List findAllWithoutTeam() { + return list("SELECT u FROM User u WHERE u.team IS NULL"); + } - @Query( - "SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId") - List findAllByTeamId(@Param("teamId") Long teamId); + public List findAllWithTeam() { + return list("SELECT u FROM User u LEFT JOIN FETCH u.team"); + } - long countByTeam(Team team); + public List findAllByTeamId(Long teamId) { + return list( + "SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id =" + + " ?1", + teamId); + } - List findAllByTeam(Team team); + public long countByTeam(Team team) { + return count("team", team); + } + + public List findAllByTeam(Team team) { + return list("team", team); + } // OAuth grandfathering queries - long countBySsoProviderIsNotNull(); + public long countBySsoProviderIsNotNull() { + return count("ssoProvider IS NOT NULL"); + } - long countByOauthGrandfatheredTrue(); + public long countByOauthGrandfatheredTrue() { + return count("oauthGrandfathered = true"); + } - List findAllBySsoProviderIsNotNull(); + public List findAllBySsoProviderIsNotNull() { + return list("ssoProvider IS NOT NULL"); + } /** * Finds all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2. * This catches V1 users who were created via SSO but never signed in (sso_provider is null). */ - @Query( - "SELECT u FROM User u WHERE u.ssoProvider IS NOT NULL " - + "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')") - List findAllSsoUsers(); + public List findAllSsoUsers() { + return list( + "SELECT u FROM User u WHERE u.ssoProvider IS NOT NULL " + + "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')"); + } /** * Finds SSO users who have never created a session (pending activation) and are not yet * grandfathered. */ - @Query( - "SELECT u FROM User u " - + "LEFT JOIN SessionEntity s ON u.username = s.principalName " - + "WHERE (u.ssoProvider IS NOT NULL " - + "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')) " - + "AND (u.oauthGrandfathered IS NULL OR u.oauthGrandfathered = false) " - + "AND s.sessionId IS NULL") - List findPendingSsoUsersWithoutSession(); + public List findPendingSsoUsersWithoutSession() { + return list( + "SELECT u FROM User u " + + "LEFT JOIN SessionEntity s ON u.username = s.principalName " + + "WHERE (u.ssoProvider IS NOT NULL " + + "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')) " + + "AND (u.oauthGrandfathered IS NULL OR u.oauthGrandfathered = false) " + + "AND s.sessionId IS NULL"); + } /** * Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2. */ - @Query( - "SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL " - + "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')") - long countSsoUsers(); + public long countSsoUsers() { + return count( + "ssoProvider IS NOT NULL " + + "OR LOWER(authenticationType) IN ('sso', 'oauth2', 'saml2')"); + } - @Query( - "SELECT COUNT(u) FROM User u JOIN u.settings settings " - + "WHERE KEY(settings) = :key AND settings = :value") - long countUsersBySetting(@Param("key") String key, @Param("value") String value); + public long countUsersBySetting(String key, String value) { + return count( + "SELECT COUNT(u) FROM User u JOIN u.settings settings " + + "WHERE KEY(settings) = ?1 AND settings = ?2", + key, + value); + } - @Modifying - @Query( - value = "DELETE FROM user_settings WHERE user_id = :userId AND setting_key IN (:keys)", - nativeQuery = true) - void deleteSettingsByUserIdAndKeys( - @Param("userId") Long userId, @Param("keys") List keys); + @Transactional + public void deleteSettingsByUserIdAndKeys(Long userId, List keys) { + getEntityManager() + .createNativeQuery( + "DELETE FROM user_settings WHERE user_id = :userId AND setting_key IN" + + " (:keys)") + .setParameter("userId", userId) + .setParameter("keys", keys) + .executeUpdate(); + } /** Anonymous users (no username) created before the cut-off, streamed for batch cleanup. */ - @Query("SELECT u.id FROM User u WHERE u.username IS NULL AND u.createdAt < :cutoffDate") - Stream findByUsernameIsNullAndCreatedAtBefore( - @Param("cutoffDate") LocalDateTime cutoffDate); + public Stream findByUsernameIsNullAndCreatedAtBefore(LocalDateTime cutoffDate) { + return getEntityManager() + .createQuery( + "SELECT u.id FROM User u WHERE u.username IS NULL AND u.createdAt <" + + " :cutoffDate", + Long.class) + .setParameter("cutoffDate", cutoffDate) + .getResultStream(); + } /** Users with an API key but no row in {@code user_credits}. */ - @Query( - value = - "SELECT u.* FROM users u " - + "LEFT JOIN user_credits uc ON uc.user_id = u.user_id " - + "WHERE u.api_key IS NOT NULL AND uc.user_id IS NULL", - nativeQuery = true) - List findUsersWithApiKeyButNoCredits(); + public List findUsersWithApiKeyButNoCredits() { + return getEntityManager() + .createNativeQuery( + "SELECT u.* FROM users u " + + "LEFT JOIN user_credits uc ON uc.user_id = u.user_id " + + "WHERE u.api_key IS NOT NULL AND uc.user_id IS NULL", + User.class) + .getResultList(); + } /** Single-shot UPDATE that reassigns a user to a different team. */ - @Modifying - @Query("UPDATE User u SET u.team.id = :teamId WHERE u.id = :userId") - int updateUserTeamId(@Param("userId") Long userId, @Param("teamId") Long teamId); + @Transactional + public int updateUserTeamId(Long userId, Long teamId) { + return update("team.id = ?1 WHERE id = ?2", teamId, userId); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/service/DatabaseNotificationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/service/DatabaseNotificationService.java index ba52116a93..e95f68c909 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/service/DatabaseNotificationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/service/DatabaseNotificationService.java @@ -1,10 +1,9 @@ package stirling.software.proprietary.security.database.service; -import java.util.Optional; - -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Service; - +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Named; import jakarta.mail.MessagingException; import lombok.extern.slf4j.Slf4j; @@ -14,19 +13,20 @@ import stirling.software.common.model.ApplicationProperties.Premium.EnterpriseFe import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface; import stirling.software.proprietary.security.service.EmailService; -@Service +@ApplicationScoped @Slf4j public class DatabaseNotificationService implements DatabaseNotificationServiceInterface { - private final Optional emailService; + private final Instance emailService; private final ApplicationProperties props; private final boolean runningEE; private DatabaseNotifications notifications; + @Inject DatabaseNotificationService( - Optional emailService, + Instance emailService, ApplicationProperties props, - @Qualifier("runningEE") boolean runningEE) { + @Named("runningEE") boolean runningEE) { this.emailService = emailService; this.props = props; this.runningEE = runningEE; @@ -62,14 +62,15 @@ public class DatabaseNotificationService implements DatabaseNotificationServiceI } private void sendMail(String subject, String message) { - emailService.ifPresent( - service -> { - try { - String to = props.getMail().getFrom(); - service.sendSimpleMail(to, subject, message); - } catch (MessagingException e) { - log.error("Error sending notification email: {}", e.getMessage(), e); - } - }); + // MIGRATION: Spring Optional optional dependency -> CDI Instance; + // ifPresent -> isResolvable() guard + get(). + if (emailService.isResolvable()) { + try { + String to = props.getMail().getFrom(); + emailService.get().sendSimpleMail(to, subject, message); + } catch (MessagingException e) { + log.error("Error sending notification email: {}", e.getMessage(), e); + } + } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/EnterpriseEndpointFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/EnterpriseEndpointFilter.java index 2d34032cff..dac69250b5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/EnterpriseEndpointFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/EnterpriseEndpointFilter.java @@ -2,29 +2,42 @@ package stirling.software.proprietary.security.filter; import java.io.IOException; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Response; -@Component -public class EnterpriseEndpointFilter extends OncePerRequestFilter { +// Spring's OncePerRequestFilter has no Quarkus equivalent; implementing jakarta.servlet.Filter +// directly. Registered via @WebFilter (quarkus-undertow). The single-execution-per-request +// guarantee OncePerRequestFilter provided is effectively given for top-level servlet filters here. +// TODO: Migration required - if this filter must run before/after other filters, ordering is not +// expressed by @WebFilter; configure quarkus.http.filter.* or a ServletExtension if order matters. +@ApplicationScoped +@WebFilter(urlPatterns = "/*") +public class EnterpriseEndpointFilter implements Filter { private final boolean runningProOrHigher; - public EnterpriseEndpointFilter(@Qualifier("runningProOrHigher") boolean runningProOrHigher) { + @Inject + public EnterpriseEndpointFilter(@Named("runningProOrHigher") boolean runningProOrHigher) { this.runningProOrHigher = runningProOrHigher; } @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + if (!runningProOrHigher && isPrometheusEndpointRequest(request)) { // Allow only health checks to pass through for non-pro users String uri = request.getRequestURI(); @@ -44,7 +57,7 @@ public class EnterpriseEndpointFilter extends OncePerRequestFilter { || "/readiness".equals(trimmedUri); if (!isHealthCheck) { - response.setStatus(HttpStatus.NOT_FOUND.value()); + response.setStatus(Response.Status.NOT_FOUND.getStatusCode()); return; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index 92bbcab89c..cd7369a785 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -11,6 +11,17 @@ import java.sql.SQLException; import java.util.Map; import java.util.Optional; +// TODO: Migration required - Spring Security glue. This filter populates the +// Spring SecurityContextHolder, which has no Quarkus equivalent. In Quarkus the +// authenticated principal is exposed as io.quarkus.security.identity.SecurityIdentity +// and is produced by an IdentityProvider / SecurityIdentityAugmentor, NOT written +// imperatively from a servlet filter. The remaining org.springframework.security.* +// imports below stay only because the collaborators (JwtServiceInterface, +// CustomUserDetailsService, UserService, JwtAuthenticationEntryPoint, +// ApiKeyAuthenticationToken) still expose Spring Security types and have not yet +// been migrated. Once those are ported to Quarkus security, this filter should +// register the user via a custom IdentityProvider keyed off the validated JWT claims +// (prefer quarkus-smallrye-jwt for bearer validation) instead of UsernamePasswordAuthenticationToken. import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -19,14 +30,17 @@ 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.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; @@ -39,20 +53,32 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.UserService; +// TODO: Migration required - registration/ordering. As a Spring OncePerRequestFilter +// this ran once per request at a Spring-defined position in the security filter chain. +// On Quarkus (quarkus-undertow) a jakarta.servlet.Filter needs explicit registration +// and ordering (e.g. a @WebFilter with urlPatterns, or a FilterRegistrationBean-style +// producer). Confirm this filter is registered ahead of the resource layer and that the +// once-per-request semantics are preserved (Undertow does not re-enter servlet filters +// per forward by default, so the OncePerRequestFilter base is not strictly required). @Slf4j -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { +@ApplicationScoped +public class JwtAuthenticationFilter implements Filter { - private final JwtServiceInterface jwtService; - private final UserService userService; - private final CustomUserDetailsService userDetailsService; - private final AuthenticationEntryPoint authenticationEntryPoint; - private final ApplicationProperties.Security securityProperties; + @Inject JwtServiceInterface jwtService; + @Inject UserService userService; + @Inject CustomUserDetailsService userDetailsService; + // TODO: Migration required - AuthenticationEntryPoint is a Spring Security type. + // JwtAuthenticationEntryPoint is still a Spring @Component; once migrated this should + // be injected as a plain CDI bean (it only writes a 401 JSON/error to the response). + @Inject AuthenticationEntryPoint authenticationEntryPoint; + @Inject ApplicationProperties.Security securityProperties; @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, + FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + if (!jwtService.isJwtEnabled()) { filterChain.doFilter(request, response); return; @@ -124,6 +150,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private boolean apiKeyExists(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + // TODO: Migration required - SecurityContextHolder has no Quarkus equivalent. + // This reads/writes the Spring thread-local security context. On Quarkus, the + // identity should come from SecurityIdentity (injected) and API-key auth should be + // handled by a custom IdentityProvider rather than imperatively setting the context. Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !authentication.isAuthenticated()) { @@ -165,6 +195,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { throws SQLException, UnsupportedProviderException { String username = claims.get("sub").toString(); + // TODO: Migration required - SecurityContextHolder/UsernamePasswordAuthenticationToken. + // Building a Spring authentication token and pushing it into the thread-local context + // must be replaced by producing a Quarkus SecurityIdentity (via IdentityProvider/ + // SecurityIdentityAugmentor) from the validated JWT claims. The user-loading logic + // (userDetailsService.loadUserByUsername) can be kept as a plain service call. if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { processUserAuthenticationType(claims, username); UserDetails userDetails = userDetailsService.loadUserByUsername(username); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ParticipantRateLimitInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ParticipantRateLimitInterceptor.java index de96cb22fe..918942f464 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ParticipantRateLimitInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ParticipantRateLimitInterceptor.java @@ -1,34 +1,43 @@ package stirling.software.proprietary.security.filter; +import java.io.IOException; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.http.HttpStatus; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.HandlerInterceptor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +import io.quarkus.scheduler.Scheduled; import lombok.extern.slf4j.Slf4j; /** Per-IP rate limiter for the unauthenticated participant token endpoints. */ @Slf4j -@Component -public class ParticipantRateLimitInterceptor implements HandlerInterceptor { +@Provider +@ApplicationScoped +public class ParticipantRateLimitInterceptor implements ContainerRequestFilter { private static final int MAX_REQUESTS_PER_MINUTE = 20; private static final long WINDOW_MS = 60_000L; + // Replaces the Spring MVC InterceptorRegistry path mapping + // "/api/v1/workflow/participant/**" (see ProprietaryWebMvcConfig). Since a @Provider + // ContainerRequestFilter is applied to every request, we self-gate on the path here. + private static final String PARTICIPANT_PATH_PREFIX = "api/v1/workflow/participant/"; + // value: [requestCount, windowStartMs] private final ConcurrentHashMap requestCounts = new ConcurrentHashMap<>(); @Override - public boolean preHandle( - HttpServletRequest request, HttpServletResponse response, Object handler) - throws Exception { + public void filter(ContainerRequestContext requestContext) throws IOException { + String path = requestContext.getUriInfo().getPath(); + if (path == null || !path.contains(PARTICIPANT_PATH_PREFIX)) { + return; + } - String ip = getClientIp(request); + String ip = getClientIp(requestContext); long now = System.currentTimeMillis(); long[] entry = @@ -46,26 +55,33 @@ public class ParticipantRateLimitInterceptor implements HandlerInterceptor { log.warn( "Rate limit exceeded for IP {} on participant endpoint {}", ip, - request.getRequestURI()); - response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); - response.setHeader("Retry-After", "60"); - response.setContentType("application/json"); - response.getWriter() - .write("{\"error\":\"Rate limit exceeded. Try again in 60 seconds.\"}"); - return false; + path); + requestContext.abortWith( + Response.status(Response.Status.TOO_MANY_REQUESTS) + .header("Retry-After", "60") + .type("application/json") + .entity("{\"error\":\"Rate limit exceeded. Try again in 60 seconds.\"}") + .build()); } - return true; } - private String getClientIp(HttpServletRequest request) { + private String getClientIp(ContainerRequestContext requestContext) { // Do not trust X-Forwarded-For: it is user-controlled and trivially spoofed, // which would allow an attacker to bypass this rate limiter by rotating fake IPs. - // Operators who deploy behind a trusted reverse proxy should configure Spring's - // RemoteIpFilter / ForwardedHeaderFilter at the framework level instead. - return request.getRemoteAddr(); + // Operators who deploy behind a trusted reverse proxy should configure Quarkus' + // quarkus.http.proxy.* (proxy-address-forwarding / trusted-proxies) at the framework + // level instead. + // TODO: Migration required - ContainerRequestContext does not expose the remote + // address. Inject quarkus' RoutingContext (io.vertx.ext.web.RoutingContext) or + // jakarta.servlet.http.HttpServletRequest (quarkus-undertow) to obtain + // request.remoteAddress()/getRemoteAddr(); the previous Spring code used + // HttpServletRequest.getRemoteAddr(). Falling back to a header-derived key here would + // reintroduce the spoofing risk documented above. + Object remoteAddr = requestContext.getProperty("org.eclipse.jetty.server.remoteAddress"); + return remoteAddr != null ? remoteAddr.toString() : "unknown"; } - @Scheduled(fixedDelay = 300_000) + @Scheduled(every = "300s") public void cleanupExpiredWindows() { long cutoff = System.currentTimeMillis() - WINDOW_MS; requestCounts.entrySet().removeIf(e -> e.getValue()[1] < cutoff); 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 5777b093e8..57fd1f8f55 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 @@ -6,23 +6,31 @@ import java.io.IOException; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Lazy; -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpStatus; +// TODO: Migration required - the following Spring Security core types are still on the +// collaborator APIs (UserService, SessionPersistentRegistry, ApiKeyAuthenticationToken) which are +// NOT yet migrated to Quarkus. Once those collaborators move to io.quarkus.security.identity +// (SecurityIdentity) + a SecurityIdentityAugmentor, replace SecurityContextHolder/Authentication +// with an injected SecurityIdentity (or @Context jakarta.ws.rs.core.SecurityContext) and drop these +// imports. The principal-type dispatch (UserDetails/OAuth2User/CustomSaml2AuthenticatedPrincipal) +// must then be re-expressed via SecurityIdentity attributes/roles. import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; @@ -36,21 +44,26 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; +// TODO: Migration required - @Profile("!saas") had no direct annotation equivalent here. Gate this +// filter's activation on the "saas" build profile (e.g. via @io.quarkus.arc.profile.UnlessBuildProfile +// or a runtime check) and register it through Quarkus (quarkus-undertow @WebFilter or a +// jakarta.ws.rs.container.ContainerRequestFilter @Provider). Registration ordering relative to the +// other security filters (JwtAuthenticationFilter, *RateLimitingFilter) must be preserved. @Slf4j -@Component -@Profile("!saas") -public class UserAuthenticationFilter extends OncePerRequestFilter { +@ApplicationScoped +public class UserAuthenticationFilter implements Filter { private final ApplicationProperties.Security securityProp; private final UserService userService; private final SessionPersistentRegistry sessionPersistentRegistry; private final boolean loginEnabledValue; + @Inject public UserAuthenticationFilter( - @Lazy ApplicationProperties.Security securityProp, - @Lazy UserService userService, + ApplicationProperties.Security securityProp, + UserService userService, SessionPersistentRegistry sessionPersistentRegistry, - @Qualifier("loginEnabled") boolean loginEnabledValue) { + @Named("loginEnabled") boolean loginEnabledValue) { this.securityProp = securityProp; this.userService = userService; this.sessionPersistentRegistry = sessionPersistentRegistry; @@ -58,10 +71,22 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + + // Spring's OncePerRequestFilter#shouldNotFilter behavior: skip the filter body for static + // resources, SPA routes and public API endpoints. TODO: Migration required - ensure the + // Quarkus filter registration does not run this filter more than once per request (the + // OncePerRequestFilter guarantee). + if (shouldNotFilter(request)) { + filterChain.doFilter(request, response); + return; + } + if (!loginEnabledValue) { // If login is not enabled, just pass all requests without authentication filterChain.doFilter(request, response); @@ -93,7 +118,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { // provider for API keys. Optional user = userService.getUserByApiKey(apiKey); if (user.isEmpty()) { - response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setStatus(Response.Status.UNAUTHORIZED.getStatusCode()); response.getWriter().write("Invalid API Key."); return; } @@ -103,7 +128,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { SecurityContextHolder.getContext().setAuthentication(authentication); } catch (AuthenticationException e) { // If API key authentication fails, deny the request - response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setStatus(Response.Status.UNAUTHORIZED.getStatusCode()); response.getWriter().write("Invalid API Key."); return; } @@ -122,7 +147,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } // For API requests, return 401 with JSON response (no redirects) - response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setStatus(Response.Status.UNAUTHORIZED.getStatusCode()); response.setContentType("application/json"); response.getWriter() .write( @@ -182,7 +207,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { if (blockRegistration && !isUserExists) { log.warn("Blocked registration for OAuth2/SAML user: {}", username); SecurityContextHolder.clearContext(); - response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setStatus(Response.Status.FORBIDDEN.getStatusCode()); response.setContentType("application/json"); response.getWriter() .write( @@ -209,7 +234,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { // Return 401 if credentials are invalid (no redirects) if (!isUserExists && notSsoLogin) { SecurityContextHolder.clearContext(); - response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setStatus(Response.Status.UNAUTHORIZED.getStatusCode()); response.setContentType("application/json"); response.getWriter() .write( @@ -224,7 +249,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } if (isUserDisabled) { SecurityContextHolder.clearContext(); - response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setStatus(Response.Status.FORBIDDEN.getStatusCode()); response.setContentType("application/json"); response.getWriter() .write( @@ -262,8 +287,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } } - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { + // Was Spring's OncePerRequestFilter#shouldNotFilter; now called explicitly at the top of + // doFilter. TODO: Migration required - if registered as a ContainerRequestFilter instead of a + // servlet Filter, fold this skip logic into the request filter using UriInfo. + private boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); String contextPath = request.getContextPath(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java index e4a15ae7b4..5bd6ed7758 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java @@ -5,48 +5,65 @@ import java.time.Duration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket; import io.github.bucket4j.ConsumptionProbe; import io.github.pixee.security.Newlines; +import io.quarkus.security.identity.SecurityIdentity; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import stirling.software.common.model.enumeration.Role; import stirling.software.common.util.RegexPatternUtils; -@Component -@Profile("!saas") -public class UserBasedRateLimitingFilter extends OncePerRequestFilter { +// Servlet filter retained (quarkus-undertow). Spring's OncePerRequestFilter replaced by a plain +// jakarta.servlet.Filter registered as a CDI bean via @WebFilter so it covers all requests; the +// rate-limiting logic operates on the raw HttpServletRequest/HttpServletResponse which a JAX-RS +// ContainerRequestFilter does not expose as conveniently. +// TODO: Migration required - Spring's @Profile("!saas") gated this filter so it was NOT registered +// in the "saas" profile. Quarkus has no per-profile bean exclusion on @WebFilter; gate registration +// with @io.quarkus.arc.profile.UnlessBuildProfile("saas") if "saas" is a build profile, or guard the +// body with a runtime check on the active profile (org.eclipse.microprofile.config Config / +// io.quarkus.runtime.LaunchMode) if it must be a runtime toggle. +@ApplicationScoped +@WebFilter("/*") +public class UserBasedRateLimitingFilter implements jakarta.servlet.Filter { private final Map apiBuckets = new ConcurrentHashMap<>(); private final Map webBuckets = new ConcurrentHashMap<>(); - @Qualifier("rateLimit") private final boolean rateLimit; - public UserBasedRateLimitingFilter(@Qualifier("rateLimit") boolean rateLimit) { + // TODO: Migration required - SecurityContextHolder replaced by injected SecurityIdentity. + // SecurityIdentity is request-scoped and is populated by Quarkus security extensions + // (quarkus-elytron-security / quarkus-oidc / etc.) once authentication is migrated. Until then + // it will be anonymous and getRoleFromIdentity will fall through to the IllegalStateException. + private final SecurityIdentity securityIdentity; + + @Inject + public UserBasedRateLimitingFilter( + @Named("rateLimit") boolean rateLimit, SecurityIdentity securityIdentity) { this.rateLimit = rateLimit; + this.securityIdentity = securityIdentity; } @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; if (!rateLimit) { // If rateLimit is not enabled, just pass all requests without rate limiting filterChain.doFilter(request, response); @@ -64,19 +81,14 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter { if (apiKey != null && !apiKey.trim().isEmpty()) { identifier = // Prefix to distinguish between API keys and usernames "API_KEY_" + apiKey; - } else { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated()) { - UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - identifier = userDetails.getUsername(); - } + } else if (securityIdentity != null && !securityIdentity.isAnonymous()) { + identifier = securityIdentity.getPrincipal().getName(); } // If neither API key nor an authenticated user is present, use IP address if (identifier == null) { identifier = request.getRemoteAddr(); } - Role userRole = - getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication()); + Role userRole = getRoleFromIdentity(securityIdentity); if (request.getHeader("X-API-KEY") != null) { // It's an API call processRequest( @@ -98,13 +110,13 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter { } } - private Role getRoleFromAuthentication(Authentication authentication) { - if (authentication != null && authentication.isAuthenticated()) { - for (GrantedAuthority authority : authentication.getAuthorities()) { + private Role getRoleFromIdentity(SecurityIdentity identity) { + if (identity != null && !identity.isAnonymous()) { + for (String role : identity.getRoles()) { try { - return Role.fromString(authority.getAuthority()); + return Role.fromString(role); } catch (IllegalArgumentException ex) { - // Ignore and continue to next authority. + // Ignore and continue to next role. } } } @@ -128,7 +140,7 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } else { long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000; - response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setStatus(jakarta.ws.rs.core.Response.Status.TOO_MANY_REQUESTS.getStatusCode()); response.setHeader( "X-Rate-Limit-Retry-After-Seconds", Newlines.stripAll(String.valueOf(waitForRefill))); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java index c969704bad..e5f173273d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java @@ -1,52 +1,64 @@ package stirling.software.proprietary.security.model; import java.util.Collection; +import java.util.Collections; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; - -public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { +// TODO: Migration required - this class extended Spring Security's +// org.springframework.security.authentication.AbstractAuthenticationToken (which implements +// org.springframework.security.core.Authentication). Quarkus has no equivalent token type; the +// runtime principal model is io.quarkus.security.identity.SecurityIdentity, typically built via a +// custom IdentityProvider / SecurityIdentityAugmentor for the API-key auth path. This class has +// been reduced to a plain POJO that preserves the principal/credentials/authorities state and the +// authenticated flag so the API-key user-loading logic can keep populating it. Re-wire it into a +// SecurityIdentity (or replace it entirely) when the API-key authentication filter is migrated. +public class ApiKeyAuthenticationToken { private final Object principal; private Object credentials; + private final Collection authorities; + private boolean authenticated; public ApiKeyAuthenticationToken(String apiKey) { - super((Collection) null); this.principal = null; this.credentials = apiKey; - setAuthenticated(false); + this.authorities = Collections.emptyList(); + this.authenticated = false; } public ApiKeyAuthenticationToken( - Object principal, String apiKey, Collection authorities) { - super(authorities); - this.principal = principal; // principal can be a UserDetails object + Object principal, String apiKey, Collection authorities) { + this.principal = principal; // principal can be a UserDetails-like object this.credentials = apiKey; - super.setAuthenticated(true); // this authentication is trusted + this.authorities = + authorities == null ? Collections.emptyList() : authorities; + this.authenticated = true; // this authentication is trusted } - @Override public Object getCredentials() { return credentials; } - @Override public Object getPrincipal() { return principal; } - @Override + public Collection getAuthorities() { + return authorities; + } + + public boolean isAuthenticated() { + return authenticated; + } + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException( - "Cannot set this token to trusted. Use constructor which takes a GrantedAuthority list instead."); + "Cannot set this token to trusted. Use constructor which takes an authorities list instead."); } - super.setAuthenticated(false); + this.authenticated = false; } - @Override public void eraseCredentials() { - super.eraseCredentials(); credentials = null; } } 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 9adcce1ad3..a267e1c641 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,8 +2,6 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; -import org.springframework.security.core.GrantedAuthority; - import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Column; @@ -18,11 +16,16 @@ import jakarta.persistence.Table; import lombok.Getter; import lombok.Setter; +// TODO: Migration required - this entity previously implemented Spring Security's +// org.springframework.security.core.GrantedAuthority. That interface only required +// String getAuthority(), which the Lombok @Getter on the 'authority' field still +// provides. Quarkus uses its own role model (SecurityIdentity roles); when wiring the +// IdentityProvider that loads users, map this 'authority' value into the granted roles. @Entity @Table(name = "authorities") @Getter @Setter -public class Authority implements GrantedAuthority, Serializable { +public class Authority implements Serializable { private static final long serialVersionUID = 1L; 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 e39b1a3306..a1bcecefa5 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 @@ -12,7 +12,6 @@ import java.util.stream.Collectors; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; -import org.springframework.security.core.userdetails.UserDetails; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -34,7 +33,14 @@ import stirling.software.proprietary.model.Team; @Setter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) -public class User implements UserDetails, Serializable { +// TODO: Migration required - this entity previously implemented +// org.springframework.security.core.userdetails.UserDetails. Quarkus has no UserDetails +// contract; the user-loading/principal adaptation must be rehosted in a Quarkus +// IdentityProvider (or SecurityIdentityAugmentor) that builds a SecurityIdentity from this +// entity. The Lombok getters still expose getUsername()/getPassword()/getAuthorities()/ +// isEnabled() so that adapter can read them directly. isEnabled() override below is retained +// as plain business logic (null-safe enabled flag). +public class User implements Serializable { private static final long serialVersionUID = 1L; @@ -117,7 +123,7 @@ public class User implements UserDetails, Serializable { return Role.getRoleNameByRoleId(getRolesAsString()); } - @Override + // No longer @Override: previously satisfied UserDetails.isEnabled(). public boolean isEnabled() { return enabled == null || enabled; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/Email.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/Email.java index 4e9421aba7..3f32e4f6ab 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/Email.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/Email.java @@ -1,7 +1,5 @@ package stirling.software.proprietary.security.model.api; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -10,10 +8,13 @@ import lombok.NoArgsConstructor; import stirling.software.common.model.api.GeneralFile; +// TODO: Migration required - dropped @ConditionalOnProperty("mail.enabled"). This is a request +// DTO, not a CDI bean, so conditional bean registration does not apply. The mail.enabled gate must +// be enforced on the consuming endpoint/service (e.g. via @IfBuildProfile / LookupIfProperty or a +// runtime guard on the email controller), not on this model. @Data @NoArgsConstructor @EqualsAndHashCode(callSuper = true) -@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false) public class Email extends GeneralFile { @Schema( 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 index f2cd5e2425..7574cd362a 100644 --- 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 @@ -1,8 +1,10 @@ package stirling.software.proprietary.security.model.exception; -import org.springframework.security.core.AuthenticationException; - -public class AuthenticationFailureException extends AuthenticationException { +// TODO: Migration required - originally extended +// org.springframework.security.core.AuthenticationException (Spring Security). Quarkus has no direct +// equivalent base type; extend RuntimeException so this remains a usable application exception. +// If integrated with quarkus-security, consider mapping to io.quarkus.security.AuthenticationFailedException. +public class AuthenticationFailureException extends RuntimeException { public AuthenticationFailureException(String message) { super(message); } 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 784a9f0a2f..fdf8507c27 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 @@ -4,16 +4,7 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.DisabledException; -import org.springframework.security.authentication.LockedException; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; - +import jakarta.enterprise.context.ApplicationScoped; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -24,72 +15,68 @@ 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 -public class CustomOAuth2AuthenticationFailureHandler - extends SimpleUrlAuthenticationFailureHandler { +@ApplicationScoped +public class CustomOAuth2AuthenticationFailureHandler { - @Override @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException exception) + HttpServletRequest request, HttpServletResponse response, Exception exception) throws IOException, ServletException { - if (exception instanceof BadCredentialsException) { - log.error("BadCredentialsException", exception); - getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials"); - return; - } - if (exception instanceof DisabledException) { - log.error("User is deactivated: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); - return; - } - if (exception instanceof LockedException) { - log.error("Account locked: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); - return; - } - if (exception instanceof OAuth2AuthenticationException oAuth2Exception) { - OAuth2Error error = oAuth2Exception.getError(); + // 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 = error.getErrorCode(); + String errorCode = null; + if ("Password must not be null".equals(errorCode)) { + errorCode = "userAlreadyExistsWeb"; + } - if ("Password must not be null".equals(error.getErrorCode())) { - 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); - } + 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); } - getRedirectStrategy().sendRedirect(request, response, redirectUrl); - return; + redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); + } else { + redirectUrl = buildFailureRedirectUrl(request, errorValue); } - log.error("Unhandled authentication exception", exception); - super.onAuthenticationFailure(request, response, exception); + response.sendRedirect(redirectUrl); } private String buildFailureRedirectUrl(HttpServletRequest request, String errorValue) { @@ -108,13 +95,10 @@ public class CustomOAuth2AuthenticationFailureHandler } private void clearRedirectCookie(HttpServletResponse response) { - ResponseCookie cookie = - ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "") - .path("/") - .sameSite("Lax") - .maxAge(0) - .build(); - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + // 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) { 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 e86857d33d..7e1a353593 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 @@ -8,22 +8,14 @@ import java.sql.SQLException; import java.util.Map; import java.util.Optional; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; -import org.springframework.security.authentication.LockedException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; -import org.springframework.security.web.savedrequest.SavedRequest; - +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.core.HttpHeaders; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; @@ -38,33 +30,42 @@ 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 extended Spring Security's +// SavedRequestAwareAuthenticationSuccessHandler, which has no Quarkus equivalent. Under quarkus-oidc +// there is no AuthenticationSuccessHandler concept; the post-login OAuth2 success flow must be +// rehosted, e.g. via a SecurityIdentityAugmentor plus a JAX-RS callback resource (or a +// jakarta.servlet endpoint) that performs the redirect/JWT-issuance below. The Spring +// Authentication/OAuth2User/OAuth2AuthenticationToken/SavedRequest types referenced here must be +// replaced with quarkus-oidc equivalents (io.quarkus.security.identity.SecurityIdentity, +// io.quarkus.oidc.IdToken/UserInfo, etc.). The user-mapping, eligibility, JWT and redirect logic is +// preserved verbatim below so it can be re-wired without re-deriving it. @Slf4j -@RequiredArgsConstructor -public class CustomOAuth2AuthenticationSuccessHandler - extends SavedRequestAwareAuthenticationSuccessHandler { +@ApplicationScoped +public class CustomOAuth2AuthenticationSuccessHandler { - private final LoginAttemptService loginAttemptService; - private final ApplicationProperties.Security.OAUTH2 oauth2Properties; - private final UserService userService; - private final JwtServiceInterface jwtService; - private final stirling.software.proprietary.service.UserLicenseSettingsService - licenseSettingsService; - private final ApplicationProperties applicationProperties; + @Inject LoginAttemptService loginAttemptService; + @Inject ApplicationProperties.Security.OAUTH2 oauth2Properties; + @Inject UserService userService; + @Inject JwtServiceInterface jwtService; + @Inject stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService; + @Inject ApplicationProperties applicationProperties; - @Override + // TODO: Migration required - the original signature took a Spring Security + // org.springframework.security.core.Authentication. Under quarkus-oidc this should receive an + // io.quarkus.security.identity.SecurityIdentity (or the OIDC IdToken/UserInfo). The "authentication" + // parameter is now typed as Object so the body still compiles; replace it with the real + // quarkus-oidc principal type and re-implement principal extraction below when wiring the + // success flow. @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( - HttpServletRequest request, HttpServletResponse response, Authentication authentication) + HttpServletRequest request, HttpServletResponse response, Object authentication) throws ServletException, IOException { - Object principal = authentication.getPrincipal(); String username = ""; - - if (principal instanceof OAuth2User oAuth2User) { - username = oAuth2User.getName(); - } else if (principal instanceof UserDetails detailsUser) { - username = detailsUser.getUsername(); - } + // TODO: Migration required - principal extraction relied on Spring Security OAuth2User / + // UserDetails. Derive the username from the quarkus-oidc principal (SecurityIdentity / + // IdToken claims) instead. + username = extractUsername(authentication); boolean userExists = userService.usernameExistsIgnoreCase(username); @@ -94,26 +95,40 @@ public class CustomOAuth2AuthenticationSuccessHandler // Get the saved request HttpSession session = request.getSession(false); String contextPath = request.getContextPath(); - SavedRequest savedRequest = + // TODO: Migration required - SavedRequest / "SPRING_SECURITY_SAVED_REQUEST" is a Spring + // Security web construct. Under quarkus-oidc the original target URL is preserved via the + // OIDC state/restore-path mechanism (quarkus.oidc.authentication.restore-path-after-redirect) + // rather than a session attribute. Re-implement saved-request resolution accordingly; the + // session attribute read below is left as a placeholder and will currently be null. + Object savedRequest = (session != null) - ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") + ? session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") : null; if (savedRequest != null - && !RequestUriUtils.isStaticResource(contextPath, savedRequest.getRedirectUrl())) { - // Redirect to the original destination - super.onAuthenticationSuccess(request, response, authentication); + && !RequestUriUtils.isStaticResource( + contextPath, getSavedRedirectUrl(savedRequest))) { + // TODO: Migration required - originally delegated to + // SavedRequestAwareAuthenticationSuccessHandler.onAuthenticationSuccess to redirect to + // the saved request. Reimplement the redirect to the saved/original destination here + // once the quarkus-oidc saved-request mechanism is in place. + redirectToSavedRequest(request, response, savedRequest); } else { if (loginAttemptService.isBlocked(username)) { if (session != null) { session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); } - throw new LockedException( + // TODO: Migration required - originally threw Spring Security's + // org.springframework.security.authentication.LockedException. Replace with the + // exception type the quarkus-oidc success flow expects (or a redirect to a locked + // page); throwing a plain IllegalStateException here as a placeholder. + throw new IllegalStateException( "Your account has been locked due to too many failed login attempts."); } if (userService.isUserDisabled(username)) { - getRedirectStrategy() - .sendRedirect(request, response, "/logout?userIsDisabled=true"); + // TODO: Migration required - originally used Spring's RedirectStrategy via + // getRedirectStrategy().sendRedirect(...). Using the servlet response directly. + response.sendRedirect(contextPath + "/logout?userIsDisabled=true"); return; } boolean isSsoUser = userService.isSsoAuthenticationTypeByUsername(username); @@ -135,13 +150,12 @@ public class CustomOAuth2AuthenticationSuccessHandler response.sendRedirect(contextPath + "/logout?maxUsersReached=true"); return; } - if (principal instanceof OAuth2User oAuth2User) { - // Extract SSO provider information from OAuth2User - String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID - // Extract provider from authentication - need to get it from the token/request - // For now, we'll extract it in a more generic way - String ssoProvider = extractProviderFromAuthentication(authentication); - + // TODO: Migration required - SSO provider/claims extraction relied on Spring + // Security's OAuth2User attributes and OAuth2AuthenticationToken. Re-derive the + // OIDC "sub" claim and the provider registration id from the quarkus-oidc principal. + String ssoProviderId = extractSubClaim(authentication); + String ssoProvider = extractProviderFromAuthentication(authentication); + if (ssoProviderId != null || ssoProvider != null) { userService.processSSOPostLogin( username, ssoProviderId, @@ -170,7 +184,11 @@ public class CustomOAuth2AuthenticationSuccessHandler desktopExpiryMinutes / 1440); } else { // Web: Use default expiry - jwt = jwtService.generateToken(authentication, claims); + // TODO: Migration required - JwtServiceInterface.generateToken(Authentication, + // claims) takes a Spring Security Authentication. Until JwtServiceInterface is + // migrated, issue the token by username (same identity) to avoid the Spring + // dependency here. + jwt = jwtService.generateToken(username, claims); log.debug("Issued WEB OAuth2 token for user '{}'", username); } @@ -189,16 +207,47 @@ public class CustomOAuth2AuthenticationSuccessHandler } } + // TODO: Migration required - placeholder for principal -> username extraction. Originally used + // Spring Security OAuth2User.getName() / UserDetails.getUsername(). Implement against the + // quarkus-oidc principal (SecurityIdentity.getPrincipal().getName() / IdToken claims). + private String extractUsername(Object authentication) { + throw new UnsupportedOperationException( + "TODO: Migration required - extract username from the quarkus-oidc principal"); + } + + // TODO: Migration required - placeholder for the OIDC "sub" claim. Originally + // oAuth2User.getAttribute("sub"). Read it from the quarkus-oidc IdToken/UserInfo. + private String extractSubClaim(Object authentication) { + throw new UnsupportedOperationException( + "TODO: Migration required - extract the 'sub' claim from the quarkus-oidc principal"); + } + + // TODO: Migration required - placeholder for the saved-request redirect URL. Originally + // SavedRequest.getRedirectUrl(). + private String getSavedRedirectUrl(Object savedRequest) { + throw new UnsupportedOperationException( + "TODO: Migration required - resolve the saved-request redirect URL under quarkus-oidc"); + } + + // TODO: Migration required - placeholder for delegating to the saved-request redirect. + // Originally SavedRequestAwareAuthenticationSuccessHandler.onAuthenticationSuccess(...). + private void redirectToSavedRequest( + HttpServletRequest request, HttpServletResponse response, Object savedRequest) + throws IOException { + throw new UnsupportedOperationException( + "TODO: Migration required - redirect to the saved/original destination under quarkus-oidc"); + } + /** * Extracts the OAuth2 provider registration ID from the authentication object. * * @param authentication The authentication object * @return The provider registration ID (e.g., "google", "github"), or null if not available */ - private String extractProviderFromAuthentication(Authentication authentication) { - if (authentication instanceof OAuth2AuthenticationToken oauth2Token) { - return oauth2Token.getAuthorizedClientRegistrationId(); - } + private String extractProviderFromAuthentication(Object authentication) { + // TODO: Migration required - originally cast to Spring Security's + // OAuth2AuthenticationToken and called getAuthorizedClientRegistrationId(). Derive the OIDC + // provider/tenant id from the quarkus-oidc principal instead. return null; } @@ -332,13 +381,14 @@ public class CustomOAuth2AuthenticationSuccessHandler } private void clearRedirectCookie(HttpServletResponse response) { - ResponseCookie cookie = - ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "") - .path("/") - .sameSite("Lax") - .maxAge(0) - .build(); - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + // TODO: Migration required - originally built the Set-Cookie value with Spring's + // org.springframework.http.ResponseCookie. Replaced with a manually built RFC 6265 + // Set-Cookie string to drop the Spring HTTP dependency. Consider switching to + // jakarta.servlet.http.Cookie / response.addCookie once SameSite handling is confirmed. + String cookie = + TauriOAuthUtils.SPA_REDIRECT_COOKIE + + "=; Path=/; Max-Age=0; SameSite=Lax"; + response.addHeader(HttpHeaders.SET_COOKIE, cookie); } /** 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 8710ed2641..14e3e4ba2c 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 @@ -1,28 +1,15 @@ package stirling.software.proprietary.security.oauth2; -import static org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE; import static stirling.software.common.util.ProviderUtils.validateProvider; import static stirling.software.common.util.ValidationUtils.isStringEmpty; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Optional; -import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.registration.ClientRegistrations; -import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; -import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; @@ -34,23 +21,39 @@ 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; +// TODO: Migration required - OAuth2 client/login is a Spring Security feature +// (org.springframework.security.oauth2.client.*) with NO direct Quarkus equivalent. In Quarkus the +// OIDC/OAuth2 client is configured declaratively via quarkus-oidc (quarkus.oidc.* and named tenants +// quarkus.oidc..* in application.properties), not by programmatically building a +// ClientRegistrationRepository. This class previously @Produces'd a ClientRegistrationRepository and +// a GrantedAuthoritiesMapper. Those producer beans have been removed because the Spring types they +// returned do not exist on the Quarkus classpath. The provider-resolution logic (reading +// ApplicationProperties and validating each provider via validateProvider/isStringEmpty) is +// preserved below so the migration to quarkus-oidc can reuse it to emit per-tenant config. The +// authorities mapping (database role lookup via UserService) must be re-implemented as a +// io.quarkus.security.identity.SecurityIdentityAugmentor. The original +// @ConditionalOnProperty(security.oauth2.enabled=true) guard maps to quarkus.oidc.enabled / +// build-profile gating; the bean is now always created and callers must consult +// applicationProperties.getSecurity().getOauth2().getEnabled() at runtime. @Slf4j -@Configuration -@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true") +@ApplicationScoped public class OAuth2Configuration { public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/"; private final ApplicationProperties applicationProperties; - @Lazy private final UserService userService; + // TODO: Migration required - @Lazy has no Quarkus equivalent; CDI proxies break the original + // lazy cycle. UserService is injected eagerly. If a genuine lazy/circular dependency exists, + // switch to jakarta.enterprise.inject.Instance and resolve at call time. + private final UserService userService; + + @Inject public OAuth2Configuration( - ApplicationProperties applicationProperties, @Lazy UserService userService) { + ApplicationProperties applicationProperties, UserService userService) { this.userService = userService; this.applicationProperties = applicationProperties; log.info( @@ -58,29 +61,40 @@ public class OAuth2Configuration { applicationProperties.getSecurity().getOauth2().getEnabled()); } - @Bean - public ClientRegistrationRepository clientRegistrationRepository() - throws NoProviderFoundException { - List registrations = new ArrayList<>(); - githubClientRegistration().ifPresent(registrations::add); - oidcClientRegistration().ifPresent(registrations::add); - googleClientRegistration().ifPresent(registrations::add); - keycloakClientRegistration().ifPresent(registrations::add); + /** + * Resolves the set of configured OAuth2 providers from ApplicationProperties and validates each + * one. The original implementation built a Spring Security ClientRegistrationRepository from + * these providers. + * + *

TODO: Migration required - the return type was + * org.springframework.security.oauth2.client.registration.ClientRegistrationRepository, produced + * via Spring @Bean. quarkus-oidc does not consume a ClientRegistrationRepository; instead each + * validated Provider below must be emitted as a named OIDC tenant config + * (quarkus.oidc.<name>.auth-server-url / client-id / credentials.secret / + * authentication.scopes / authentication.redirect-path, etc.). The validated providers are + * returned here so the wiring layer can register them; this method no longer produces a CDI bean. + */ + public List resolveValidatedProviders() throws NoProviderFoundException { + List providers = new ArrayList<>(); + githubProvider().ifPresent(providers::add); + oidcProvider().ifPresent(providers::add); + googleProvider().ifPresent(providers::add); + keycloakProvider().ifPresent(providers::add); - if (registrations.isEmpty()) { + if (providers.isEmpty()) { log.error("No OAuth2 provider registered - check your OAuth2 configuration"); throw new NoProviderFoundException("At least one OAuth2 provider must be configured."); } log.info( - "OAuth2 ClientRegistrationRepository created with {} provider(s): {}", - registrations.size(), - registrations.stream().map(ClientRegistration::getRegistrationId).toList()); + "OAuth2 providers resolved: {} provider(s): {}", + providers.size(), + providers.stream().map(Provider::getName).toList()); - return new InMemoryClientRegistrationRepository(registrations); + return providers; } - private Optional keycloakClientRegistration() { + private Optional keycloakProvider() { OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2(); if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) { @@ -97,20 +111,14 @@ public class OAuth2Configuration { keycloakClient.getScopes(), keycloakClient.getUseAsUsername()); - return validateProvider(keycloak) - ? Optional.of( - ClientRegistrations.fromIssuerLocation(keycloak.getIssuer()) - .registrationId(keycloak.getName()) - .clientId(keycloak.getClientId()) - .clientSecret(keycloak.getClientSecret()) - .scope(keycloak.getScopes()) - .userNameAttributeName(keycloak.getUseAsUsername().getName()) - .clientName(keycloak.getClientName()) - .build()) - : Optional.empty(); + // TODO: Migration required - the original built a ClientRegistration via + // ClientRegistrations.fromIssuerLocation(issuer) (OIDC discovery). Under quarkus-oidc this + // maps to quarkus.oidc..auth-server-url= with discovery enabled, plus + // client-id/credentials.secret/authentication.scopes/token-state username attribute. + return validateProvider(keycloak) ? Optional.of(keycloak) : Optional.empty(); } - private Optional googleClientRegistration() { + private Optional googleProvider() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) { @@ -126,24 +134,16 @@ public class OAuth2Configuration { googleClient.getScopes(), googleClient.getUseAsUsername()); - return validateProvider(google) - ? Optional.of( - ClientRegistration.withRegistrationId(google.getName()) - .clientId(google.getClientId()) - .clientSecret(google.getClientSecret()) - .scope(google.getScopes()) - .authorizationUri(google.getAuthorizationUri()) - .tokenUri(google.getTokenUri()) - .userInfoUri(google.getUserInfoUri()) - .userNameAttributeName(google.getUseAsUsername().getName()) - .clientName(google.getClientName()) - .redirectUri(REDIRECT_URI_PATH + google.getName()) - .authorizationGrantType(AUTHORIZATION_CODE) - .build()) - : Optional.empty(); + // TODO: Migration required - the original built a ClientRegistration with explicit + // authorizationUri/tokenUri/userInfoUri + redirectUri(REDIRECT_URI_PATH + name) + + // AUTHORIZATION_CODE grant. Under quarkus-oidc this maps to a named tenant + // quarkus.oidc.google.* (authorization-path/token-path/user-info-path or auth-server-url, + // authentication.redirect-path, application-type=web-app). Google's endpoints come from the + // GoogleProvider getters below. + return validateProvider(google) ? Optional.of(google) : Optional.empty(); } - private Optional githubClientRegistration() { + private Optional githubProvider() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); if (isOAuth2Disabled(oAuth2)) { @@ -170,26 +170,15 @@ public class OAuth2Configuration { githubClient.getScopes(), githubClient.getUseAsUsername()); - boolean isValid = validateProvider(github); - - return isValid - ? Optional.of( - ClientRegistration.withRegistrationId(github.getName()) - .clientId(github.getClientId()) - .clientSecret(github.getClientSecret()) - .scope(github.getScopes()) - .authorizationUri(github.getAuthorizationUri()) - .tokenUri(github.getTokenUri()) - .userInfoUri(github.getUserInfoUri()) - .userNameAttributeName(github.getUseAsUsername().getName()) - .clientName(github.getClientName()) - .redirectUri(REDIRECT_URI_PATH + github.getName()) - .authorizationGrantType(AUTHORIZATION_CODE) - .build()) - : Optional.empty(); + // TODO: Migration required - the original built a ClientRegistration with explicit + // authorizationUri/tokenUri/userInfoUri + redirectUri(REDIRECT_URI_PATH + name) + + // AUTHORIZATION_CODE grant. Map to quarkus.oidc.github.* tenant config (GitHub is a plain + // OAuth2, not OIDC, provider - quarkus-oidc may require provider=github or explicit + // *-path settings). + return validateProvider(github) ? Optional.of(github) : Optional.empty(); } - private Optional oidcClientRegistration() { + private Optional oidcProvider() { OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) { @@ -226,19 +215,12 @@ public class OAuth2Configuration { log.warn("OIDC OAuth2 provider validation failed - provider will not be registered"); } - return isValid - ? Optional.of( - ClientRegistrations.fromIssuerLocation(oauth.getIssuer()) - .registrationId(name) - .clientId(oidcProvider.getClientId()) - .clientSecret(oidcProvider.getClientSecret()) - .scope(oidcProvider.getScopes()) - .userNameAttributeName(oidcProvider.getUseAsUsername().getName()) - .clientName(clientName) - .redirectUri(REDIRECT_URI_PATH + name) - .authorizationGrantType(AUTHORIZATION_CODE) - .build()) - : Optional.empty(); + // TODO: Migration required - the original built a ClientRegistration via + // ClientRegistrations.fromIssuerLocation(issuer) (OIDC discovery) with + // redirectUri(REDIRECT_URI_PATH + name) + AUTHORIZATION_CODE grant. Map to a named tenant + // quarkus.oidc..auth-server-url= (discovery on), + // client-id/credentials.secret/authentication.scopes, authentication.redirect-path. + return isValid ? Optional.of(oidcProvider) : Optional.empty(); } private boolean isOAuth2Disabled(OAUTH2 oAuth2) { @@ -251,40 +233,26 @@ public class OAuth2Configuration { } /* - This following function is to grant Authorities to the OAUTH2 user from the values stored in the database. - This is required for the internal; 'hasRole()' function to give out the correct role. - */ + This following function granted Authorities to the OAUTH2 user from the values stored in the + database. This was required for the internal 'hasRole()' function to give out the correct role. - @Bean - @ConditionalOnProperty(value = "security.oauth2.enabled", havingValue = "true") - GrantedAuthoritiesMapper userAuthoritiesMapper() { - return (authorities) -> { - Set mappedAuthorities = new HashSet<>(); - authorities.forEach( - authority -> { - // Add existing OAUTH2 Authorities - mappedAuthorities.add(new SimpleGrantedAuthority(authority.getAuthority())); - // Add Authorities from database for existing user, if user is present. - if (authority instanceof OAuth2UserAuthority oAuth2Auth) { - String useAsUsername = - applicationProperties - .getSecurity() - .getOauth2() - .getUseAsUsername(); - Optional userOpt = - userService.findByUsernameIgnoreCase( - (String) oAuth2Auth.getAttributes().get(useAsUsername)); - userOpt.ifPresent( - user -> - mappedAuthorities.add( - new Authority( - userService - .findRole(user) - .getAuthority(), - user))); - } - }); - return mappedAuthorities; - }; - } + TODO: Migration required - this was a Spring Security + org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper @Bean (guarded by + @ConditionalOnProperty security.oauth2.enabled=true). Quarkus has no GrantedAuthoritiesMapper. + Re-implement as an io.quarkus.security.identity.SecurityIdentityAugmentor (a CDI + @ApplicationScoped bean): after quarkus-oidc authenticates, read the configured username claim + (applicationProperties.getSecurity().getOauth2().getUseAsUsername()) from the SecurityIdentity + attributes, load the User via userService.findByUsernameIgnoreCase(...), and add + userService.findRole(user).getAuthority() as a role on the augmented identity. The preserved + logic to port: + + String useAsUsername = applicationProperties.getSecurity().getOauth2().getUseAsUsername(); + Optional userOpt = + userService.findByUsernameIgnoreCase((String) attributes.get(useAsUsername)); + userOpt.ifPresent(user -> addRole(userService.findRole(user).getAuthority())); + + The original also re-added the existing OAuth2 authorities (SimpleGrantedAuthority) untouched; + under quarkus-oidc the token roles are already present on the SecurityIdentity, so only the + database-derived role needs to be added. + */ } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolver.java index 8af7bbeca3..38cb437314 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolver.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/TauriAuthorizationRequestResolver.java @@ -1,49 +1,48 @@ package stirling.software.proprietary.security.oauth2; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; - +import jakarta.enterprise.context.ApplicationScoped; import jakarta.servlet.http.HttpServletRequest; -public class TauriAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { +// TODO: Migration required - this class implemented Spring Security's +// org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver SPI, +// wrapping DefaultOAuth2AuthorizationRequestResolver (built from a +// ClientRegistrationRepository) to inject a custom "tauri:" state value before the +// authorization request is sent to the OAuth2 provider. quarkus-oidc has no equivalent +// pluggable AuthorizationRequestResolver SPI. The Spring glue +// (OAuth2AuthorizationRequestResolver, DefaultOAuth2AuthorizationRequestResolver, +// ClientRegistrationRepository, OAuth2AuthorizationRequest) has been removed. +// +// To re-wire on quarkus-oidc, the Tauri state customization below must be applied during +// the authorization-code redirect. Options: +// - Use quarkus.oidc.authentication.extra-params / state cookie customization, or +// - Implement a io.quarkus.oidc.runtime.OidcTenantConfigResolver / +// io.quarkus.oidc.TenantConfigResolver, or a jakarta.ws.rs.container.ContainerRequestFilter +// that intercepts the /oauth2/authorization redirect and rewrites the "state" param. +// The state-prefixing/nonce logic in customizeState(...) below is preserved and reusable. +@ApplicationScoped +public class TauriAuthorizationRequestResolver { private static final String TAURI_STATE_PREFIX = "tauri:"; - private final OAuth2AuthorizationRequestResolver delegate; - - public TauriAuthorizationRequestResolver( - ClientRegistrationRepository clientRegistrationRepository) { - this.delegate = - new DefaultOAuth2AuthorizationRequestResolver( - clientRegistrationRepository, "/oauth2/authorization"); - } - - @Override - public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { - return customize(request, delegate.resolve(request)); - } - - @Override - public OAuth2AuthorizationRequest resolve( - HttpServletRequest request, String clientRegistrationId) { - return customize(request, delegate.resolve(request, clientRegistrationId)); - } - - private OAuth2AuthorizationRequest customize( - HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) { - if (authorizationRequest == null) { - return null; + /** + * Preserved Tauri state-customization logic. Given the original OAuth2 "state" value and the + * incoming request, returns the state value that should be used for the authorization request. + * + *

When the request carries {@code tauri=1}, the state is prefixed with {@code "tauri:"} (and + * the optional {@code nonce} request parameter appended for CSRF protection), unless it has + * already been customized. Otherwise the original state is returned unchanged. + */ + public String customizeState(HttpServletRequest request, String state) { + if (request == null) { + return state; } String tauriParam = request.getParameter("tauri"); if (!"1".equals(tauriParam)) { - return authorizationRequest; + return state; } - String state = authorizationRequest.getState(); if (state == null || state.startsWith(TAURI_STATE_PREFIX)) { - return authorizationRequest; + return state; } // Extract nonce from request for CSRF protection @@ -53,6 +52,6 @@ public class TauriAuthorizationRequestResolver implements OAuth2AuthorizationReq customState = customState + ":" + nonce; } - return OAuth2AuthorizationRequest.from(authorizationRequest).state(customState).build(); + return customState; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java index be3cd9c9e0..fdf4a4e7d8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java @@ -4,29 +4,39 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import io.quarkus.panache.common.Parameters; import stirling.software.proprietary.security.model.InviteToken; -@Repository -public interface InviteTokenRepository extends JpaRepository { +@ApplicationScoped +public class InviteTokenRepository implements PanacheRepository { - Optional findByToken(String token); + public Optional findByToken(String token) { + return find("token", token).firstResultOptional(); + } - Optional findByEmail(String email); + public Optional findByEmail(String email) { + return find("email", email).firstResultOptional(); + } - List findByUsedFalseAndExpiresAtAfter(LocalDateTime now); + public List findByUsedFalseAndExpiresAtAfter(LocalDateTime now) { + return find("used = false and expiresAt > ?1", now).list(); + } - List findByCreatedBy(String createdBy); + public List findByCreatedBy(String createdBy) { + return find("createdBy", createdBy).list(); + } - @Modifying - @Query("DELETE FROM InviteToken it WHERE it.expiresAt < :now") - void deleteExpiredTokens(@Param("now") LocalDateTime now); + @Transactional + public void deleteExpiredTokens(LocalDateTime now) { + delete("expiresAt < :now", Parameters.with("now", now)); + } - @Query("SELECT COUNT(it) FROM InviteToken it WHERE it.used = false AND it.expiresAt > :now") - long countActiveInvites(@Param("now") LocalDateTime now); + public long countActiveInvites(LocalDateTime now) { + return count("used = false and expiresAt > :now", Parameters.with("now", now)); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java index ccf72be0a8..d25b2dca4e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamRepository.java @@ -3,21 +3,40 @@ package stirling.software.proprietary.security.repository; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.dto.TeamWithUserCountDTO; -@Repository -public interface TeamRepository extends JpaRepository { - Optional findByName(String name); +/** + * Quarkus Panache repository for {@link Team}. + * + *

Migrated from a Spring Data {@code JpaRepository}. The derived finders are + * reimplemented as Panache queries: {@code findByName} matches on the {@code name} field, and + * {@code existsByNameIgnoreCase} performs a case-insensitive count. The aggregate JPQL constructor + * query is preserved verbatim inside {@code findAllTeamsWithUserCount}. + */ +@ApplicationScoped +public class TeamRepository implements PanacheRepositoryBase { - @Query( - "SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) " - + "FROM Team t LEFT JOIN t.users u GROUP BY t.id, t.name") - List findAllTeamsWithUserCount(); + public Optional findByName(String name) { + return find("name", name).firstResultOptional(); + } - boolean existsByNameIgnoreCase(String name); + public List findAllTeamsWithUserCount() { + // The JPQL uses a constructor expression (new ...DTO(...)), so the result rows are already + // TeamWithUserCountDTO instances; run it through the EntityManager to keep that typing. + return getEntityManager() + .createQuery( + "SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) " + + "FROM Team t LEFT JOIN t.users u GROUP BY t.id, t.name", + TeamWithUserCountDTO.class) + .getResultList(); + } + + public boolean existsByNameIgnoreCase(String name) { + return count("LOWER(name) = LOWER(?1)", name) > 0; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/UserLicenseSettingsRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/UserLicenseSettingsRepository.java index 15b35e1bf4..2771c890c4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/UserLicenseSettingsRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/UserLicenseSettingsRepository.java @@ -2,20 +2,21 @@ package stirling.software.proprietary.security.repository; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; +import io.quarkus.hibernate.orm.panache.PanacheRepository; + +import jakarta.enterprise.context.ApplicationScoped; import stirling.software.proprietary.model.UserLicenseSettings; -@Repository -public interface UserLicenseSettingsRepository extends JpaRepository { +@ApplicationScoped +public class UserLicenseSettingsRepository implements PanacheRepository { /** * Finds the singleton UserLicenseSettings record. * * @return Optional containing the settings if they exist */ - default Optional findSettings() { - return findById(UserLicenseSettings.SINGLETON_ID); + public Optional findSettings() { + return findByIdOptional(UserLicenseSettings.SINGLETON_ID); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CertificateUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CertificateUtils.java index fff03fd4f5..8bd9b15651 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CertificateUtils.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CertificateUtils.java @@ -13,10 +13,14 @@ import org.bouncycastle.openssl.PEMParser; import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemReader; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.core.io.Resource; -@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") +import stirling.software.common.model.io.Resource; + +// TODO: Migration required - the original @ConditionalOnProperty(name = +// "security.saml2.enabled", havingValue = "true") gated this class on a runtime property. This is a +// utility holding only static methods (not a CDI bean), so the annotation was a no-op for +// instantiation and is dropped. Callers must enforce the security.saml2.enabled runtime toggle +// (e.g. via a runtime guard at the SAML SP entry point); see the SAML2 migration notes. public class CertificateUtils { public static X509Certificate readCertificate(Resource certificateResource) throws Exception { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticatedPrincipal.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticatedPrincipal.java index a39a390927..907b1873fd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticatedPrincipal.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticatedPrincipal.java @@ -4,23 +4,28 @@ import java.io.Serializable; import java.util.List; import java.util.Map; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; - -@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") +// TODO: Migration required - this record implemented Spring Security's +// org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal. +// There is NO Quarkus SAML extension; the SAML SP must be rehosted on a Jakarta @WebServlet +// using OpenSAML 5 (dnulnets/quarkus-saml pattern). The OpenSAML-derived principal data +// (name, attributes, nameId, sessionIndexes) is preserved below as a plain data carrier; +// re-wire it into the replacement SAML authentication flow / SecurityIdentity when that lands. +// +// TODO: Migration required - the original @ConditionalOnProperty(name = "security.saml2.enabled", +// havingValue = "true") guard was dropped because this is a plain data record, not a CDI bean. +// Gate construction of this principal on the "security.saml2.enabled" runtime config in the +// SAML authentication flow instead. public record CustomSaml2AuthenticatedPrincipal( String name, Map> attributes, String nameId, List sessionIndexes) - implements Saml2AuthenticatedPrincipal, Serializable { + implements Serializable { - @Override public String getName() { return this.name; } - @Override public Map> getAttributes() { return this.attributes; } 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 e6f5bc3887..45a5be8f30 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 @@ -2,13 +2,7 @@ package stirling.software.proprietary.security.saml2; import java.io.IOException; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.security.authentication.ProviderNotFoundException; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.saml2.core.Saml2Error; -import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; - +import jakarta.enterprise.context.ApplicationScoped; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -19,21 +13,40 @@ 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 -@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") -public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { +@ApplicationScoped +public class CustomSaml2AuthenticationFailureHandler { - @Override @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException exception) + HttpServletRequest request, HttpServletResponse response, Exception exception) throws IOException { log.error("Authentication error", exception); - if (exception instanceof Saml2AuthenticationException) { - Saml2Error error = ((Saml2AuthenticationException) exception).getSaml2Error(); + // 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()); @@ -41,13 +54,12 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica if (nonce != null) { redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", error.getErrorCode()); - getRedirectStrategy().sendRedirect(request, response, redirectUrl); + redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", samlErrorCode); + response.sendRedirect(redirectUrl); return; } - getRedirectStrategy() - .sendRedirect(request, response, "/login?errorOAuth=" + error.getErrorCode()); - } else if (exception instanceof ProviderNotFoundException) { + response.sendRedirect("/login?errorOAuth=" + samlErrorCode); + } else if (isProviderNotFound(exception)) { if (TauriSamlUtils.isTauriRelayState(request)) { String redirectUrl = TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); @@ -58,17 +70,26 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica redirectUrl = appendQueryParam( redirectUrl, "errorOAuth", "not_authentication_provider_found"); - getRedirectStrategy().sendRedirect(request, response, redirectUrl); + response.sendRedirect(redirectUrl); return; } - getRedirectStrategy() - .sendRedirect( - request, - response, - "/login?errorOAuth=not_authentication_provider_found"); + 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; 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 f790cbac36..f1375c457a 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 @@ -10,13 +10,7 @@ import java.sql.SQLException; import java.util.Map; import java.util.Optional; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; -import org.springframework.security.authentication.LockedException; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; -import org.springframework.security.web.savedrequest.SavedRequest; - +import jakarta.enterprise.context.ApplicationScoped; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; @@ -39,10 +33,25 @@ 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 -public class CustomSaml2AuthenticationSuccessHandler - extends SavedRequestAwareAuthenticationSuccessHandler { +@ApplicationScoped +public class CustomSaml2AuthenticationSuccessHandler { private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path"; private static final String DEFAULT_CALLBACK_PATH = "/auth/callback"; @@ -55,13 +64,14 @@ public class CustomSaml2AuthenticationSuccessHandler licenseSettingsService; private final ApplicationProperties applicationProperties; - @Override @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( - HttpServletRequest request, HttpServletResponse response, Authentication authentication) + HttpServletRequest request, HttpServletResponse response, Object authentication) throws ServletException, IOException { - Object principal = authentication.getPrincipal(); + // 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) { @@ -96,23 +106,22 @@ public class CustomSaml2AuthenticationSuccessHandler HttpSession session = request.getSession(false); String contextPath = request.getContextPath(); - SavedRequest savedRequest = - (session != null) - ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") - : null; + // 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 - && !RequestUriUtils.isStaticResource( - contextPath, savedRequest.getRedirectUrl())) { - log.debug( - "Valid saved request found, redirecting to original destination: {}", - savedRequest.getRedirectUrl()); - super.onAuthenticationSuccess(request, response, authentication); + 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: {}", @@ -123,7 +132,10 @@ public class CustomSaml2AuthenticationSuccessHandler if (session != null) { session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); } - throw new LockedException( + // 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."); } @@ -209,8 +221,12 @@ public class CustomSaml2AuthenticationSuccessHandler desktopExpiryMinutes, desktopExpiryMinutes / 1440); } else { - // Web: Use default expiry - jwt = jwtService.generateToken(authentication, claims); + // 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); } @@ -231,8 +247,10 @@ public class CustomSaml2AuthenticationSuccessHandler } } } else { - log.debug("Non-SAML2 principal detected, delegating to parent handler"); - super.onAuthenticationSuccess(request, response, authentication); + // 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"); } } @@ -398,12 +416,12 @@ public class CustomSaml2AuthenticationSuccessHandler } private void clearRedirectCookie(HttpServletResponse response) { - ResponseCookie cookie = - ResponseCookie.from(SPA_REDIRECT_COOKIE, "") - .path("/") - .sameSite("Lax") - .maxAge(0) - .build(); - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + // 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/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index f95e2cbc25..afe76df675 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -11,11 +11,8 @@ import org.opensaml.saml.saml2.core.Assertion; import org.opensaml.saml.saml2.core.Attribute; import org.opensaml.saml.saml2.core.AttributeStatement; import org.opensaml.saml.saml2.core.AuthnStatement; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseToken; -import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; + +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,11 +20,35 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.UserService; +// TODO: Migration required - there is NO Quarkus SAML extension. This class +// previously implemented Spring Security's +// org.springframework.security.core.convert.converter.Converter< +// OpenSaml5AuthenticationProvider.ResponseToken, Saml2Authentication> +// to plug into Spring's SAML2 OpenSaml5AuthenticationProvider pipeline. +// +// The OpenSAML 5 (org.opensaml.*) assertion/attribute extraction logic below is +// preserved unchanged. The Spring SAML2 glue has been removed: +// - org.springframework.security.saml2.provider.service.authentication +// .OpenSaml5AuthenticationProvider.ResponseToken (input token) +// - org.springframework.security.saml2.provider.service.authentication +// .Saml2Authentication (output Authentication) +// - org.springframework.security.core.authority.SimpleGrantedAuthority +// - org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +// (gated on security.saml2.enabled=true) +// +// The SAML SP must be rehosted on a Jakarta @WebServlet using OpenSAML 5 +// (dnulnets/quarkus-saml pattern). When that is in place: +// - convert(...) should accept the parsed OpenSAML Response/ResponseToken +// equivalent and produce a Quarkus SecurityIdentity (via a +// SecurityIdentityAugmentor / custom IdentityProvider) instead of a +// Saml2Authentication. +// - the "ROLE_USER"/user-role authority should map to SecurityIdentity roles. +// - re-gate this bean on security.saml2.enabled (runtime config guard) since +// @ConditionalOnProperty has no direct CDI equivalent here. @Slf4j -@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") +@ApplicationScoped @RequiredArgsConstructor -public class CustomSaml2ResponseAuthenticationConverter - implements Converter { +public class CustomSaml2ResponseAuthenticationConverter { private final UserService userService; @@ -59,9 +80,15 @@ public class CustomSaml2ResponseAuthenticationConverter return attributes; } - @Override - public Saml2Authentication convert(ResponseToken responseToken) { - Assertion assertion = responseToken.getResponse().getAssertions().get(0); + // TODO: Migration required - signature changed from + // convert(OpenSaml5AuthenticationProvider.ResponseToken) returning + // Saml2Authentication. Re-wire the input to the OpenSAML 5 Assertion obtained + // from the rehosted SAML SP and the output to a Quarkus SecurityIdentity. The + // OpenSAML attribute/identifier/session-index extraction logic below is the + // reusable part and is preserved. The returned CustomSaml2AuthenticatedPrincipal + // plus the resolved role (ROLE_USER or the user's role) carry the data the new + // SecurityIdentity must be built from. + public CustomSaml2AuthenticatedPrincipal convert(Assertion assertion) { Map> attributes = extractAttributes(assertion); // Debug log with actual values @@ -85,26 +112,24 @@ public class CustomSaml2ResponseAuthenticationConverter // Rest of your existing code... Optional userOpt = userService.findByUsernameIgnoreCase(userIdentifier); - SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_USER"); + // TODO: Migration required - resolved authority was previously wrapped in a + // Spring SimpleGrantedAuthority("ROLE_USER" / userService.findRole(user)). + // Map this role String onto a Quarkus SecurityIdentity role when wiring the + // SAML SP / SecurityIdentityAugmentor. + String authority = "ROLE_USER"; if (userOpt.isPresent()) { User user = userOpt.get(); - simpleGrantedAuthority = - new SimpleGrantedAuthority(userService.findRole(user).getAuthority()); + authority = userService.findRole(user).getAuthority(); } + log.debug("Resolved SAML authority: {}", authority); List sessionIndexes = new ArrayList<>(); for (AuthnStatement authnStatement : assertion.getAuthnStatements()) { sessionIndexes.add(authnStatement.getSessionIndex()); } - CustomSaml2AuthenticatedPrincipal principal = - new CustomSaml2AuthenticatedPrincipal( - userIdentifier, attributes, userIdentifier, sessionIndexes); - - return new Saml2Authentication( - principal, - responseToken.getToken().getSaml2Response(), - List.of(simpleGrantedAuthority)); + return new CustomSaml2AuthenticatedPrincipal( + userIdentifier, attributes, userIdentifier, sessionIndexes); } private boolean hasAttribute(Map> attributes, String name) { 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 index d0508151c5..4f9903ecf5 100644 --- 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 @@ -3,11 +3,6 @@ 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; @@ -15,27 +10,38 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.security.service.JwtServiceInterface; +// TODO: Migration required - this class implemented Spring Security's +// org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository +// over Saml2PostAuthenticationRequest / RelyingPartyRegistration(Repository). There is NO Quarkus +// SAML extension, so the Spring Security SAML glue (interface, Saml2PostAuthenticationRequest, +// RelyingPartyRegistration[Repository]) has been removed. The SAML SP must be rehosted on a +// Jakarta @WebServlet using OpenSAML 5 (see the dnulnets/quarkus-saml pattern); this repository +// should then store/restore the OpenSAML AuthnRequest state instead of the Spring types below. +// +// The reusable, provider-agnostic logic is preserved here: the JWT-backed token store keyed by +// RelayState, plus the serialize/deserialize of the SAML request fields into JWT claims. The +// methods that referenced the removed Spring types now operate on a plain Map +// claims representation and a String relayState. Re-wire these to the OpenSAML request model once +// the SP is rehosted. @Slf4j -public class JwtSaml2AuthenticationRequestRepository - implements Saml2AuthenticationRequestRepository { +public class JwtSaml2AuthenticationRequestRepository { 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) { + Map tokenStore, JwtServiceInterface jwtService) { this.tokenStore = tokenStore; this.jwtService = jwtService; - this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository; } - @Override + // TODO: Migration required - original signature was + // saveAuthenticationRequest(Saml2PostAuthenticationRequest authRequest, HttpServletRequest, + // HttpServletResponse). Pass the OpenSAML-derived claims + relayState once the SP is rehosted. public void saveAuthenticationRequest( - Saml2PostAuthenticationRequest authRequest, + Map claims, + String relayState, HttpServletRequest request, HttpServletResponse response) { if (!jwtService.isJwtEnabled()) { @@ -43,14 +49,12 @@ public class JwtSaml2AuthenticationRequestRepository return; } - if (authRequest == null) { + if (claims == 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); @@ -59,8 +63,9 @@ public class JwtSaml2AuthenticationRequestRepository log.debug("Saved SAMLRequest token with RelayState: {}", relayState); } - @Override - public Saml2PostAuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) { + // TODO: Migration required - original returned Saml2PostAuthenticationRequest. Map the returned + // claims back to the OpenSAML AuthnRequest model once the SP is rehosted. + public Map loadAuthenticationRequest(HttpServletRequest request) { String token = extractTokenFromStore(request); if (token == null) { @@ -72,10 +77,10 @@ public class JwtSaml2AuthenticationRequestRepository return deserializeSamlRequest(claims); } - @Override - public Saml2PostAuthenticationRequest removeAuthenticationRequest( + // TODO: Migration required - original returned Saml2PostAuthenticationRequest. + public Map removeAuthenticationRequest( HttpServletRequest request, HttpServletResponse response) { - Saml2PostAuthenticationRequest authRequest = loadAuthenticationRequest(request); + Map authRequest = loadAuthenticationRequest(request); String relayStateId = request.getParameter("RelayState"); if (relayStateId != null) { @@ -104,32 +109,32 @@ public class JwtSaml2AuthenticationRequestRepository return null; } - private Map serializeSamlRequest(Saml2PostAuthenticationRequest authRequest) { + // TODO: Migration required - original signature was + // serializeSamlRequest(Saml2PostAuthenticationRequest authRequest). Build this claims map from + // the OpenSAML AuthnRequest fields (id, relyingPartyRegistrationId / SP entity id, + // authenticationRequestUri / destination, samlRequest, relayState) once the SP is rehosted. + private Map serializeSamlRequest( + String id, + String relyingPartyRegistrationId, + String authenticationRequestUri, + String samlRequest, + String relayState) { 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()); + claims.put("id", id); + claims.put("relyingPartyRegistrationId", relyingPartyRegistrationId); + claims.put("authenticationRequestUri", authenticationRequestUri); + claims.put("samlRequest", samlRequest); + claims.put("relayState", relayState); 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(); + // TODO: Migration required - original returned Saml2PostAuthenticationRequest rebuilt via + // Saml2PostAuthenticationRequest.withRelyingPartyRegistration(...). Resolve the + // RelyingPartyRegistration equivalent (SP metadata) and rebuild the OpenSAML AuthnRequest from + // these claims once the SP is rehosted. For now the raw claims map is returned unchanged. + private Map deserializeSamlRequest(Map claims) { + return claims; } } 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 index f42fc562d8..5dd7906075 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 @@ -5,18 +5,8 @@ import java.util.Collections; import java.util.UUID; import org.opensaml.saml.saml2.core.AuthnRequest; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -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.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.authentication.OpenSaml5AuthenticationRequestResolver; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -24,18 +14,36 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.Security.SAML2; +import stirling.software.common.model.io.Resource; -@Configuration +// TODO: Migration required - there is NO Quarkus SAML extension. The original class was a Spring +// @Configuration that exposed two @Bean factory methods producing Spring Security SAML2 types +// (org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository +// and ...web.authentication.OpenSaml5AuthenticationRequestResolver). Those builder/glue types have +// no Quarkus equivalent, so the Spring Security SAML2 imports and @Configuration/@Bean wiring have +// been removed. The SAML Service Provider must be rehosted on a Jakarta @WebServlet driving OpenSAML +// 5 directly (see the dnulnets/quarkus-saml pattern). The OpenSAML 5 logic and the credential / +// metadata-location preparation below are PRESERVED so the rehost can reuse them. +// +// TODO: Migration required - @ConditionalOnProperty(value = "security.saml2.enabled", +// havingValue = "true") gated this whole class on a runtime property. Quarkus has no runtime +// @ConditionalOnProperty for beans; enforce the security.saml2.enabled runtime toggle at the SAML SP +// entry point (e.g. a guard in the @WebServlet, or skip SP registration when disabled). +@ApplicationScoped @Slf4j -@ConditionalOnProperty(value = "security.saml2.enabled", havingValue = "true") @RequiredArgsConstructor public class Saml2Configuration { private final ApplicationProperties applicationProperties; - @Bean - @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") - public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { + // TODO: Migration required - originally a @Bean returning Spring Security's + // RelyingPartyRegistrationRepository built via RelyingPartyRegistration.withRegistrationId(...) + // (InMemoryRelyingPartyRegistrationRepository, Saml2X509Credential, Saml2MessageBinding). Those + // Spring Security SAML2 builder types are unavailable in Quarkus. The credential loading + // (CertificateUtils via the common Resource shim) and the entityId / ACS / SLO location strings + // are kept verbatim so the OpenSAML-5-based SP rehost can consume them; the actual + // RelyingPartyRegistration assembly must be re-implemented against OpenSAML 5 metadata APIs. + public void prepareRelyingPartyRegistration() throws Exception { SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); log.info( @@ -46,13 +54,12 @@ public class Saml2Configuration { X509Certificate idpCert; try { Resource idpCertResource = samlConf.getIdpCert(); - log.info("Loading IdP certificate from: {}", idpCertResource.getDescription()); + log.info("Loading IdP certificate from: {}", idpCertResource.getFilename()); if (!idpCertResource.exists()) { - log.error( - "SAML2 IdP certificate not found at: {}", idpCertResource.getDescription()); + log.error("SAML2 IdP certificate not found at: {}", idpCertResource.getFilename()); throw new IllegalStateException( "SAML2 IdP certificate file does not exist: " - + idpCertResource.getDescription()); + + idpCertResource.getFilename()); } idpCert = CertificateUtils.readCertificate(idpCertResource); log.info( @@ -63,36 +70,33 @@ public class Saml2Configuration { throw new IllegalStateException("Failed to load SAML2 IdP certificate", e); } - Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); + // TODO: Migration required - was Saml2X509Credential.verification(idpCert). Re-create the + // IdP verification credential from idpCert using OpenSAML 5 (BasicX509Credential). // Load SP private key and certificate Resource privateKeyResource = samlConf.getPrivateKey(); Resource certificateResource = samlConf.getSpCert(); - log.info("Loading SP private key from: {}", privateKeyResource.getDescription()); + log.info("Loading SP private key from: {}", privateKeyResource.getFilename()); if (!privateKeyResource.exists()) { - log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription()); + log.error("SAML2 SP private key not found at: {}", privateKeyResource.getFilename()); throw new IllegalStateException( - "SAML2 SP private key file does not exist: " - + privateKeyResource.getDescription()); + "SAML2 SP private key file does not exist: " + privateKeyResource.getFilename()); } - log.info("Loading SP certificate from: {}", certificateResource.getDescription()); + log.info("Loading SP certificate from: {}", certificateResource.getFilename()); if (!certificateResource.exists()) { - log.error( - "SAML2 SP certificate not found at: {}", certificateResource.getDescription()); + log.error("SAML2 SP certificate not found at: {}", certificateResource.getFilename()); throw new IllegalStateException( - "SAML2 SP certificate file does not exist: " - + certificateResource.getDescription()); + "SAML2 SP certificate file does not exist: " + certificateResource.getFilename()); } - Saml2X509Credential signingCredential; + // TODO: Migration required - was new Saml2X509Credential(privateKey, cert, + // Saml2X509CredentialType.SIGNING). Build the SP signing credential from the key/cert below + // using OpenSAML 5 (BasicX509Credential) instead of Spring Security's Saml2X509Credential. try { - signingCredential = - new Saml2X509Credential( - CertificateUtils.readPrivateKey(privateKeyResource), - CertificateUtils.readCertificate(certificateResource), - Saml2X509CredentialType.SIGNING); + CertificateUtils.readPrivateKey(privateKeyResource); + CertificateUtils.readCertificate(certificateResource); log.info("Successfully loaded SP credentials"); } catch (Exception e) { log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e); @@ -102,7 +106,7 @@ public class Saml2Configuration { // Get backend URL from configuration (for SAML endpoints) String backendUrl = applicationProperties.getSystem().getBackendUrl(); if (backendUrl == null || backendUrl.isBlank()) { - backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution + backendUrl = "{baseUrl}"; // Fallback to auto-resolution at the SP entry point log.warn( "system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use."); } else { @@ -114,70 +118,52 @@ public class Saml2Configuration { String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}"; String sloResponseLocation = backendUrl + "/login"; - RelyingPartyRegistration rp = - RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) - .signingX509Credentials(c -> c.add(signingCredential)) - .entityId(entityId) - .singleLogoutServiceBinding(Saml2MessageBinding.POST) - .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation(sloResponseLocation) - .assertionConsumerServiceBinding(Saml2MessageBinding.POST) - .assertionConsumerServiceLocation(acsLocation) - .authnRequestsSigned(true) - .assertingPartyMetadata( - metadata -> - metadata.entityId(samlConf.getIdpIssuer()) - .verificationX509Credentials( - c -> c.add(verificationCredential)) - .singleSignOnServiceBinding( - Saml2MessageBinding.POST) - .singleSignOnServiceLocation( - samlConf.getIdpSingleLoginUrl()) - .singleLogoutServiceBinding( - Saml2MessageBinding.POST) - .singleLogoutServiceLocation( - samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation( - sloResponseLocation) - .wantAuthnRequestsSigned(true)) - .build(); - + // TODO: Migration required - the following Spring Security RelyingPartyRegistration was built + // here and stored in an InMemoryRelyingPartyRegistrationRepository. Re-implement against the + // OpenSAML-5-based SP using entityId / acsLocation / sloResponseLocation, the IdP issuer + // (samlConf.getIdpIssuer()), SSO/SLO bindings (POST) and locations + // (samlConf.getIdpSingleLoginUrl() / samlConf.getIdpSingleLogoutUrl()), authnRequestsSigned + // and wantAuthnRequestsSigned both true, and the signing/verification credentials above. log.info( - "SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}", + "SAML2 configuration prepared. Registration ID: {}, IdP: {}, entityId: {}, acs: {}, slo: {}", samlConf.getRegistrationId(), - samlConf.getIdpIssuer()); - return new InMemoryRelyingPartyRegistrationRepository(rp); + samlConf.getIdpIssuer(), + entityId, + acsLocation, + sloResponseLocation); } - @Bean - @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") - public OpenSaml5AuthenticationRequestResolver authenticationRequestResolver( - RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { - OpenSaml5AuthenticationRequestResolver resolver = - new OpenSaml5AuthenticationRequestResolver(relyingPartyRegistrationRepository); + // TODO: Migration required - originally a @Bean returning Spring Security's + // OpenSaml5AuthenticationRequestResolver, configured with a RelayState resolver and an + // AuthnRequest customizer. That resolver type is Spring-Security-specific and has no Quarkus + // equivalent. The RelayState logic (Tauri detection -> TauriSamlUtils.buildRelayState(nonce)) + // and the AuthnRequest customization (unique ARQ id + logging) are PRESERVED below as helper + // methods so the OpenSAML-5-based SP rehost can invoke them when building the AuthnRequest. - resolver.setRelayStateResolver( - request -> { - String tauriParam = request.getParameter("tauri"); - if (!"1".equals(tauriParam)) { - return null; - } - String nonce = request.getParameter("nonce"); - return TauriSamlUtils.buildRelayState(nonce); - }); + /** + * Resolves the SAML RelayState for a request, preserving the original Tauri-aware behavior: + * returns null unless the {@code tauri} parameter equals "1", otherwise builds a relay state + * from the {@code nonce} parameter. + */ + static String resolveRelayState(HttpServletRequest request) { + String tauriParam = request.getParameter("tauri"); + if (!"1".equals(tauriParam)) { + return null; + } + String nonce = request.getParameter("nonce"); + return TauriSamlUtils.buildRelayState(nonce); + } - resolver.setAuthnRequestCustomizer( - customizer -> { - HttpServletRequest request = customizer.getRequest(); - AuthnRequest authnRequest = customizer.getAuthnRequest(); + /** + * Applies the original AuthnRequest customization: assigns a unique request ID and logs the + * request/HTTP details. Invoke this from the OpenSAML-5-based SP after building the AuthnRequest. + */ + static void customizeAuthnRequest(HttpServletRequest request, AuthnRequest authnRequest) { + // Generate a unique AuthnRequest ID for each SAML request + authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1)); - // Generate a unique AuthnRequest ID for each SAML request - authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1)); - - logAuthnRequestDetails(authnRequest); - logHttpRequestDetails(request); - }); - return resolver; + logAuthnRequestDetails(authnRequest); + logHttpRequestDetails(request); } private static void logAuthnRequestDetails(AuthnRequest authnRequest) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/AppUpdateAuthService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/AppUpdateAuthService.java index c60c5e2d95..1ec5669ca2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/AppUpdateAuthService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/AppUpdateAuthService.java @@ -2,9 +2,9 @@ package stirling.software.proprietary.security.service; import java.util.Optional; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.security.identity.SecurityIdentity; import lombok.RequiredArgsConstructor; @@ -13,7 +13,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -@Service +@ApplicationScoped @RequiredArgsConstructor class AppUpdateAuthService implements ShowAdminInterface { @@ -21,6 +21,12 @@ class AppUpdateAuthService implements ShowAdminInterface { private final ApplicationProperties applicationProperties; + // TODO: Migration required - SecurityIdentity is request-scoped; injecting it into an + // @ApplicationScoped bean relies on Quarkus' client proxy resolving the current request's + // identity. Verify this resolves correctly when invoked outside an active HTTP request + // (e.g. scheduled/background contexts), where the identity may be anonymous/null. + private final SecurityIdentity securityIdentity; + @Override public boolean getShowUpdateOnlyAdmins() { boolean showUpdate = applicationProperties.getSystem().isShowUpdate(); @@ -28,14 +34,16 @@ class AppUpdateAuthService implements ShowAdminInterface { return showUpdate; } boolean showUpdateOnlyAdmin = applicationProperties.getSystem().isShowUpdateOnlyAdmin(); - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { + if (securityIdentity == null || securityIdentity.isAnonymous()) { return !showUpdateOnlyAdmin; } - if ("anonymousUser".equalsIgnoreCase(authentication.getName())) { + String name = securityIdentity.getPrincipal() != null + ? securityIdentity.getPrincipal().getName() + : null; + if (name == null || "anonymousUser".equalsIgnoreCase(name)) { return !showUpdateOnlyAdmin; } - Optional user = userRepository.findByUsername(authentication.getName()); + Optional user = userRepository.findByUsername(name); if (user.isPresent() && showUpdateOnlyAdmin) { return "ROLE_ADMIN".equals(user.get().getRolesAsString()); } 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 c1057c7e36..39f62f86f8 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 @@ -6,16 +6,8 @@ import java.util.Optional; import java.util.Set; import java.util.TreeSet; -import org.springframework.security.authentication.LockedException; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.oidc.OidcIdToken; -import org.springframework.security.oauth2.core.oidc.OidcUserInfo; -import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; @@ -23,10 +15,26 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.UsernameAttribute; import stirling.software.proprietary.security.model.User; +// TODO: Migration required - quarkus-oidc has no equivalent of Spring's +// OAuth2UserService / OidcUserService delegate. Under quarkus-oidc the +// OIDC flow is handled by the extension (quarkus.oidc.* config); per-login user mapping and the +// "useAsUsername" claim selection should be re-implemented in a +// io.quarkus.security.identity.SecurityIdentityAugmentor (inject the @io.quarkus.oidc.IdToken +// JsonWebToken / OidcSession), and the blocked-account / hasPassword checks below should run there +// before the SecurityIdentity is finalized. The claim-dump diagnostics (logClaimDump, +// appendClaims, suggestUsernameClaims) and the username-resolution + LockedException logic are +// preserved unchanged so they can be reused by the augmentor. +// The previous Spring types map roughly to: +// OidcUserRequest.getClientRegistration().getRegistrationId() -> the OIDC tenant id +// OidcUserService.loadUser(...) -> handled by quarkus-oidc (UserInfo via +// quarkus.oidc.authentication.user-info-required=true) +// OidcUser.getAttribute(key)/getSubject()/getIdToken()/getUserInfo() -> JsonWebToken claims + +// io.quarkus.oidc.UserInfo +// DefaultOidcUser(...) -> the augmented SecurityIdentity +// OAuth2AuthenticationException -> io.quarkus.security.AuthenticationFailedException @Slf4j -public class CustomOAuth2UserService implements OAuth2UserService { - - private final OidcUserService delegate = new OidcUserService(); +@ApplicationScoped +public class CustomOAuth2UserService { private final UserService userService; @@ -34,6 +42,7 @@ public class CustomOAuth2UserService implements OAuth2UserServiceTODO: Migration required - this method previously implemented Spring's {@code + * OAuth2UserService.loadUser}. Under quarkus-oidc there is no + * user-request object handed to application code; instead call this logic from a {@code + * SecurityIdentityAugmentor} once quarkus-oidc has produced the {@code SecurityIdentity}. + * Provide the registration/tenant id, the merged claim map and the ID-token claim map from the + * augmentor's {@code AuthenticationRequestContext} / injected {@code JsonWebToken}. + * + * @param registrationId the OIDC tenant/registration id + * @param subject the standard OIDC {@code sub} claim + * @param attributes the merged claim/attribute map (ID token + UserInfo) + * @param idTokenClaims the raw ID-token claims (may be null on unexpected failures) + * @return the resolved username claim key configured via {@code security.oauth2.useAsUsername} + */ + public String resolveUser( + String registrationId, + String subject, + Map attributes, + Map idTokenClaims) { boolean debugLogging = Boolean.TRUE.equals(oauth2Properties.getDebugLogging()); // Resolved inside the try so a bad/null useAsUsername (IllegalArgumentException from - // valueOf, or NPE on toUpperCase) is caught and wrapped as OAuth2AuthenticationException - // by the existing handlers below, matching the pre-debugLogging behaviour. + // valueOf, or NPE on toUpperCase) is caught and wrapped, matching the pre-debugLogging + // behaviour. String usernameAttributeKey = null; try { usernameAttributeKey = UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase()) .getName(); - OidcUser user = delegate.loadUser(userRequest); if (debugLogging) { logClaimDump( "OAuth2/OIDC login claims received", registrationId, usernameAttributeKey, - user.getIdToken(), - user.getUserInfo(), - user.getAttributes(), + idTokenClaims, + attributes, + attributes, false); } // Extract SSO provider information - String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim - String username = user.getAttribute(usernameAttributeKey); + String ssoProviderId = subject; // Standard OIDC 'sub' claim + String username = + attributes == null ? null : (String) attributes.get(usernameAttributeKey); log.debug( "OAuth2 login - Provider: {}, ProviderId: {}, Username: {}", @@ -84,7 +111,10 @@ public class CustomOAuth2UserService implements OAuth2UserService idTokenClaims, + Map userInfoClaims, Map mergedAttributes, boolean failure) { StringBuilder sb = new StringBuilder(); @@ -167,23 +194,18 @@ public class CustomOAuth2UserService implements OAuth2UserService idClaims = idToken.getClaims(); - sb.append("\n-- ID token claims (") - .append(idClaims == null ? 0 : idClaims.size()) - .append(") --\n"); - appendClaims(sb, idClaims); - sb.append("ID token issued at : ").append(idToken.getIssuedAt()).append('\n'); - sb.append("ID token expires at: ").append(idToken.getExpiresAt()).append('\n'); + if (idTokenClaims != null) { + sb.append("\n-- ID token claims (").append(idTokenClaims.size()).append(") --\n"); + appendClaims(sb, idTokenClaims); } else { sb.append("\n-- ID token: --\n"); } - if (userInfo != null && userInfo.getClaims() != null) { + if (userInfoClaims != null) { sb.append("\n-- UserInfo endpoint claims (") - .append(userInfo.getClaims().size()) + .append(userInfoClaims.size()) .append(") --\n"); - appendClaims(sb, userInfo.getClaims()); + appendClaims(sb, userInfoClaims); } else { sb.append("\n-- UserInfo endpoint claims: none returned --\n"); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java index 99e87a82b8..a7d92b8390 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java @@ -2,11 +2,8 @@ package stirling.software.proprietary.security.service; import java.util.Locale; -import org.springframework.security.authentication.LockedException; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -15,9 +12,19 @@ import stirling.software.proprietary.security.database.repository.UserRepository import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; -@Service +// TODO: Migration required - this class implemented +// org.springframework.security.core.userdetails.UserDetailsService and returned a +// org.springframework.security.core.userdetails.UserDetails. Quarkus has no UserDetailsService +// contract; the user-loading logic below should be invoked from a Quarkus IdentityProvider +// (or SecurityIdentityAugmentor) that turns the returned User into a SecurityIdentity. The method +// is retained as a plain service returning the User entity. Former Spring exceptions are mapped to +// plain runtime exceptions: UsernameNotFoundException -> IllegalArgumentException (user not found), +// LockedException -> IllegalStateException (account locked); the IdentityProvider should translate +// these into the appropriate io.quarkus.security.AuthenticationFailedException / unauthorized +// responses. +@ApplicationScoped @RequiredArgsConstructor -public class CustomUserDetailsService implements UserDetailsService { +public class CustomUserDetailsService { private final UserRepository userRepository; @@ -25,18 +32,18 @@ public class CustomUserDetailsService implements UserDetailsService { private final ApplicationProperties.Security securityProperties; - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + @Transactional + public User loadUserByUsername(String username) { User user = userRepository .findByUsername(username) .orElseThrow( () -> - new UsernameNotFoundException( + new IllegalArgumentException( "No user found with username: " + username)); if (loginAttemptService.isBlocked(username)) { - throw new LockedException( + throw new IllegalStateException( "Your account has been locked due to too many failed login attempts."); } @@ -58,7 +65,7 @@ public class CustomUserDetailsService implements UserDetailsService { authTypeStr = detectedType.name(); // Update the user record to set the detected authentication type user.setAuthenticationType(detectedType); - userRepository.save(user); + userRepository.persist(user); } AuthenticationType userAuthenticationType = 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 f120e4e42e..32135f23e5 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 @@ -28,10 +28,9 @@ import java.util.stream.Collectors; import javax.sql.DataSource; import org.apache.commons.lang3.tuple.Pair; -import org.springframework.context.annotation.Profile; -import org.springframework.jdbc.datasource.init.CannotReadScriptException; -import org.springframework.jdbc.datasource.init.ScriptException; -import org.springframework.stereotype.Service; + +import io.quarkus.arc.profile.UnlessBuildProfile; +import jakarta.enterprise.context.ApplicationScoped; import lombok.extern.slf4j.Slf4j; @@ -42,8 +41,8 @@ import stirling.software.proprietary.security.database.DatabaseNotificationServi import stirling.software.proprietary.security.model.exception.BackupNotFoundException; @Slf4j -@Service -@Profile("!saas") +@ApplicationScoped +@UnlessBuildProfile("saas") public class DatabaseService implements DatabaseServiceInterface { public static final String BACKUP_PREFIX = "backup_"; @@ -289,15 +288,12 @@ public class DatabaseService implements DatabaseServiceInterface { + insertOutputFilePath.getFileName() + " Message: " + e.getMessage()); - } catch (CannotReadScriptException e) { - log.error("Error during database export: File {} not found", insertOutputFilePath); - backupNotificationService.notifyBackupsFailure( - "Database backup export failed", - "Error during database export: File " - + insertOutputFilePath.getFileName() - + " not found. Message: " - + e.getMessage()); } + // TODO: Migration required - dropped catch for + // org.springframework.jdbc.datasource.init.CannotReadScriptException (Spring JDBC). Raw + // JDBC PreparedStatement.execute() only throws SQLException; the missing-file case is + // now reported via the SQLException branch above. Restore equivalent handling if a + // Quarkus/Hibernate script runner is introduced later. log.info("Database export completed: {}", insertOutputFilePath); verifyBackup(insertOutputFilePath); @@ -486,9 +482,12 @@ public class DatabaseService implements DatabaseServiceInterface { stmt.execute(); } catch (SQLException e) { log.error("Error during database import: {}", e.getMessage(), e); - } catch (ScriptException e) { - log.error("Error: File {} not found", scriptPath.toString(), e); } + // TODO: Migration required - dropped catch for + // org.springframework.jdbc.datasource.init.ScriptException (Spring JDBC). Raw JDBC + // PreparedStatement.execute() only throws SQLException; script errors are now logged via + // the SQLException branch above. Restore equivalent handling if a Quarkus/Hibernate + // script runner is introduced later. } log.info("Database import completed: {}", scriptPath); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 5c1cf75450..4816d62bf7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -1,19 +1,24 @@ package stirling.software.proprietary.security.service; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +// TODO: Migration required - org.springframework.mail.javamail.* is Spring's mail abstraction (NOT +// Spring DI) and Quarkus has no drop-in equivalent. quarkus-mailer (io.quarkus.mailer.Mailer / +// ReactiveMailer) exposes a different API and would require migrating the collaborator MailConfig +// (which still produces a JavaMailSender) together with this service. The JavaMailSender / +// MimeMessage / MimeMessageHelper logic is kept unchanged until that joint migration; only the DI +// glue has been converted. When migrating, swap to quarkus.mailer.* config + io.quarkus.mailer.Mail. import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import jakarta.mail.util.ByteArrayDataSource; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.MultipartFile; import stirling.software.proprietary.security.model.api.Email; /** @@ -21,15 +26,25 @@ import stirling.software.proprietary.security.model.api.Email; * JavaMailSender to send the email and is designed to handle both the message content and file * attachments. */ +// TODO: Migration required - the original class was guarded by +// @ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false). +// Quarkus has no @ConditionalOnProperty. mail.enabled is a runtime property +// (ApplicationProperties.Mail#isEnabled) rather than a build-time flag, so the bean is always +// produced and callers must guard on applicationProperties.getMail().isEnabled() at call time +// (matching the decision already made in the MailConfig producer). @Slf4j -@Service -@RequiredArgsConstructor -@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false) +@ApplicationScoped public class EmailService { private final JavaMailSender mailSender; private final ApplicationProperties applicationProperties; + @Inject + public EmailService(JavaMailSender mailSender, ApplicationProperties applicationProperties) { + this.mailSender = mailSender; + this.applicationProperties = applicationProperties; + } + /** * Sends an email with an attachment asynchronously. This method is annotated with @Async, which * means it will be executed asynchronously. @@ -37,7 +52,10 @@ public class EmailService { * @param email The Email object containing the recipient, subject, body, and file attachment. * @throws MessagingException If there is an issue with creating or sending the email. */ - @Async + // TODO: Migration required - Spring's @Async ran this on a managed executor. Quarkus has no + // @Async; the method now runs synchronously on the caller's thread. To restore async behaviour + // wrap the body in io.smallrye.mutiny.Uni or submit to a jakarta.enterprise.concurrent + // ManagedExecutor (would change the void signature, so deferred). public void sendEmailWithAttachment(Email email) throws MessagingException { MultipartFile file = email.getFileInput(); // 1) Validate recipient email address @@ -69,8 +87,18 @@ public class EmailService { true); // The "true" here indicates that the body contains HTML content. helper.setFrom(mailProperties.getFrom()); - // Adds the attachment to the email - helper.addAttachment(file.getOriginalFilename(), file); + // Adds the attachment to the email. The common MultipartFile shim is not a Spring + // InputStreamSource, so wrap its bytes in a jakarta.mail DataSource (pure Jakarta Mail API). + try { + String contentType = file.getContentType(); + ByteArrayDataSource dataSource = + new ByteArrayDataSource( + file.getBytes(), + contentType != null ? contentType : "application/octet-stream"); + helper.addAttachment(file.getOriginalFilename(), dataSource); + } catch (java.io.IOException e) { + throw new MessagingException("Failed to read attachment content", e); + } // Sends the email via the configured mail sender mailSender.send(message); @@ -89,7 +117,7 @@ public class EmailService { * @param body message body * @throws MessagingException if sending fails or address is invalid */ - @Async + // TODO: Migration required - @Async dropped (no Quarkus equivalent); now runs synchronously. public void sendSimpleMail(String to, String subject, String body) throws MessagingException { if (to == null || to.trim().isEmpty()) { throw new MessagingException("Invalid Addresses"); @@ -119,7 +147,7 @@ public class EmailService { * @param isHtml Whether the body contains HTML content * @throws MessagingException If there is an issue with creating or sending the email. */ - @Async + // TODO: Migration required - @Async dropped (no Quarkus equivalent); now runs synchronously. public void sendPlainEmail(String to, String subject, String body, boolean isHtml) throws MessagingException { // Validate recipient email address @@ -154,7 +182,7 @@ public class EmailService { * @param loginUrl The URL to the login page * @throws MessagingException If there is an issue with creating or sending the email. */ - @Async + // TODO: Migration required - @Async dropped (no Quarkus equivalent); now runs synchronously. public void sendInviteEmail( String to, String username, String temporaryPassword, String loginUrl) throws MessagingException { @@ -214,7 +242,7 @@ public class EmailService { * @param expiresAt The expiration timestamp * @throws MessagingException If there is an issue with creating or sending the email. */ - @Async + // TODO: Migration required - @Async dropped (no Quarkus equivalent); now runs synchronously. public void sendInviteLinkEmail(String to, String inviteUrl, String expiresAt) throws MessagingException { String subject = "You've been invited to Stirling PDF"; @@ -260,7 +288,7 @@ public class EmailService { sendPlainEmail(to, subject, body, true); } - @Async + // TODO: Migration required - @Async dropped (no Quarkus equivalent); now runs synchronously. public void sendPasswordChangedNotification( String to, String username, String newPassword, String loginUrl) throws MessagingException { 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 2c838ee122..14ef400ae9 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 @@ -13,12 +13,14 @@ 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; +// TODO: Migration required - JwtServiceInterface still declares generateToken(Authentication, ...) +// using org.springframework.security.core.Authentication. The interface (a separate file) must be +// migrated too; once it switches to io.quarkus.security.identity.SecurityIdentity, update the +// implementation below and drop these Spring Security imports. Kept here to satisfy the interface +// contract without changing a collaborator file. 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.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; @@ -27,6 +29,9 @@ import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.SignatureException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; @@ -41,7 +46,7 @@ import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; @Slf4j -@Service +@ApplicationScoped public class JwtService implements JwtServiceInterface { private final ObjectMapper objectMapper; @@ -49,10 +54,10 @@ public class JwtService implements JwtServiceInterface { private final boolean v2Enabled; private final ApplicationProperties.Security securityProperties; - @Autowired + @Inject public JwtService( ObjectMapper objectMapper, - @Qualifier("v2Enabled") boolean v2Enabled, + @Named("v2Enabled") boolean v2Enabled, KeyPersistenceServiceInterface keyPersistenceService, ApplicationProperties applicationProperties) { this.objectMapper = objectMapper; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java index 6b6c62f8e8..7b9f9b5cb4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.security.service; import java.util.Map; -import org.springframework.security.core.Authentication; +import io.quarkus.security.identity.SecurityIdentity; import jakarta.servlet.http.HttpServletRequest; @@ -11,10 +11,13 @@ public interface JwtServiceInterface { /** * Generate a JWT token for the authenticated user * - * @param authentication Spring Security authentication object + * @param identity Quarkus security identity for the authenticated user * @return JWT token as a string */ - String generateToken(Authentication authentication, Map claims); + // TODO: Migration required - the implementation must derive the username/claims from + // SecurityIdentity (getPrincipal()/getRoles()) instead of the former Spring + // Authentication.getName()/getAuthorities(). + String generateToken(SecurityIdentity identity, Map claims); /** * Generate a JWT token for a specific username 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 aec455a929..ad13e6e991 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 @@ -6,15 +6,14 @@ 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 io.quarkus.runtime.StartupEvent; +import io.quarkus.scheduler.Scheduled; -import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; import lombok.extern.slf4j.Slf4j; @@ -23,14 +22,17 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.model.JwtVerificationKey; @Slf4j -@Service -@ConditionalOnBooleanProperty("v2") +@ApplicationScoped +// TODO: Migration required - Spring @ConditionalOnBooleanProperty("v2") dropped; the "v2" +// runtime toggle has no direct CDI equivalent. Guard activation via a runtime check or +// @io.quarkus.arc.lookup.LookupIfProperty / quarkus.scheduler config if this bean should be +// conditionally enabled. public class KeyPairCleanupService { private final KeyPersistenceService keyPersistenceService; private final ApplicationProperties.Security.Jwt jwtProperties; - @Autowired + @Inject public KeyPairCleanupService( KeyPersistenceService keyPersistenceService, ApplicationProperties applicationProperties) { @@ -38,9 +40,14 @@ public class KeyPairCleanupService { this.jwtProperties = applicationProperties.getSecurity().getJwt(); } + // Run cleanup once at application startup (replaces Spring @PostConstruct on the scheduled + // method). + void onStart(@Observes StartupEvent event) { + cleanup(); + } + @Transactional - @PostConstruct - @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + @Scheduled(every = "24h") public void cleanup() { if (!jwtProperties.isEnableKeyCleanup() || !keyPersistenceService.isKeystoreEnabled()) { return; 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 d0c9f879be..edafa61252 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 @@ -22,15 +22,12 @@ 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 com.github.benmanes.caffeine.cache.Caffeine; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; import lombok.extern.slf4j.Slf4j; @@ -39,24 +36,28 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.model.JwtVerificationKey; @Slf4j -@Service +@ApplicationScoped public class KeyPersistenceService implements KeyPersistenceServiceInterface { public static final String KEY_SUFFIX = ".key"; public static final String PUB_KEY_SUFFIX = ".pub"; private final ApplicationProperties.Security.Jwt jwtProperties; - private final CacheManager cacheManager; - private final Cache verifyingKeyCache; + + // TODO: Migration required - Spring's CacheManager/Cache("verifyingKeys") and + // org.springframework.cache.caffeine.CaffeineCache (which exposed getNativeCache()) have no + // direct Quarkus-cache equivalent. getKeysEligibleForCleanup() needs to enumerate ALL cached + // values, which io.quarkus.cache.Cache does not support. Replaced with a directly-managed + // Caffeine cache so all original logic (put/get/evict + full iteration) is preserved. If a + // shared/named Quarkus cache is desired, rebind via @io.quarkus.cache.CacheResult and friends. + private final com.github.benmanes.caffeine.cache.Cache verifyingKeyCache = + Caffeine.newBuilder().build(); private volatile JwtVerificationKey activeKey; - @Autowired - public KeyPersistenceService( - ApplicationProperties applicationProperties, CacheManager cacheManager) { + @Inject + public KeyPersistenceService(ApplicationProperties applicationProperties) { this.jwtProperties = applicationProperties.getSecurity().getJwt(); - this.cacheManager = cacheManager; - this.verifyingKeyCache = cacheManager.getCache("verifyingKeys"); } @PostConstruct @@ -179,6 +180,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { } } + // TODO: Migration required - jakarta.transaction.@Transactional is a CDI interceptor binding + // and (like Spring's @Transactional) is not applied to private methods via the bean proxy. This + // method performs no JPA writes (only disk I/O + in-memory cache), so the annotation was a no-op + // under Spring too; kept for parity. If transactional semantics are ever needed, make the method + // public or extract it to a separate bean. @Transactional private JwtVerificationKey generateAndStoreKeypair() { JwtVerificationKey verifyingKey = null; @@ -215,7 +221,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { try { JwtVerificationKey verifyingKey = - verifyingKeyCache.get(keyId, JwtVerificationKey.class); + (JwtVerificationKey) verifyingKeyCache.getIfPresent(keyId); if (verifyingKey == null) { log.warn("No signing key found in database for keyId: {}", keyId); @@ -243,26 +249,22 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { } @Override - @CacheEvict( - value = {"verifyingKeys"}, - key = "#keyId", - condition = "#root.target.isKeystoreEnabled()") public void removeKey(String keyId) { - verifyingKeyCache.evict(keyId); + // Spring's @CacheEvict(condition="#root.target.isKeystoreEnabled()") is replicated here as + // an explicit guard since the cache is now managed directly. + if (isKeystoreEnabled()) { + verifyingKeyCache.invalidate(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()); + verifyingKeyCache.estimatedSize(), + verifyingKeyCache.asMap().size()); - return nativeCache.asMap().values().stream() + return verifyingKeyCache.asMap().values().stream() .filter(value -> value instanceof JwtVerificationKey) .map(value -> (JwtVerificationKey) value) .filter( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/LoginAttemptService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/LoginAttemptService.java index d715771b59..b5834aa8bf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/LoginAttemptService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/LoginAttemptService.java @@ -7,9 +7,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import org.springframework.stereotype.Service; - import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,7 +16,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.model.AttemptCounter; -@Service +@ApplicationScoped @Slf4j @RequiredArgsConstructor public class LoginAttemptService { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java index f8a558ecff..b22bcdddd4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java @@ -5,8 +5,8 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,7 +21,7 @@ import stirling.software.proprietary.security.model.User; *

This service reads and writes MFA-related settings such as secrets, enablement flags, and * last-used TOTP steps. */ -@Service +@ApplicationScoped @RequiredArgsConstructor @Slf4j public class MfaService { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java index bb4e454290..ed2cf018d7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java @@ -5,10 +5,9 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; +import io.quarkus.scheduler.Scheduled; import lombok.extern.slf4j.Slf4j; import stirling.software.common.constants.JwtConstants; @@ -20,13 +19,12 @@ import stirling.software.common.model.ApplicationProperties; *

Prevents abuse of expired tokens by tracking and limiting refresh attempts per token. Tokens * are identified by a hash to avoid storing actual token values. */ -@Service +@ApplicationScoped @Slf4j public class RefreshRateLimitService { private final ApplicationProperties.Security.Jwt jwtProperties; - @Autowired public RefreshRateLimitService(ApplicationProperties applicationProperties) { this.jwtProperties = applicationProperties.getSecurity().getJwt(); } @@ -89,7 +87,7 @@ public class RefreshRateLimitService { } /** Clean up expired tracking entries every 5 minutes. */ - @Scheduled(fixedRate = 300000) + @Scheduled(every = "300s") public void cleanupExpiredEntries() { // Use configured grace period with same normalization as runtime checks int configuredMinutes = jwtProperties.getRefreshGraceMinutes(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java index 194a2a967f..35fd2d9738 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamService.java @@ -1,13 +1,14 @@ package stirling.software.proprietary.security.service; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.repository.TeamRepository; -@Service +@ApplicationScoped @RequiredArgsConstructor public class TeamService { @@ -16,6 +17,7 @@ public class TeamService { public static final String DEFAULT_TEAM_NAME = "Default"; public static final String INTERNAL_TEAM_NAME = "Internal"; + @Transactional public Team getOrCreateDefaultTeam() { return teamRepository .findByName(DEFAULT_TEAM_NAME) @@ -23,10 +25,12 @@ public class TeamService { () -> { Team defaultTeam = new Team(); defaultTeam.setName(DEFAULT_TEAM_NAME); - return teamRepository.save(defaultTeam); + teamRepository.persist(defaultTeam); + return defaultTeam; }); } + @Transactional public Team getOrCreateInternalTeam() { return teamRepository .findByName(INTERNAL_TEAM_NAME) @@ -34,7 +38,8 @@ public class TeamService { () -> { Team internalTeam = new Team(); internalTeam.setName(INTERNAL_TEAM_NAME); - return teamRepository.save(internalTeam); + teamRepository.persist(internalTeam); + return internalTeam; }); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java index ea302dcec8..3ec539a713 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java @@ -11,7 +11,7 @@ import java.util.regex.Pattern; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; @@ -24,7 +24,7 @@ import stirling.software.proprietary.security.util.Base32Codec; *

This service handles secret generation, code validation across time steps, and building * otpauth:// URIs to provision authenticator apps. */ -@Service +@ApplicationScoped @RequiredArgsConstructor public class TotpService { 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 a7aaa573fd..eeb8bc10f6 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 @@ -16,9 +16,19 @@ import java.util.Optional; import java.util.UUID; import java.util.function.Supplier; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + import org.slf4j.MDC; -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; + +// TODO: Migration required - Spring Security glue retained until the security layer is migrated. +// SecurityContextHolder/Authentication should become io.quarkus.security.identity.SecurityIdentity +// (injected) or @Context jakarta.ws.rs.core.SecurityContext; UsernamePasswordAuthenticationToken / +// GrantedAuthority / UserDetails / UsernameNotFoundException / OAuth2User / SessionInformation are +// produced and consumed by collaborators not yet ported (SessionPersistentRegistry, the auth +// filters, CustomUserDetailsService). These types are kept so the public bridge methods +// (getAuthentication, getCurrentUsername, invalidateUserSessions, isCurrentUserAdmin) keep working +// until those collaborators are converted together. import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -26,10 +36,12 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +// TODO: Migration required - spring-security-crypto is the agreed temporary shim. PasswordEncoder is +// produced by PasswordEncoderConfig (see that file's class-level note); replace this import once a +// Quarkus-compatible BCrypt abstraction is wired across UserService + PasswordEncoderConfig + +// SecurityConfiguration together. +import org.springframework.security.crypto.password.PasswordEncoder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -60,7 +72,7 @@ import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepo import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository; import stirling.software.proprietary.workflow.service.UserServerCertificateService; -@Service +@ApplicationScoped @Slf4j @RequiredArgsConstructor public class UserService implements UserServiceInterface { @@ -71,7 +83,12 @@ public class UserService implements UserServiceInterface { private final PasswordEncoder passwordEncoder; - private final MessageSource messageSource; + // TODO: Migration required - org.springframework.context.MessageSource and + // LocaleContextHolder (Spring i18n) have no Quarkus equivalent on the classpath. Rebind to a + // Quarkus message bundle (io.quarkus.qute / @org.eclipse.microprofile.config or a + // jakarta.enterprise localization helper) and an explicit Locale source. The injected field is + // removed for now and getInvalidUsernameMessage() returns a constant fallback so the bean can be + // constructed; localization must be restored when the i18n layer is ported. private final SessionPersistentRegistry sessionRegistry; @@ -121,7 +138,7 @@ public class UserService implements UserServiceInterface { log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId); user.setSsoProviderId(ssoProviderId); user.setSsoProvider(ssoProvider); - userRepository.save(user); + userRepository.persist(user); databaseService.exportDatabase(); } return; @@ -144,6 +161,8 @@ public class UserService implements UserServiceInterface { throw new UsernameNotFoundException("API key is not valid"); } // Convert the user into an Authentication object + // TODO: Migration required - emits a Spring Security Authentication consumed by the auth + // filters; replace with a SecurityIdentity construction once the filter layer is ported. return new UsernamePasswordAuthenticationToken( // principal (typically the user) user, // credentials (we don't expose the password or API key here) null, // user's authorities (roles/permissions) @@ -176,8 +195,10 @@ public class UserService implements UserServiceInterface { private User saveUser(Optional user, String apiKey) { if (user.isPresent()) { - user.get().setApiKey(apiKey); - return userRepository.save(user.get()); + User existing = user.get(); + existing.setApiKey(apiKey); + userRepository.persist(existing); + return existing; } throw new UsernameNotFoundException("User not found"); } @@ -329,7 +350,7 @@ public class UserService implements UserServiceInterface { settingsMap.clear(); settingsMap.putAll(updates); user.setSettings(settingsMap); - userRepository.save(user); + userRepository.persist(user); databaseService.exportDatabase(); } } @@ -351,7 +372,8 @@ public class UserService implements UserServiceInterface { /** Low-level user persistence; bypasses {@link #saveUserCore}'s settings/audit lifecycle. */ @Transactional public User saveUser(User user) { - return userRepository.save(user); + userRepository.persist(user); + return user; } public Optional findByUsernameIgnoreCase(String username) { @@ -372,21 +394,21 @@ public class UserService implements UserServiceInterface { throw new IllegalArgumentException(getInvalidUsernameMessage()); } user.setUsername(newUsername); - userRepository.save(user); + userRepository.persist(user); databaseService.exportDatabase(); } public void changePassword(User user, String newPassword) throws SQLException, UnsupportedProviderException { user.setPassword(passwordEncoder.encode(newPassword)); - userRepository.save(user); + userRepository.persist(user); databaseService.exportDatabase(); } public void changeFirstUse(User user, boolean firstUse) throws SQLException, UnsupportedProviderException { user.setFirstLogin(firstUse); - userRepository.save(user); + userRepository.persist(user); databaseService.exportDatabase(); } @@ -394,14 +416,14 @@ public class UserService implements UserServiceInterface { throws SQLException, UnsupportedProviderException { Authority userAuthority = this.findRole(user); userAuthority.setAuthority(newRole); - authorityRepository.save(userAuthority); + authorityRepository.persist(userAuthority); databaseService.exportDatabase(); } public void changeUserEnabled(User user, Boolean enbeled) throws SQLException, UnsupportedProviderException { user.setEnabled(enbeled); - userRepository.save(user); + userRepository.persist(user); databaseService.exportDatabase(); } @@ -411,7 +433,7 @@ public class UserService implements UserServiceInterface { team = getDefaultTeam(); } user.setTeam(team); - userRepository.save(user); + userRepository.persist(user); databaseService.exportDatabase(); } @@ -433,7 +455,7 @@ public class UserService implements UserServiceInterface { } return teamRepository - .findById(teamId) + .findByIdOptional(teamId) .orElseThrow(() -> new IllegalArgumentException("Invalid team ID: " + teamId)); } @@ -449,7 +471,8 @@ public class UserService implements UserServiceInterface { () -> { Team team = new Team(); team.setName("Default"); - return teamRepository.save(team); + teamRepository.persist(team); + return team; }); } @@ -522,7 +545,7 @@ public class UserService implements UserServiceInterface { } // Save user - userRepository.save(user); + userRepository.persist(user); // Export database databaseService.exportDatabase(); @@ -556,8 +579,12 @@ public class UserService implements UserServiceInterface { } private String getInvalidUsernameMessage() { - return messageSource.getMessage( - "invalidUsernameMessage", null, LocaleContextHolder.getLocale()); + // TODO: Migration required - was messageSource.getMessage("invalidUsernameMessage", null, + // LocaleContextHolder.getLocale()). Spring's MessageSource / LocaleContextHolder are not on + // the Quarkus classpath; rebind to a Quarkus localization mechanism (message bundle + + // request Locale) and restore the localized lookup. Returning the message key as a fallback + // preserves behavior shape until i18n is ported. + return "invalidUsernameMessage"; } public boolean hasPassword(String username) { @@ -604,6 +631,10 @@ public class UserService implements UserServiceInterface { } public void invalidateUserSessions(String username) { + // TODO: Migration required - SessionPersistentRegistry still exposes Spring Security types + // (SessionInformation, UserDetails, OAuth2User). Once that collaborator is ported to a + // Quarkus session store, drop these Spring Security imports and adjust the principal type + // checks accordingly. String usernameP = ""; for (Object principal : sessionRegistry.getAllPrincipals()) { @@ -627,7 +658,10 @@ public class UserService implements UserServiceInterface { @Override public String getCurrentUsername() { - // Try SecurityContext first (normal request context) + // TODO: Migration required - SecurityContextHolder/Authentication are Spring Security. The + // request-context branch should be replaced by an injected + // io.quarkus.security.identity.SecurityIdentity (or @Context SecurityContext) once the + // security layer is ported; the MDC fallback below is framework-agnostic and stays. try { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null) { @@ -661,6 +695,8 @@ public class UserService implements UserServiceInterface { @Override public boolean isCurrentUserAdmin() { + // TODO: Migration required - SecurityContextHolder/Authentication are Spring Security; + // replace with SecurityIdentity#hasRole(Role.ADMIN) once the security layer is ported. try { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null @@ -707,7 +743,7 @@ public class UserService implements UserServiceInterface { if (!customApiKey.equals(updatedUser.getApiKey())) { updatedUser.setApiKey(customApiKey); - userRepository.save(updatedUser); + userRepository.persist(updatedUser); } }, () -> { @@ -720,7 +756,7 @@ public class UserService implements UserServiceInterface { user.setAuthenticationType(AuthenticationType.WEB); user.setApiKey(customApiKey); user.addAuthority(new Authority(Role.INTERNAL_API_USER.getRoleId(), user)); - userRepository.save(user); + userRepository.persist(user); }); try { @@ -745,8 +781,9 @@ public class UserService implements UserServiceInterface { return userRepository.findAllWithoutTeam(); } + @Transactional public void saveAll(List users) { - userRepository.saveAll(users); + userRepository.persist(users); } /** @@ -787,7 +824,7 @@ public class UserService implements UserServiceInterface { } if (updated > 0) { - userRepository.saveAll(ssoUsers); + userRepository.persist(ssoUsers); } return updated; @@ -813,7 +850,7 @@ public class UserService implements UserServiceInterface { } if (updated > 0) { - userRepository.saveAll(pendingUsers); + userRepository.persist(pendingUsers); } return updated; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/CustomHttpSessionListener.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/CustomHttpSessionListener.java index b69dfaefbf..aba6e9ea91 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/CustomHttpSessionListener.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/CustomHttpSessionListener.java @@ -1,18 +1,19 @@ package stirling.software.proprietary.security.session; -import org.springframework.stereotype.Component; - +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.servlet.http.HttpSessionEvent; import jakarta.servlet.http.HttpSessionListener; import lombok.extern.slf4j.Slf4j; -@Component +@ApplicationScoped @Slf4j public class CustomHttpSessionListener implements HttpSessionListener { private final SessionPersistentRegistry sessionPersistentRegistry; + @Inject public CustomHttpSessionListener(SessionPersistentRegistry sessionPersistentRegistry) { super(); this.sessionPersistentRegistry = sessionPersistentRegistry; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index 3961107870..1895f361b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -7,13 +7,13 @@ import java.util.Date; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Value; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -22,18 +22,27 @@ import stirling.software.proprietary.security.database.repository.SessionReposit import stirling.software.proprietary.security.model.SessionEntity; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; -@Component +// TODO: Migration required - this class implements Spring Security's SessionRegistry +// (org.springframework.security.core.session.SessionRegistry) and exposes SessionInformation, +// UserDetails and OAuth2User from spring-security. Quarkus has no equivalent session-registry +// abstraction. The Spring Security imports below are kept ONLY because un-migrated collaborators +// (UserAuthenticationFilter, UserService, SessionRegistryConfig) still consume this interface and +// its return types. Once those collaborators are migrated to Quarkus security +// (io.quarkus.security.identity.SecurityIdentity), this class should drop the SessionRegistry +// contract and the spring-security types, replacing them with a plain CDI service over the +// SessionEntity table. +@ApplicationScoped @RequiredArgsConstructor public class SessionPersistentRegistry implements SessionRegistry { private final SessionRepository sessionRepository; - @Value("${server.servlet.session.timeout:30m}") - private Duration defaultMaxInactiveInterval; + @ConfigProperty(name = "server.servlet.session.timeout", defaultValue = "30m") + Duration defaultMaxInactiveInterval; @Override public List getAllPrincipals() { - List sessions = sessionRepository.findAll(); + List sessions = sessionRepository.listAll(); List principals = new ArrayList<>(); for (SessionEntity session : sessions) { principals.add(session.getPrincipalName()); @@ -102,7 +111,7 @@ public class SessionPersistentRegistry implements SessionRegistry { sessionEntity.setPrincipalName(principalName); sessionEntity.setLastRequest(Instant.now()); // Set lastRequest to the current date sessionEntity.setExpired(false); - sessionRepository.save(sessionEntity); + sessionRepository.persist(sessionEntity); } } @@ -115,17 +124,17 @@ public class SessionPersistentRegistry implements SessionRegistry { @Override @Transactional public void refreshLastRequest(String sessionId) { - Optional sessionEntityOpt = sessionRepository.findById(sessionId); + Optional sessionEntityOpt = sessionRepository.findByIdOptional(sessionId); if (sessionEntityOpt.isPresent()) { SessionEntity sessionEntity = sessionEntityOpt.get(); sessionEntity.setLastRequest(Instant.now()); // Update lastRequest to the current date - sessionRepository.save(sessionEntity); + sessionRepository.persist(sessionEntity); } } @Override public SessionInformation getSessionInformation(String sessionId) { - Optional sessionEntityOpt = sessionRepository.findById(sessionId); + Optional sessionEntityOpt = sessionRepository.findByIdOptional(sessionId); if (sessionEntityOpt.isPresent()) { SessionEntity sessionEntity = sessionEntityOpt.get(); return new SessionInformation( @@ -143,16 +152,17 @@ public class SessionPersistentRegistry implements SessionRegistry { // Retrieve all sessions public List getAllSessions() { - return sessionRepository.findAll(); + return sessionRepository.listAll(); } // Mark a session as expired + @Transactional public void expireSession(String sessionId) { - Optional sessionEntityOpt = sessionRepository.findById(sessionId); + Optional sessionEntityOpt = sessionRepository.findByIdOptional(sessionId); if (sessionEntityOpt.isPresent()) { SessionEntity sessionEntity = sessionEntityOpt.get(); sessionEntity.setExpired(true); // Set expired to true - sessionRepository.save(sessionEntity); + sessionRepository.persist(sessionEntity); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionRegistryConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionRegistryConfig.java index eccd7332e5..c138e5e1d7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionRegistryConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionRegistryConfig.java @@ -1,20 +1,26 @@ package stirling.software.proprietary.security.session; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.core.session.SessionRegistryImpl; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; import stirling.software.proprietary.security.database.repository.SessionRepository; -@Configuration +@ApplicationScoped public class SessionRegistryConfig { - @Bean - public SessionRegistryImpl sessionRegistry() { - return new SessionRegistryImpl(); - } + // TODO: Migration required - SessionRegistryImpl is a Spring Security type + // (org.springframework.security.core.session.SessionRegistryImpl) with no Quarkus + // equivalent. Concurrent-session tracking must be rehosted (e.g. a custom bean backed + // by SessionPersistentRegistry / SecurityIdentity, or quarkus session management). + // The original producer was: + // @Bean public SessionRegistryImpl sessionRegistry() { return new SessionRegistryImpl(); } - @Bean + // Note: SessionPersistentRegistry is a local CDI bean (@ApplicationScoped) and is + // auto-discovered by Quarkus, so an explicit producer is no longer required. A producer + // is kept here only to preserve the explicit construction with SessionRepository; remove + // it if SessionPersistentRegistry is annotated as a CDI bean to avoid an ambiguous bean. + @Produces + @ApplicationScoped public SessionPersistentRegistry sessionPersistentRegistry( SessionRepository sessionRepository) { return new SessionPersistentRegistry(sessionRepository); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java index 1f491bf4d6..c73cea7498 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java @@ -5,13 +5,19 @@ import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; -import org.springframework.scheduling.annotation.Scheduled; +// TODO: Migration required - SessionInformation is a Spring Security type +// (org.springframework.security.core.session.SessionInformation) still returned by the +// not-yet-migrated collaborator SessionPersistentRegistry. Keep this import until that +// collaborator and the session-registry abstraction are migrated off Spring Security. import org.springframework.security.core.session.SessionInformation; -import org.springframework.stereotype.Component; + +import io.quarkus.scheduler.Scheduled; + +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; -@Component +@ApplicationScoped @RequiredArgsConstructor public class SessionScheduled { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseEndpoints.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseEndpoints.java index 24afb97465..06ae543c48 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseEndpoints.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseEndpoints.java @@ -1,13 +1,14 @@ package stirling.software.proprietary.security.supabase; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.config.inject.ConfigProperty; /** * Stirling's customer-facing Supabase project endpoints ({@code auth.stirling.com} in production). * Overridable via {@code stirling.supabase.url} / {@code stirling.supabase.publishable-key}. */ -@Component +@ApplicationScoped public class SupabaseEndpoints { public static final String DEFAULT_URL = "https://auth.stirling.com"; @@ -16,11 +17,11 @@ public class SupabaseEndpoints { public static final String DEFAULT_PUBLISHABLE_KEY = "sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb"; // gitleaks:allow - @Value("${stirling.supabase.url:" + DEFAULT_URL + "}") - private String url; + @ConfigProperty(name = "stirling.supabase.url", defaultValue = DEFAULT_URL) + String url; - @Value("${stirling.supabase.publishable-key:" + DEFAULT_PUBLISHABLE_KEY + "}") - private String publishableKey; + @ConfigProperty(name = "stirling.supabase.publishable-key", defaultValue = DEFAULT_PUBLISHABLE_KEY) + String publishableKey; public String getUrl() { return url; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseJwtDecoderFactory.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseJwtDecoderFactory.java index 2092818fab..44bc0d130f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseJwtDecoderFactory.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseJwtDecoderFactory.java @@ -1,43 +1,48 @@ package stirling.software.proprietary.security.supabase; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * Produces a {@link JwtDecoder} bean for the proprietary Supabase login path. Only registered when + * Produces the JWKS configuration for the proprietary Supabase login path. Only relevant when * {@code security.supabase.user-login.enabled=true}. + * + *

TODO: Migration required - this class previously produced a Spring Security + * {@code org.springframework.security.oauth2.jwt.JwtDecoder} bean (Nimbus-based) via + * {@code @Configuration}/{@code @Bean}, conditionally registered with + * {@code @ConditionalOnProperty(security.supabase.user-login.enabled=true)}. Quarkus has no + * {@code JwtDecoder} abstraction; bearer/JWT validation for the Supabase issuer must be wired via + * quarkus-oidc (resource-server) using {@code quarkus.oidc..auth-server-url}/ + * {@code quarkus.oidc..jwks-path}, or via quarkus-smallrye-jwt + * ({@code mp.jwt.verify.publickey.location} pointing at the JWKS URL computed by + * {@link #jwksUri()}). The conditional registration becomes a build/runtime profile guard + * (e.g. {@code @io.quarkus.arc.profile.IfBuildProfile} or a {@code quarkus.oidc..tenant-enabled} + * runtime toggle). The fail-closed behaviour (reject every token when the issuer is unset) must be + * reproduced at the tenant-resolution / IdentityProvider layer. The JWKS-URL helper logic below is + * preserved for reuse during that wiring. */ @Slf4j -@Configuration -@ConditionalOnProperty( - prefix = "security.supabase.user-login", - name = "enabled", - havingValue = "true") -@RequiredArgsConstructor +@ApplicationScoped public class SupabaseJwtDecoderFactory { - private final SupabaseUserLoginProperties properties; + @Inject SupabaseUserLoginProperties properties; - @Bean - public JwtDecoder supabaseUserLoginJwtDecoder() { + /** + * Computes the Supabase JWKS endpoint, or returns {@code null} when no issuer is configured + * (in which case token verification must fail closed - reject every token). + */ + public String jwksUri() { if (!properties.isJwtConfigured()) { log.warn( "security.supabase.user-login.enabled=true but issuer URL is not set;" - + " producing a fail-closed JwtDecoder that rejects every token." + + " token verification must fail closed and reject every token." + " Set security.supabase.user-login.issuer to enable real verification."); - return token -> { - throw new org.springframework.security.oauth2.jwt.JwtException( - "Supabase user-login issuer not configured"); - }; + return null; } String jwks = properties.getIssuer() + "/.well-known/jwks.json"; - log.info("Configuring proprietary-mode Supabase JwtDecoder with JWKS: {}", jwks); - return NimbusJwtDecoder.withJwkSetUri(jwks).build(); + log.info("Configuring proprietary-mode Supabase JWKS: {}", jwks); + return jwks; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseUserLoginProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseUserLoginProperties.java index a6b435a00d..0187037404 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseUserLoginProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/supabase/SupabaseUserLoginProperties.java @@ -1,14 +1,16 @@ package stirling.software.proprietary.security.supabase; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; +import jakarta.enterprise.context.ApplicationScoped; import lombok.Data; /** Config for the optional Supabase login on proprietary deployments. */ +// TODO: Migration required - this was a Spring @ConfigurationProperties(prefix = +// "security.supabase.user-login") POJO. Rebind the prefixed properties via +// @io.smallrye.config.ConfigMapping(prefix = "security.supabase.user-login") (interface-based) +// so the fields are populated from configuration; until then this bean holds defaults only. @Data -@Component -@ConfigurationProperties(prefix = "security.supabase.user-login") +@ApplicationScoped public class SupabaseUserLoginProperties { /** Master switch. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java index b0d726ceca..394a495d47 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineClient.java @@ -10,24 +10,24 @@ import java.time.Duration; import java.util.function.Consumer; import java.util.stream.Stream; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; @Slf4j -@Service +@ApplicationScoped public class AiEngineClient { private final ApplicationProperties applicationProperties; private final HttpClient httpClient; private final String engineSharedSecret; - @Autowired + @Inject public AiEngineClient(ApplicationProperties applicationProperties) { this( applicationProperties, @@ -73,8 +73,8 @@ public class AiEngineClient { throws IOException { ApplicationProperties.AiEngine config = applicationProperties.getAiEngine(); if (!config.isEnabled()) { - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled"); + throw new WebApplicationException( + "AI engine is not enabled", Response.Status.SERVICE_UNAVAILABLE); } String url = config.getUrl().stripTrailing() + path; @@ -126,8 +126,8 @@ public class AiEngineClient { throws IOException { ApplicationProperties.AiEngine config = applicationProperties.getAiEngine(); if (!config.isEnabled()) { - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled"); + throw new WebApplicationException( + "AI engine is not enabled", Response.Status.SERVICE_UNAVAILABLE); } String url = config.getUrl().stripTrailing() + path; @@ -152,21 +152,23 @@ public class AiEngineClient { try { response = httpClient.send(request, HttpResponse.BodyHandlers.ofLines()); } catch (HttpTimeoutException e) { - throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "AI engine timed out", e); + throw new WebApplicationException( + "AI engine timed out", e, Response.Status.GATEWAY_TIMEOUT); } catch (IOException e) { - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI engine unreachable: " + e.getMessage(), e); + throw new WebApplicationException( + "AI engine unreachable: " + e.getMessage(), + e, + Response.Status.SERVICE_UNAVAILABLE); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI engine request was interrupted"); + throw new WebApplicationException( + "AI engine request was interrupted", Response.Status.SERVICE_UNAVAILABLE); } int status = response.statusCode(); if (status >= 400) { - throw new ResponseStatusException( - HttpStatus.valueOf(status >= 500 ? 502 : status), - "AI engine returned error: " + status); + throw new WebApplicationException( + "AI engine returned error: " + status, status >= 500 ? 502 : status); } try (Stream lines = response.body()) { @@ -187,8 +189,8 @@ public class AiEngineClient { public String delete(String path, String userId) throws IOException { ApplicationProperties.AiEngine config = applicationProperties.getAiEngine(); if (!config.isEnabled()) { - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled"); + throw new WebApplicationException( + "AI engine is not enabled", Response.Status.SERVICE_UNAVAILABLE); } String url = config.getUrl().stripTrailing() + path; @@ -212,8 +214,8 @@ public class AiEngineClient { public String get(String path, String userId) throws IOException { ApplicationProperties.AiEngine config = applicationProperties.getAiEngine(); if (!config.isEnabled()) { - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled"); + throw new WebApplicationException( + "AI engine is not enabled", Response.Status.SERVICE_UNAVAILABLE); } String url = config.getUrl().stripTrailing() + path; @@ -238,30 +240,33 @@ public class AiEngineClient { try { return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (HttpTimeoutException e) { - throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "AI engine timed out", e); + throw new WebApplicationException( + "AI engine timed out", e, Response.Status.GATEWAY_TIMEOUT); } catch (IOException e) { // Connection refused, DNS failure, socket reset, etc. — surface as // SERVICE_UNAVAILABLE so every caller of this client sees a structured // status rather than a raw 500 from an unhandled IOException. - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI engine unreachable: " + e.getMessage(), e); + throw new WebApplicationException( + "AI engine unreachable: " + e.getMessage(), + e, + Response.Status.SERVICE_UNAVAILABLE); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new ResponseStatusException( - HttpStatus.SERVICE_UNAVAILABLE, "AI engine request was interrupted"); + throw new WebApplicationException( + "AI engine request was interrupted", Response.Status.SERVICE_UNAVAILABLE); } } private void checkResponseStatus(HttpResponse response) { int status = response.statusCode(); if (status >= 500) { - throw new ResponseStatusException( - HttpStatus.BAD_GATEWAY, "AI engine returned error: " + status); + throw new WebApplicationException( + "AI engine returned error: " + status, Response.Status.BAD_GATEWAY); } if (status >= 400) { - throw new ResponseStatusException( - HttpStatus.valueOf(status), - "AI engine returned client error: " + response.body()); + // status is a known 4xx client error code; pass it through directly. + throw new WebApplicationException( + "AI engine returned client error: " + response.body(), status); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineEndpointResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineEndpointResolver.java index ee2ce63eb6..bdad8e4e76 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineEndpointResolver.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineEndpointResolver.java @@ -1,17 +1,13 @@ package stirling.software.proprietary.service; -import java.lang.reflect.Method; -import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TreeSet; -import org.springframework.context.ApplicationContext; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import org.springframework.web.servlet.mvc.method.RequestMappingInfo; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import io.quarkus.runtime.StartupEvent; import lombok.extern.slf4j.Slf4j; @@ -24,37 +20,40 @@ import stirling.software.SPDF.config.EndpointConfiguration; * engine considers a tool - we just emit what's enabled here. */ @Slf4j -@Service +@ApplicationScoped public class AiEngineEndpointResolver { private static final String API_PREFIX = "/api/v1/"; - private final ApplicationContext applicationContext; private final EndpointConfiguration endpointConfiguration; - // Written once on the Spring startup thread during ContextRefreshedEvent, read on HTTP - // request threads. Spring's lifecycle establishes happens-before (the servlet container - // and its worker threads are started after refresh completes), so no volatile is needed. + // Written once on the startup thread during StartupEvent, read on HTTP request threads. + // Quarkus' lifecycle establishes happens-before (HTTP serving starts after StartupEvent + // observers complete), so no volatile is needed. private Set apiUrls = Set.of(); - public AiEngineEndpointResolver( - ApplicationContext applicationContext, EndpointConfiguration endpointConfiguration) { - this.applicationContext = applicationContext; + public AiEngineEndpointResolver(EndpointConfiguration endpointConfiguration) { this.endpointConfiguration = endpointConfiguration; } - @EventListener(ContextRefreshedEvent.class) + void onStart(@Observes StartupEvent event) { + discoverApiUrls(); + } + public void discoverApiUrls() { Set discovered = new TreeSet<>(); - for (RequestMappingHandlerMapping mapping : - applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) { - for (RequestMappingInfo info : mapping.getHandlerMethods().keySet()) { - for (String pattern : extractPatterns(info)) { - if (pattern.startsWith(API_PREFIX)) { - discovered.add(pattern); - } - } - } - } + // TODO: Migration required - this previously enumerated all registered request mappings + // via Spring MVC's RequestMappingHandlerMapping + // (org.springframework.web.servlet.mvc.method.*) obtained from the ApplicationContext at + // ContextRefreshedEvent, keeping every pattern that started with "/api/v1/". Quarkus / + // JAX-RS (RESTEasy Reactive) has no equivalent runtime-queryable handler-mapping registry. + // Options for porting: + // - Build-time scan of @jakarta.ws.rs.Path methods via a Quarkus build step / Jandex + // index, exposing the discovered "/api/v1/" paths as a startup bean, or + // - Query the OpenAPI model (quarkus-smallrye-openapi) for "/api/v1/" paths, or + // - Maintain an explicit allow-list. + // Until one of the above is implemented, no endpoints are discovered and the enabled-URL + // list will be empty (preserving the safe "engine drops what it doesn't recognise" + // contract described above). apiUrls = Set.copyOf(discovered); log.debug("Discovered {} /api/v1/ endpoint URLs for AI engine filtering", apiUrls.size()); } @@ -65,23 +64,4 @@ public class AiEngineEndpointResolver { .sorted() .toList(); } - - private static Set extractPatterns(RequestMappingInfo info) { - try { - Method getDirectPaths = info.getClass().getMethod("getDirectPaths"); - Object result = getDirectPaths.invoke(info); - if (result instanceof Set set) { - Set patterns = new HashSet<>(); - for (Object value : set) { - if (value instanceof String s) { - patterns.add(s); - } - } - return patterns; - } - } catch (Exception e) { - log.trace("getDirectPaths unavailable on RequestMappingInfo", e); - } - return Set.of(); - } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiToolInputValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiToolInputValidator.java index 260d69fb22..684abdf48c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiToolInputValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiToolInputValidator.java @@ -1,17 +1,18 @@ package stirling.software.proprietary.service; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.server.ResponseStatusException; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import stirling.software.common.model.MultipartFile; /** * Shared input-validation for AI-backed tool endpoints. * - *

Spring's {@code spring.servlet.multipart.max-file-size} is tuned for the regular PDF tools (2 - * GB) — far too permissive for AI tools where upload size translates directly into token budget, - * memory, and engine cost. Every AI tool should call {@link #validatePdfUpload} on its input before - * doing any work. + *

The platform's multipart max-file-size is tuned for the regular PDF tools (2 GB) — far too + * permissive for AI tools where upload size translates directly into token budget, memory, and + * engine cost. Every AI tool should call {@link #validatePdfUpload} on its input before doing any + * work. */ public final class AiToolInputValidator { @@ -25,24 +26,24 @@ public final class AiToolInputValidator { private AiToolInputValidator() {} /** - * Validate a PDF uploaded to an AI tool endpoint. Throws {@link ResponseStatusException} with - * an appropriate HTTP status on any failure. + * Validate a PDF uploaded to an AI tool endpoint. Throws {@link WebApplicationException} with an + * appropriate HTTP status on any failure. */ public static void validatePdfUpload(MultipartFile file) { if (file == null || file.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fileInput is required"); + throw new WebApplicationException("fileInput is required", Response.Status.BAD_REQUEST); } String contentType = file.getContentType(); - if (contentType == null || !contentType.equals(MediaType.APPLICATION_PDF_VALUE)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Only application/pdf uploads are supported"); + if (contentType == null || !contentType.equals(MediaType.APPLICATION_PDF)) { + throw new WebApplicationException( + "Only application/pdf uploads are supported", Response.Status.BAD_REQUEST); } if (file.getSize() > MAX_INPUT_FILE_BYTES) { - throw new ResponseStatusException( - HttpStatus.PAYLOAD_TOO_LARGE, + throw new WebApplicationException( "PDF exceeds maximum size of " + (MAX_INPUT_FILE_BYTES / (1024 * 1024)) - + " MB for AI tools"); + + " MB for AI tools", + Response.Status.REQUEST_ENTITY_TOO_LARGE); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiUserDataService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiUserDataService.java index 0876c734f7..2b0a7734e5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiUserDataService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiUserDataService.java @@ -2,10 +2,13 @@ package stirling.software.proprietary.service; import java.io.IOException; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; +// TODO: Migration required - AiEngineClient (a collaborator, not yet migrated) still throws +// org.springframework.web.server.ResponseStatusException, so this import must stay until that file +// is converted. Once AiEngineClient throws jakarta.ws.rs.WebApplicationException, swap this catch. import org.springframework.web.server.ResponseStatusException; +import jakarta.enterprise.context.ApplicationScoped; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -14,11 +17,10 @@ import lombok.extern.slf4j.Slf4j; * *

Today: cleanup on logout. The engine also runs a TTL reaper that catches sessions ended * without a clean logout (tab close, JWT expiry, engine restart), so this service is the happy-path - * purge, not a hard guarantee. Calls are fire-and-forget on a background thread (Spring's default - * {@code @Async} executor) so an unavailable engine never delays the caller's response. + * purge, not a hard guarantee. Engine errors are logged and never propagated to the caller. */ @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class AiUserDataService { @@ -32,7 +34,12 @@ public class AiUserDataService { * logout handler) returns immediately; engine errors are logged on the worker thread and never * propagated. The engine's TTL reaper backstops any miss within ~24h. */ - @Async + // TODO: Migration required - Spring's @Async ran this fire-and-forget on a managed executor so + // an unavailable engine never delayed the logout response. Quarkus has no @Async; the method + // now runs synchronously on the caller's thread. To restore off-thread dispatch, inject a + // jakarta.enterprise.concurrent.ManagedExecutorService (or annotate the calling REST endpoint + // with @io.smallrye.common.annotation.RunOnVirtualThread). Errors are still swallowed, so the + // only behavioural change is that the caller now blocks on the engine call. public void purgeUserDocuments(String userId) { if (userId == null || userId.isBlank()) { log.debug("Skipping user document purge: no user id"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 0178e041a0..c2fe6c6a1d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.service; import java.io.IOException; +import java.net.URLConnection; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -9,16 +10,12 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + import org.apache.commons.io.FilenameUtils; import org.apache.pdfbox.pdmodel.PDDocument; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.http.MediaType; -import org.springframework.http.MediaTypeFactory; -import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpServerErrorException; -import org.springframework.web.multipart.MultipartFile; import io.github.pixee.security.Filenames; @@ -26,6 +23,10 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.io.FileSystemResource; +import stirling.software.common.model.io.InputStreamResource; +import stirling.software.common.model.io.Resource; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.FileStorage; import stirling.software.common.service.InternalApiTimeoutException; @@ -63,7 +64,7 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @Slf4j -@Service +@ApplicationScoped public class AiWorkflowService { private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents"; @@ -78,9 +79,12 @@ public class AiWorkflowService { private final FileIdStrategy fileIdStrategy; private final AiEngineEndpointResolver endpointResolver; private final PolicyExecutor policyExecutor; + // @Autowired(required = false) -> Instance for the optional UserServiceInterface bean + // (present only when security is enabled). Resolved once in the constructor. private final UserServiceInterface userService; private final ApplicationProperties applicationProperties; + @Inject public AiWorkflowService( CustomPDFDocumentFactory pdfDocumentFactory, AiEngineClient aiEngineClient, @@ -91,7 +95,7 @@ public class AiWorkflowService { FileIdStrategy fileIdStrategy, AiEngineEndpointResolver endpointResolver, PolicyExecutor policyExecutor, - @Autowired(required = false) UserServiceInterface userService, + Instance userService, ApplicationProperties applicationProperties) { this.pdfDocumentFactory = pdfDocumentFactory; this.aiEngineClient = aiEngineClient; @@ -102,7 +106,7 @@ public class AiWorkflowService { this.fileIdStrategy = fileIdStrategy; this.endpointResolver = endpointResolver; this.policyExecutor = policyExecutor; - this.userService = userService; + this.userService = userService.isResolvable() ? userService.get() : null; this.applicationProperties = applicationProperties; } @@ -509,13 +513,9 @@ public class AiWorkflowService { listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.PROCESSING)); String safeFilename = Filenames.toSimpleFileName(filename); byte[] bytes = content.getBytes(java.nio.charset.StandardCharsets.UTF_8); - org.springframework.core.io.Resource resource = - new org.springframework.core.io.ByteArrayResource(bytes) { - @Override - public String getFilename() { - return safeFilename; - } - }; + Resource resource = + new InputStreamResource( + new java.io.ByteArrayInputStream(bytes), safeFilename); return new WorkflowState.Terminal( buildCompletedResponse(response.getSummary(), List.of(resource), List.of(), null)); } @@ -580,10 +580,12 @@ public class AiWorkflowService { log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage()); return new WorkflowState.Terminal( cannotContinue(toolTimeoutMessage(e.getEndpointPath(), e))); - } catch (HttpServerErrorException e) { - String reason = extractDetailFromHttpError(e); - log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason); - return new WorkflowState.Terminal(cannotContinue(reason)); + // TODO: Migration required - the dedicated catch for Spring's + // org.springframework.web.client.HttpServerErrorException was dropped: the migrated + // PolicyExecutor surfaces internal-API HTTP failures as a plain IOException (see + // PolicyExecutor#invoke), so no Spring HTTP exception ever reaches here. If the engine's + // structured "detail" error body needs to surface in chat again, have PolicyExecutor throw + // a typed exception carrying the response body and re-add a catch that extracts it. } catch (Exception e) { log.error("Failed to execute plan: {}", e.getMessage(), e); return new WorkflowState.Terminal( @@ -609,27 +611,6 @@ public class AiWorkflowService { return String.format("The %s tool failed: %s", endpointPath, reason); } - /** - * Extracts the {@code detail} field from an HTTP error response body if it is valid JSON, - * otherwise falls back to the exception message. This lets controller-level error messages - * (e.g. missing system dependency) surface cleanly in the chat response. - */ - private String extractDetailFromHttpError(HttpServerErrorException e) { - try { - String body = e.getResponseBodyAsString(); - if (body != null && !body.isBlank()) { - JsonNode node = objectMapper.readTree(body); - JsonNode detail = node.get("detail"); - if (detail != null && detail.isTextual() && !detail.asText().isBlank()) { - return detail.asText(); - } - } - } catch (Exception ignored) { - // fall through to generic message - } - return "The request could not be completed. Please try again or contact your system administrator."; - } - /** * Adapt the AI workflow's {@link ProgressListener} to the engine's {@link * PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event @@ -681,10 +662,11 @@ public class AiWorkflowService { } else { name = "result-" + (i + 1); } - String contentType = - MediaTypeFactory.getMediaType(name) - .orElse(MediaType.APPLICATION_OCTET_STREAM) - .toString(); + // Spring's MediaTypeFactory.getMediaType(name) did extension-based content-type + // guessing; jakarta.ws.rs.core.MediaType has no equivalent factory, so use the JDK's + // URLConnection.guessContentTypeFromName and fall back to application/octet-stream. + String guessed = URLConnection.guessContentTypeFromName(name); + String contentType = guessed != null ? guessed : "application/octet-stream"; String fileId; try (java.io.InputStream is = resource.getInputStream()) { fileId = fileStorage.storeInputStream(is, name).fileId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java index 8a70a1b7a4..1c93e23a6b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java @@ -3,13 +3,12 @@ package stirling.software.proprietary.service; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; -import java.util.concurrent.TimeUnit; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.panache.common.Page; +import io.quarkus.scheduler.Scheduled; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,7 +18,7 @@ import stirling.software.proprietary.repository.PersistentAuditEventRepository; /** Service to periodically clean up old audit events based on retention policy. */ @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class AuditCleanupService { @@ -33,7 +32,7 @@ public class AuditCleanupService { * Scheduled task that runs daily to clean up old audit events. The retention period is * configurable in settings.yml. */ - @Scheduled(fixedDelay = 1, initialDelay = 1, timeUnit = TimeUnit.DAYS) + @Scheduled(every = "24h", delay = 24, delayUnit = java.util.concurrent.TimeUnit.HOURS) public void cleanupOldAuditEvents() { if (!auditConfig.isEnabled()) { return; @@ -87,10 +86,12 @@ public class AuditCleanupService { } /** Finds a batch of IDs to delete. */ - @Transactional(readOnly = true) + @Transactional private List findBatchOfIdsToDelete(Instant cutoffDate) { - PageRequest pageRequest = PageRequest.of(0, BATCH_SIZE, Sort.by("id")); - return auditRepository.findIdsForBatchDeletion(cutoffDate, pageRequest); + // Spring Data PageRequest.of(0, BATCH_SIZE, Sort.by("id")) -> Panache Page (first page, + // BATCH_SIZE rows). The repository's JPQL already applies "ORDER BY e.id". + Page page = Page.of(0, BATCH_SIZE); + return auditRepository.findIdsForBatchDeletion(cutoffDate, page); } /** Deletes a batch of events by ID. Each batch is in its own transaction. */ @@ -101,7 +102,8 @@ public class AuditCleanupService { } int batchSize = batchIds.size(); - auditRepository.deleteAllByIdInBatch(batchIds); + // Spring Data deleteAllByIdInBatch(ids) -> Panache bulk delete by id collection. + auditRepository.delete("id IN ?1", batchIds); log.debug("Deleted batch of {} audit events", batchSize); return batchSize; 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 9f50b9f595..8132a51720 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 @@ -20,22 +20,31 @@ import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.MDC; -import org.springframework.beans.factory.annotation.Qualifier; + +// TODO: Migration required - org.springframework.boot.actuate.audit.AuditEvent and +// AuditEventRepository are Spring Boot Actuator types with no Quarkus equivalent. They are kept +// here because this is a coordinated migration: stirling.software.proprietary.config +// .CustomAuditEventRepository implements AuditEventRepository and several controllers/services +// (AuditRestController, AuditDashboardController, AuditCleanupService, PersistentAuditEventRepository) +// share these types. Replace them across that set with a plain audit-record DTO + a CDI-managed +// repository (PanacheRepository over the existing PersistentAuditEvent entity) when that set is +// migrated; the persistence logic below (repository.add(new AuditEvent(...))) is preserved verbatim. import org.springframework.boot.actuate.audit.AuditEvent; import org.springframework.boot.actuate.audit.AuditEventRepository; -import org.springframework.http.MediaType; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; -import org.springframework.web.multipart.MultipartFile; +import io.quarkus.security.identity.SecurityIdentity; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Named; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.MediaType; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.MultipartFile; import stirling.software.common.model.api.PDFFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.RegexPatternUtils; @@ -44,7 +53,6 @@ import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.config.AuditConfigurationProperties; -import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.service.JwtServiceInterface; /** @@ -52,7 +60,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface; * with data collection utilities for comprehensive audit trail management. */ @Slf4j -@Service +@ApplicationScoped public class AuditService { private final AuditEventRepository repository; @@ -61,17 +69,32 @@ public class AuditService { private final CustomPDFDocumentFactory pdfDocumentFactory; private final JwtServiceInterface jwtService; + // Quarkus migration: replaces Spring's SecurityContextHolder.getContext().getAuthentication(). + // SecurityIdentity is request-scoped, so it is injected as Instance to allow safe (lazy) + // resolution from non-request / async threads where no identity is active. + private final Instance securityIdentity; + + // Quarkus migration: replaces Spring's RequestContextHolder/ServletRequestAttributes for + // accessing the current request. Injected as Instance so it can be resolved lazily and + // tolerate absence of an active request (returns null below, mirroring the old null checks). + private final Instance currentRequest; + + @Inject public AuditService( AuditEventRepository repository, AuditConfigurationProperties auditConfig, - @Qualifier("runningEE") boolean runningEE, + @Named("runningEE") boolean runningEE, CustomPDFDocumentFactory pdfDocumentFactory, - JwtServiceInterface jwtService) { + JwtServiceInterface jwtService, + Instance securityIdentity, + Instance currentRequest) { this.repository = repository; this.auditConfig = auditConfig; this.runningEE = runningEE; this.pdfDocumentFactory = pdfDocumentFactory; this.jwtService = jwtService; + this.securityIdentity = securityIdentity; + this.currentRequest = currentRequest; } // ========== PERSISTENCE METHODS ========== @@ -284,9 +307,10 @@ public class AuditService { // This ensures consistency in async contexts where SecurityContext may not be available String principal = MDC.get("auditPrincipal"); if (principal == null) { - // Fallback: capture from SecurityContext if running in request thread - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - principal = (auth != null && auth.getName() != null) ? auth.getName() : "system"; + // Fallback: capture from the active SecurityIdentity if running in a request thread. + // Quarkus migration: was SecurityContextHolder.getContext().getAuthentication(). + String identityName = currentIdentityName(); + principal = (identityName != null) ? identityName : "system"; } data.put("principal", principal); @@ -319,16 +343,10 @@ public class AuditService { data.put("httpMethod", httpMethod); data.put("path", path); - // Get request attributes safely - ServletRequestAttributes attrs = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - if (attrs == null) { - return; // No request context available - } - - HttpServletRequest req = attrs.getRequest(); + // Get request safely. Quarkus migration: was RequestContextHolder.getRequestAttributes(). + HttpServletRequest req = getCurrentRequest(); if (req == null) { - return; // No request available + return; // No request context available } // STANDARD level HTTP data @@ -347,8 +365,8 @@ public class AuditService { && req.getContentType() != null) { String contentType = req.getContentType(); - if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE) - || contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) { + if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED) + || contentType.contains(MediaType.MULTIPART_FORM_DATA)) { Map params = new HashMap<>(req.getParameterMap()); // Remove CSRF token from logged parameters @@ -720,9 +738,20 @@ public class AuditService { * @return The current request or null if not in a request context */ public HttpServletRequest getCurrentRequest() { - ServletRequestAttributes attrs = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - return attrs != null ? attrs.getRequest() : null; + // Quarkus migration: was RequestContextHolder.getRequestAttributes().getRequest(). + // securityIdentity/currentRequest are request-scoped; outside an active request the + // proxy resolution throws, so we treat that as "no request" (mirrors the old null check). + try { + if (currentRequest.isUnsatisfied()) { + return null; + } + HttpServletRequest req = currentRequest.get(); + // Touch the proxy to force resolution; if no request is active this throws. + req.getRequestURI(); + return req; + } catch (RuntimeException e) { + return null; + } } /** @@ -813,9 +842,10 @@ public class AuditService { /** Get the current authenticated username or "system" if none */ private String getCurrentUsername() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.getName() != null && !"anonymousUser".equals(auth.getName())) { - return auth.getName(); + // Quarkus migration: was SecurityContextHolder.getContext().getAuthentication().getName(). + String name = currentIdentityName(); + if (name != null && !"anonymousUser".equals(name)) { + return name; } // Refresh endpoint runs without normal JWT authentication so SecurityContext may be @@ -827,7 +857,29 @@ public class AuditService { return tokenSubject; } - return (auth != null && auth.getName() != null) ? auth.getName() : "system"; + return (name != null) ? name : "system"; + } + + /** + * Quarkus migration helper: resolve the current principal name from the active + * SecurityIdentity, or null if no identity / anonymous / no active request. Replaces Spring's + * SecurityContextHolder.getContext().getAuthentication().getName(). + */ + private String currentIdentityName() { + try { + if (securityIdentity.isUnsatisfied()) { + return null; + } + SecurityIdentity identity = securityIdentity.get(); + if (identity == null || identity.isAnonymous() || identity.getPrincipal() == null) { + return null; + } + String name = identity.getPrincipal().getName(); + return StringUtils.isNotBlank(name) ? name : null; + } catch (RuntimeException e) { + // No active request scope (e.g. async/background thread) - treat as no identity. + return null; + } } /** @@ -856,16 +908,30 @@ public class AuditService { */ private String determineOrigin() { try { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + // Quarkus migration: was SecurityContextHolder.getContext().getAuthentication(). + // TODO: Migration required - the original code distinguished API-key auth from web/JWT + // auth via `instanceof ApiKeyAuthenticationToken`. Under Quarkus the runtime principal + // is io.quarkus.security.identity.SecurityIdentity and ApiKeyAuthenticationToken has + // been reduced to a plain POJO (it is no longer the identity type), so the API-key vs + // WEB distinction can no longer be made by instanceof here. Once the API-key auth path + // is migrated (custom IdentityProvider / SecurityIdentityAugmentor), re-derive "API" by + // inspecting a SecurityIdentity attribute/role set by that augmentor (e.g. + // identity.getAttribute("authType") or a dedicated role) instead of an instanceof check. + // Until then, any authenticated non-anonymous identity is reported as "WEB"; the + // refresh-token claim inspection below still recovers "API" for refresh requests. + if (securityIdentity.isSatisfied()) { + SecurityIdentity identity = securityIdentity.get(); + String authType = String.valueOf(identity.getAttribute("authType")); + if ("API".equalsIgnoreCase(authType)) { + return "API"; + } - // Check if authenticated via API key - if (auth instanceof ApiKeyAuthenticationToken) { - return "API"; - } - - // Check if authenticated via JWT (web user) - if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) { - return "WEB"; + // Check if authenticated (web user) + String name = + identity.getPrincipal() != null ? identity.getPrincipal().getName() : null; + if (!identity.isAnonymous() && name != null && !"anonymousUser".equals(name)) { + return "WEB"; + } } // Refresh endpoint may still be anonymous in SecurityContext; infer origin from a diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ByteHashFileIdStrategy.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ByteHashFileIdStrategy.java index a30d7bc562..ac160ec7f9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ByteHashFileIdStrategy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ByteHashFileIdStrategy.java @@ -5,15 +5,19 @@ import java.io.InputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; +import jakarta.enterprise.context.ApplicationScoped; + +import stirling.software.common.model.MultipartFile; /** * Content-addressable id derived from the SHA-256 hash of the uploaded bytes. Same content always * hashes to the same id, so re-uploads dedupe naturally in RAG. Suitable for session and SaaS * deployments; a folder-watch deployment would use a different strategy keyed by path. */ -@Component +// TODO: Migration required - the FileIdStrategy interface (collaborator file) still imports +// org.springframework.web.multipart.MultipartFile; it must be switched to +// stirling.software.common.model.MultipartFile so this implementation's signature matches. +@ApplicationScoped public class ByteHashFileIdStrategy implements FileIdStrategy { /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/FileIdStrategy.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/FileIdStrategy.java index b5e6169359..80d1e3828a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/FileIdStrategy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/FileIdStrategy.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.service; import java.io.IOException; -import org.springframework.web.multipart.MultipartFile; +import stirling.software.common.model.MultipartFile; /** * Produces stable identifiers for uploaded files. The identifier is opaque to the AI engine and diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java index 8ca6a83e7c..41c99c9363 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java @@ -11,7 +11,8 @@ import javax.imageio.ImageIO; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; -import org.springframework.stereotype.Service; + +import jakarta.enterprise.context.ApplicationScoped; import lombok.extern.slf4j.Slf4j; @@ -20,7 +21,7 @@ import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; @Slf4j -@Service +@ApplicationScoped public class ImageMagickLineArtConversionService implements LineArtConversionService { @Override diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/MathAuditorOrchestrator.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/MathAuditorOrchestrator.java index e8c6f7f6ba..9695ea2daf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/MathAuditorOrchestrator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/MathAuditorOrchestrator.java @@ -7,13 +7,14 @@ import java.util.Collections; import java.util.List; import java.util.UUID; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; + import org.apache.pdfbox.pdmodel.PDDocument; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.MultipartFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.model.api.ai.Evidence; @@ -40,7 +41,7 @@ import tools.jackson.databind.ObjectMapper; *

The raw PDF never leaves Java. Python only receives structured text and CSV data. */ @Slf4j -@Service +@ApplicationScoped public class MathAuditorOrchestrator { private static final String EXAMINE_PATH = "/api/v1/ai/math-auditor-agent/examine"; @@ -50,14 +51,14 @@ public class MathAuditorOrchestrator { private final CustomPDFDocumentFactory pdfDocumentFactory; private final PdfContentExtractor pdfContentExtractor; private final ObjectMapper objectMapper; - private final UserServiceInterface userService; + private final Instance userService; public MathAuditorOrchestrator( AiEngineClient aiEngineClient, CustomPDFDocumentFactory pdfDocumentFactory, PdfContentExtractor pdfContentExtractor, ObjectMapper objectMapper, - @Autowired(required = false) UserServiceInterface userService) { + Instance userService) { this.aiEngineClient = aiEngineClient; this.pdfDocumentFactory = pdfDocumentFactory; this.pdfContentExtractor = pdfContentExtractor; @@ -66,7 +67,7 @@ public class MathAuditorOrchestrator { } private String currentUserId() { - return userService != null ? userService.getCurrentUsername() : null; + return userService.isResolvable() ? userService.get().getCurrentUsername() : null; } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfCommentAgentOrchestrator.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfCommentAgentOrchestrator.java index 9fbcda00f8..8dd8d11107 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfCommentAgentOrchestrator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfCommentAgentOrchestrator.java @@ -10,14 +10,15 @@ import java.util.UUID; import org.apache.commons.io.FilenameUtils; import org.apache.pdfbox.pdmodel.PDDocument; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.server.ResponseStatusException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.MultipartFile; import stirling.software.common.model.api.comments.AnnotationLocation; import stirling.software.common.model.api.comments.StickyNoteSpec; import stirling.software.common.service.CustomPDFDocumentFactory; @@ -50,7 +51,7 @@ import tools.jackson.databind.ObjectMapper; * engine round-trip. */ @Slf4j -@Service +@ApplicationScoped public class PdfCommentAgentOrchestrator { private static final String GENERATE_PATH = "/api/v1/ai/pdf-comment-agent/generate"; @@ -80,7 +81,7 @@ public class PdfCommentAgentOrchestrator { private final CustomPDFDocumentFactory pdfDocumentFactory; private final ObjectMapper objectMapper; private final PdfAnnotationService pdfAnnotationService; - private final UserServiceInterface userService; + private final Instance userService; public PdfCommentAgentOrchestrator( AiEngineClient aiEngineClient, @@ -88,7 +89,7 @@ public class PdfCommentAgentOrchestrator { CustomPDFDocumentFactory pdfDocumentFactory, ObjectMapper objectMapper, PdfAnnotationService pdfAnnotationService, - @Autowired(required = false) UserServiceInterface userService) { + Instance userService) { this.aiEngineClient = aiEngineClient; this.pdfTextChunkExtractor = pdfTextChunkExtractor; this.pdfDocumentFactory = pdfDocumentFactory; @@ -98,7 +99,7 @@ public class PdfCommentAgentOrchestrator { } private String currentUserId() { - return userService != null ? userService.getCurrentUsername() : null; + return userService.isResolvable() ? userService.get().getCurrentUsername() : null; } /** @@ -112,12 +113,12 @@ public class PdfCommentAgentOrchestrator { AiToolInputValidator.validatePdfUpload(pdfFile); String trimmedPrompt = prompt == null ? "" : prompt.trim(); if (trimmedPrompt.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required"); + throw new WebApplicationException("Prompt is required", Response.Status.BAD_REQUEST); } if (trimmedPrompt.length() > MAX_PROMPT_LEN) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Prompt exceeds maximum length of " + MAX_PROMPT_LEN + " characters"); + throw new WebApplicationException( + "Prompt exceeds maximum length of " + MAX_PROMPT_LEN + " characters", + Response.Status.BAD_REQUEST); } String sessionId = UUID.randomUUID().toString(); @@ -130,8 +131,8 @@ public class PdfCommentAgentOrchestrator { try (PDDocument document = pdfDocumentFactory.load(pdfFile)) { List chunks = pdfTextChunkExtractor.extract(document); if (chunks.isEmpty()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "PDF has no extractable text"); + throw new WebApplicationException( + "PDF has no extractable text", Response.Status.BAD_REQUEST); } log.info( "[pdf-comment-agent] session={} extracted {} chunks across {} pages", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfContentExtractor.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfContentExtractor.java index 9dccb91f38..874f5f3e59 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfContentExtractor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfContentExtractor.java @@ -20,11 +20,12 @@ import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.text.TextPosition; -import org.springframework.stereotype.Service; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.enterprise.context.ApplicationScoped; + import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -41,7 +42,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection; import stirling.software.proprietary.model.api.ai.FolioType; @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class PdfContentExtractor { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfTextChunkExtractor.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfTextChunkExtractor.java index 346cb69c5b..fbeaedaccc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfTextChunkExtractor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfTextChunkExtractor.java @@ -9,7 +9,8 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.text.TextPosition; -import org.springframework.stereotype.Service; + +import jakarta.enterprise.context.ApplicationScoped; import lombok.extern.slf4j.Slf4j; @@ -21,7 +22,7 @@ import stirling.software.proprietary.model.api.ai.comments.TextChunk; * coordinates (origin = bottom-left). */ @Slf4j -@Service +@ApplicationScoped public class PdfTextChunkExtractor { /** Hard cap on total chunks emitted per document. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java index 01db79a5d8..b108c785ba 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java @@ -23,8 +23,10 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; @@ -33,7 +35,7 @@ import stirling.software.common.service.ServerCertificateServiceInterface; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; -@Service +@ApplicationScoped @Slf4j public class ServerCertificateService implements ServerCertificateServiceInterface { @@ -41,20 +43,25 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa private static final String KEYSTORE_ALIAS = "stirling-pdf-server"; private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert"; - @Value("${system.serverCertificate.enabled:false}") - private boolean enabled; + @Inject + @ConfigProperty(name = "system.serverCertificate.enabled", defaultValue = "false") + boolean enabled; - @Value("${system.serverCertificate.organizationName:Stirling-PDF}") - private String organizationName; + @Inject + @ConfigProperty(name = "system.serverCertificate.organizationName", defaultValue = "Stirling-PDF") + String organizationName; - @Value("${system.serverCertificate.validity:365}") - private int validityDays; + @Inject + @ConfigProperty(name = "system.serverCertificate.validity", defaultValue = "365") + int validityDays; - @Value("${system.serverCertificate.regenerateOnStartup:false}") - private boolean regenerateOnStartup; + @Inject + @ConfigProperty(name = "system.serverCertificate.regenerateOnStartup", defaultValue = "false") + boolean regenerateOnStartup; private final LicenseKeyChecker licenseKeyChecker; + @Inject public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) { this.licenseKeyChecker = licenseKeyChecker; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java index 3b7c0ed5dd..7d15d8268d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java @@ -13,7 +13,7 @@ import java.util.List; import java.util.regex.Pattern; import java.util.stream.Stream; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; import lombok.extern.slf4j.Slf4j; @@ -29,7 +29,7 @@ import tools.jackson.databind.ObjectMapper; * version enforces per-user quotas and requires authentication. Provides access to personal * signatures only (shared signatures handled by core service). */ -@Service +@ApplicationScoped @Slf4j public class SignatureService implements PersonalSignatureServiceInterface { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index 54660a1ccb..9e061212aa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -10,9 +10,9 @@ import java.util.UUID; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,7 +37,7 @@ import stirling.software.proprietary.security.service.UserService; *

  • Without pro license: grandfathered limit * */ -@Service +@ApplicationScoped @Slf4j @RequiredArgsConstructor public class UserLicenseSettingsService { @@ -49,7 +49,7 @@ public class UserLicenseSettingsService { private final UserLicenseSettingsRepository settingsRepository; private final UserService userService; private final ApplicationProperties applicationProperties; - private final ObjectProvider licenseKeyChecker; + private final Instance licenseKeyChecker; /** * Gets the current user license settings, creating them if they don't exist. @@ -547,7 +547,8 @@ public class UserLicenseSettingsService { } private boolean hasPaidLicense() { - LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable(); + LicenseKeyChecker checker = + licenseKeyChecker.isResolvable() ? licenseKeyChecker.get() : null; if (checker == null) { return false; } @@ -566,7 +567,8 @@ public class UserLicenseSettingsService { * @return true if ENTERPRISE license is active */ private boolean hasEnterpriseLicense() { - LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable(); + LicenseKeyChecker checker = + licenseKeyChecker.isResolvable() ? licenseKeyChecker.get() : null; if (checker == null) { return false; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/ClusterStorageGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/ClusterStorageGate.java index 07320a9ce7..56cdfc92da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/ClusterStorageGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/ClusterStorageGate.java @@ -3,10 +3,12 @@ package stirling.software.proprietary.storage.config; import java.util.Locale; import java.util.Optional; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; +import org.eclipse.microprofile.config.inject.ConfigProperty; -import jakarta.annotation.PostConstruct; +import io.quarkus.runtime.StartupEvent; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,7 +23,7 @@ import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker * that any S3-backed configuration ({@code storage.provider=s3} or {@code * cluster.artifactStore=s3}) is accompanied by a valid Pro / Enterprise license. */ -@Configuration +@ApplicationScoped @RequiredArgsConstructor @Slf4j public class ClusterStorageGate { @@ -29,13 +31,16 @@ public class ClusterStorageGate { private final ApplicationProperties applicationProperties; private final LicenseKeyChecker licenseKeyChecker; - @Value("${cluster.enabled:false}") - private boolean clusterEnabled; + @ConfigProperty(name = "cluster.enabled", defaultValue = "false") + boolean clusterEnabled; - @Value("${cluster.artifactStore:local}") - private String clusterArtifactStore; + @ConfigProperty(name = "cluster.artifactStore", defaultValue = "local") + String clusterArtifactStore; + + void onStart(@Observes StartupEvent event) { + validate(); + } - @PostConstruct void validate() { // License enforcement runs regardless of cluster.enabled: even a single-node setup that // selects a remote backend must hold a Pro or higher license. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java index e990fa2ceb..e02796b05b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java @@ -7,8 +7,10 @@ import java.nio.file.Paths; import java.util.Locale; import java.util.Optional; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Disposes; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,7 +25,7 @@ import stirling.software.proprietary.storage.provider.S3StorageProvider; import stirling.software.proprietary.storage.provider.StorageProvider; import stirling.software.proprietary.storage.repository.StoredFileBlobRepository; -@Configuration +@ApplicationScoped @RequiredArgsConstructor @Slf4j public class StorageProviderConfig { @@ -32,7 +34,8 @@ public class StorageProviderConfig { private final StoredFileBlobRepository storedFileBlobRepository; private final LicenseKeyChecker licenseKeyChecker; - @Bean(destroyMethod = "close") + @Produces + @Singleton public StorageProvider storageProvider() { boolean storageEnabled = applicationProperties.getStorage().isEnabled(); String providerName = @@ -81,6 +84,16 @@ public class StorageProviderConfig { return new LocalStorageProvider(basePath); } + // Replaces Spring's @Bean(destroyMethod = "close"): CDI invokes this disposer + // when the application-scoped StorageProvider is destroyed. + void closeStorageProvider(@Disposes StorageProvider storageProvider) { + try { + storageProvider.close(); + } catch (Exception e) { + log.warn("Failed to close storage provider", e); + } + } + private S3StorageProvider buildS3Provider(ApplicationProperties.Storage.S3 cfg) { S3Clients.Bundle bundle = S3Clients.build(cfg, "storage provider"); return new S3StorageProvider(bundle.client(), bundle.presigner(), cfg.getBucket()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java index b2f2cd0933..e02f7ae21a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java @@ -3,17 +3,14 @@ package stirling.software.proprietary.storage.controller; import java.util.List; import java.util.UUID; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - +import jakarta.enterprise.context.ApplicationScoped; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Response; import lombok.AllArgsConstructor; import lombok.Data; @@ -28,8 +25,8 @@ import stirling.software.proprietary.storage.service.FolderService; * Authentication, storage-gate, ownership checks, and the bulk cap all live on the service (where * {@code @Transactional} also lives) so the JDBC connection isn't held through JSON serialization. */ -@RestController -@RequestMapping("/api/v1/storage/files") +@ApplicationScoped +@Path("/api/v1/storage/files") @RequiredArgsConstructor public class FileFolderPlacementController { @@ -38,11 +35,12 @@ public class FileFolderPlacementController { private final FolderService folderService; /** Move a single file to a folder (or to root when folderId is null). */ - @PatchMapping("/{fileId}/folder") - public ResponseEntity moveFileToFolder( - @PathVariable Long fileId, @Valid @RequestBody FolderPlacement body) { + @PATCH + @Path("/{fileId}/folder") + public Response moveFileToFolder( + @PathParam("fileId") Long fileId, @Valid FolderPlacement body) { folderService.moveFileToFolder(fileId, body.getFolderId()); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } /** @@ -50,14 +48,16 @@ public class FileFolderPlacementController { * success, 207 (Multi-Status) when some files were skipped (typically because they don't belong * to the caller). */ - @PatchMapping("/folder") - public ResponseEntity bulkMove(@Valid @RequestBody BulkMoveRequest body) { + @PATCH + @Path("/folder") + public Response bulkMove(@Valid BulkMoveRequest body) { FolderService.BulkMoveResult result = folderService.bulkMoveFilesToFolder(body.getFolderId(), body.getFileIds()); - HttpStatus status = - result.skippedFileIds().isEmpty() ? HttpStatus.OK : HttpStatus.MULTI_STATUS; - return ResponseEntity.status(status) - .body(new BulkMoveResponse(result.movedFileIds(), result.skippedFileIds())); + // 207 Multi-Status has no Response.Status constant; use the numeric code directly. + int status = result.skippedFileIds().isEmpty() ? Response.Status.OK.getStatusCode() : 207; + return Response.status(status) + .entity(new BulkMoveResponse(result.movedFileIds(), result.skippedFileIds())) + .build(); } @Data diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java index 4eb299cd24..5eac857b29 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java @@ -1,36 +1,38 @@ package stirling.software.proprietary.storage.controller; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.time.Duration; import java.util.List; import java.util.Locale; import java.util.Optional; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -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.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.server.ResponseStatusException; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; + import lombok.extern.slf4j.Slf4j; +import io.quarkus.security.identity.SecurityIdentity; + +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.storage.model.FileShare; import stirling.software.proprietary.storage.model.StoredFile; @@ -43,9 +45,10 @@ import stirling.software.proprietary.storage.model.api.StoredFileResponse; import stirling.software.proprietary.storage.provider.StorageProvider; import stirling.software.proprietary.storage.service.FileStorageService; -@RestController -@RequestMapping("/api/v1/storage") -@RequiredArgsConstructor +// IMPORTANT: this class also references java.nio-style paths indirectly; @jakarta.ws.rs.Path is +// fully-qualified on the class/methods to avoid any clash with collaborator types. +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/storage") @Slf4j @Tag( name = "File Storage", @@ -54,74 +57,100 @@ public class FileStorageController { private static final Duration SIGNED_URL_TTL = Duration.ofMinutes(5); - private final FileStorageService fileStorageService; - private final StorageProvider storageProvider; + @Inject FileStorageService fileStorageService; + @Inject StorageProvider storageProvider; - @PostMapping( - value = "/files", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) + // TODO: Migration required - SecurityIdentity replaces Spring's Authentication. The collaborator + // FileStorageService still exposes canAccessShareLink(FileShare, org.springframework.security + // .core.Authentication) and recordShareAccess(FileShare, Authentication, boolean). Once that + // service is migrated those methods should accept SecurityIdentity (or io.quarkus.security + // SecurityContext) and this injected identity can be passed through directly. + @Inject SecurityIdentity securityIdentity; + + @POST + @jakarta.ws.rs.Path("/files") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) public StoredFileResponse uploadFile( - @RequestPart("file") MultipartFile file, - @RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle, - @RequestPart(name = "auditLog", required = false) MultipartFile auditLog) { + @RestForm("file") FileUpload file, + @RestForm("historyBundle") FileUpload historyBundle, + @RestForm("auditLog") FileUpload auditLog) { User user = fileStorageService.requireAuthenticatedUser(); - return fileStorageService.storeFileResponse(user, file, historyBundle, auditLog); + // TODO: Migration required - storeFileResponse(...) still accepts Spring + // org.springframework.web.multipart.MultipartFile. Migrate FileStorageService to accept + // stirling.software.common.model.MultipartFile, then this wrapping is type-compatible. + return fileStorageService.storeFileResponse( + user, + FileUploadMultipartFile.of(file), + FileUploadMultipartFile.of(historyBundle), + FileUploadMultipartFile.of(auditLog)); } - @PutMapping( - value = "/files/{fileId}", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) + @PUT + @jakarta.ws.rs.Path("/files/{fileId}") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) public StoredFileResponse updateFile( - @PathVariable Long fileId, - @RequestPart("file") MultipartFile file, - @RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle, - @RequestPart(name = "auditLog", required = false) MultipartFile auditLog) { + @jakarta.ws.rs.PathParam("fileId") Long fileId, + @RestForm("file") FileUpload file, + @RestForm("historyBundle") FileUpload historyBundle, + @RestForm("auditLog") FileUpload auditLog) { User user = fileStorageService.requireAuthenticatedUser(); - return fileStorageService.updateFileResponse(user, fileId, file, historyBundle, auditLog); + // TODO: Migration required - updateFileResponse(...) still accepts Spring MultipartFile; + // migrate FileStorageService to stirling.software.common.model.MultipartFile. + return fileStorageService.updateFileResponse( + user, + fileId, + FileUploadMultipartFile.of(file), + FileUploadMultipartFile.of(historyBundle), + FileUploadMultipartFile.of(auditLog)); } - @GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE) + @GET + @jakarta.ws.rs.Path("/files") + @Produces(MediaType.APPLICATION_JSON) public List listFiles() { User user = fileStorageService.requireAuthenticatedUser(); return fileStorageService.listAccessibleFileResponses(user); } - @GetMapping(value = "/files/{fileId}", produces = MediaType.APPLICATION_JSON_VALUE) - public StoredFileResponse getFileMetadata(@PathVariable Long fileId) { + @GET + @jakarta.ws.rs.Path("/files/{fileId}") + @Produces(MediaType.APPLICATION_JSON) + public StoredFileResponse getFileMetadata(@jakarta.ws.rs.PathParam("fileId") Long fileId) { User user = fileStorageService.requireAuthenticatedUser(); return fileStorageService.getAccessibleFileResponse(user, fileId); } - @GetMapping("/files/{fileId}/download") - public ResponseEntity downloadFile( - @PathVariable Long fileId, - @RequestParam(name = "inline", defaultValue = "false") boolean inline) { + @GET + @jakarta.ws.rs.Path("/files/{fileId}/download") + public Response downloadFile( + @jakarta.ws.rs.PathParam("fileId") Long fileId, + @QueryParam("inline") @jakarta.ws.rs.DefaultValue("false") boolean inline) { User user = fileStorageService.requireAuthenticatedUser(); StoredFile file = fileStorageService.getAccessibleFile(user, fileId); fileStorageService.requireReadAccess(user, file); - Optional> redirect = - tryRedirectToSignedUrl(file, inline); + Optional redirect = tryRedirectToSignedUrl(file, inline); return redirect.orElseGet(() -> buildFileResponse(file, inline)); } - @DeleteMapping("/files/{fileId}") - public ResponseEntity deleteFile(@PathVariable Long fileId) { + @DELETE + @jakarta.ws.rs.Path("/files/{fileId}") + public Response deleteFile(@jakarta.ws.rs.PathParam("fileId") Long fileId) { User user = fileStorageService.requireAuthenticatedUser(); StoredFile file = fileStorageService.getOwnedFile(user, fileId); fileStorageService.deleteFile(user, file); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } - @PostMapping( - value = "/files/{fileId}/shares/users", - produces = MediaType.APPLICATION_JSON_VALUE) + @POST + @jakarta.ws.rs.Path("/files/{fileId}/shares/users") + @Produces(MediaType.APPLICATION_JSON) public StoredFileResponse shareWithUser( - @PathVariable Long fileId, @RequestBody ShareWithUserRequest request) { + @jakarta.ws.rs.PathParam("fileId") Long fileId, ShareWithUserRequest request) { User owner = fileStorageService.requireAuthenticatedUser(); if (request == null || request.getUsername() == null || request.getUsername().isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required"); + throw new WebApplicationException("Username is required", Response.Status.BAD_REQUEST); } return fileStorageService.shareWithUserResponse( owner, @@ -130,28 +159,31 @@ public class FileStorageController { fileStorageService.normalizeShareRole(request.getAccessRole())); } - @DeleteMapping("/files/{fileId}/shares/users/{username}") - public ResponseEntity revokeUserShare( - @PathVariable Long fileId, @PathVariable String username) { + @DELETE + @jakarta.ws.rs.Path("/files/{fileId}/shares/users/{username}") + public Response revokeUserShare( + @jakarta.ws.rs.PathParam("fileId") Long fileId, + @jakarta.ws.rs.PathParam("username") String username) { User owner = fileStorageService.requireAuthenticatedUser(); StoredFile file = fileStorageService.getOwnedFile(owner, fileId); fileStorageService.revokeUserShare(owner, file, username); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } - @DeleteMapping("/files/{fileId}/shares/self") - public ResponseEntity leaveUserShare(@PathVariable Long fileId) { + @DELETE + @jakarta.ws.rs.Path("/files/{fileId}/shares/self") + public Response leaveUserShare(@jakarta.ws.rs.PathParam("fileId") Long fileId) { User user = fileStorageService.requireAuthenticatedUser(); StoredFile file = fileStorageService.getAccessibleFile(user, fileId); fileStorageService.leaveUserShare(user, file); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } - @PostMapping( - value = "/files/{fileId}/shares/links", - produces = MediaType.APPLICATION_JSON_VALUE) + @POST + @jakarta.ws.rs.Path("/files/{fileId}/shares/links") + @Produces(MediaType.APPLICATION_JSON) public ShareLinkResponse createShareLink( - @PathVariable Long fileId, @RequestBody CreateShareLinkRequest request) { + @jakarta.ws.rs.PathParam("fileId") Long fileId, CreateShareLinkRequest request) { User owner = fileStorageService.requireAuthenticatedUser(); StoredFile file = fileStorageService.getOwnedFile(owner, fileId); FileShare share = @@ -171,56 +203,63 @@ public class FileStorageController { .build(); } - @DeleteMapping("/files/{fileId}/shares/links/{token}") - public ResponseEntity revokeShareLink( - @PathVariable Long fileId, @PathVariable String token) { + @DELETE + @jakarta.ws.rs.Path("/files/{fileId}/shares/links/{token}") + public Response revokeShareLink( + @jakarta.ws.rs.PathParam("fileId") Long fileId, + @jakarta.ws.rs.PathParam("token") String token) { User owner = fileStorageService.requireAuthenticatedUser(); StoredFile file = fileStorageService.getOwnedFile(owner, fileId); fileStorageService.revokeShareLink(owner, file, token); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } - @GetMapping("/share-links/{token}") - public ResponseEntity downloadShareLink( - @PathVariable String token, - Authentication authentication, - @RequestParam(name = "inline", defaultValue = "false") boolean inline) { + @GET + @jakarta.ws.rs.Path("/share-links/{token}") + public Response downloadShareLink( + @jakarta.ws.rs.PathParam("token") String token, + @QueryParam("inline") @jakarta.ws.rs.DefaultValue("false") boolean inline) { fileStorageService.ensureShareLinksEnabled(); FileShare share = fileStorageService.getShareByToken(token); - if (!fileStorageService.canAccessShareLink(share, authentication)) { - HttpStatus status = - isAuthenticated(authentication) - ? HttpStatus.FORBIDDEN - : HttpStatus.UNAUTHORIZED; + // TODO: Migration required - canAccessShareLink/recordShareAccess still take Spring + // Authentication. Passing null preserves the anonymous-deny behavior until the service is + // migrated to SecurityIdentity; once migrated, pass `securityIdentity` through instead. + if (!fileStorageService.canAccessShareLink(share, null)) { + Response.Status status = + isAuthenticated() + ? Response.Status.FORBIDDEN + : Response.Status.UNAUTHORIZED; String message = - status == HttpStatus.FORBIDDEN + status == Response.Status.FORBIDDEN ? "Access denied for this share link" : "Authentication required for this share link"; - throw new ResponseStatusException(status, message); + throw new WebApplicationException(message, status); } fileStorageService.requireReadAccess(share); - fileStorageService.recordShareAccess(share, authentication, inline); + fileStorageService.recordShareAccess(share, null, inline); StoredFile file = share.getFile(); - Optional> redirect = - tryRedirectToSignedUrl(file, inline); + Optional redirect = tryRedirectToSignedUrl(file, inline); return redirect.orElseGet(() -> buildFileResponse(file, inline)); } - @GetMapping("/share-links/{token}/metadata") + @GET + @jakarta.ws.rs.Path("/share-links/{token}/metadata") public ShareLinkMetadataResponse getShareLinkMetadata( - @PathVariable String token, Authentication authentication) { + @jakarta.ws.rs.PathParam("token") String token) { fileStorageService.ensureShareLinksEnabled(); FileShare share = fileStorageService.getShareByToken(token); - if (!fileStorageService.canAccessShareLink(share, authentication)) { - HttpStatus status = - isAuthenticated(authentication) - ? HttpStatus.FORBIDDEN - : HttpStatus.UNAUTHORIZED; + // TODO: Migration required - canAccessShareLink still takes Spring Authentication; pass + // `securityIdentity` once FileStorageService is migrated. + if (!fileStorageService.canAccessShareLink(share, null)) { + Response.Status status = + isAuthenticated() + ? Response.Status.FORBIDDEN + : Response.Status.UNAUTHORIZED; String message = - status == HttpStatus.FORBIDDEN + status == Response.Status.FORBIDDEN ? "Access denied for this share link" : "Authentication required for this share link"; - throw new ResponseStatusException(status, message); + throw new WebApplicationException(message, status); } StoredFile file = share.getFile(); User currentUser = fileStorageService.requireAuthenticatedUser(); @@ -243,52 +282,67 @@ public class FileStorageController { .build(); } - @GetMapping("/share-links/accessed") + @GET + @jakarta.ws.rs.Path("/share-links/accessed") + @Produces(MediaType.APPLICATION_JSON) public List listAccessedShareLinks() { fileStorageService.ensureShareLinksEnabled(); User user = fileStorageService.requireAuthenticatedUser(); return fileStorageService.listAccessedShareLinkResponses(user); } - @GetMapping("/files/{fileId}/shares/links/{token}/accesses") + @GET + @jakarta.ws.rs.Path("/files/{fileId}/shares/links/{token}/accesses") + @Produces(MediaType.APPLICATION_JSON) public List listShareAccesses( - @PathVariable Long fileId, @PathVariable String token) { + @jakarta.ws.rs.PathParam("fileId") Long fileId, + @jakarta.ws.rs.PathParam("token") String token) { fileStorageService.ensureShareLinksEnabled(); User owner = fileStorageService.requireAuthenticatedUser(); StoredFile file = fileStorageService.getOwnedFile(owner, fileId); return fileStorageService.listShareAccessResponses(owner, file, token); } - private ResponseEntity buildFileResponse( - StoredFile file, boolean inline) { - org.springframework.core.io.Resource resource = fileStorageService.loadFile(file); + private Response buildFileResponse(StoredFile file, boolean inline) { + // TODO: Migration required - FileStorageService.loadFile(...) still returns Spring + // org.springframework.core.io.Resource. Once migrated to + // stirling.software.common.model.io.Resource the local variable type below can be the shim + // and the getInputStream()/contentLength() calls remain identical. + final org.springframework.core.io.Resource resource = fileStorageService.loadFile(file); String contentType = file.getContentType() == null - ? MediaType.APPLICATION_OCTET_STREAM_VALUE + ? MediaType.APPLICATION_OCTET_STREAM : file.getContentType(); - ContentDisposition disposition = - ContentDisposition.builder(inline ? "inline" : "attachment") - .filename(file.getOriginalFilename()) - .build(); - HttpHeaders headers = new HttpHeaders(); - headers.setContentDisposition(disposition); + MediaType mediaType; try { - headers.setContentType(MediaType.parseMediaType(contentType)); + mediaType = MediaType.valueOf(contentType); } catch (IllegalArgumentException ex) { - headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + mediaType = MediaType.APPLICATION_OCTET_STREAM_TYPE; } - headers.setContentLength(file.getSizeBytes()); - return ResponseEntity.ok().headers(headers).body(resource); + String disposition = + (inline ? "inline" : "attachment") + + "; filename=\"" + + file.getOriginalFilename() + + "\""; + StreamingOutput stream = + output -> { + try (InputStream in = resource.getInputStream()) { + in.transferTo(output); + } + }; + return Response.ok(stream, mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition) + .header(HttpHeaders.CONTENT_LENGTH, file.getSizeBytes()) + .build(); } - private boolean isAuthenticated(Authentication authentication) { - return authentication != null - && authentication.isAuthenticated() - && !"anonymousUser".equals(authentication.getPrincipal()); + private boolean isAuthenticated() { + // TODO: Migration required - Spring's Authentication-based anonymous check is replaced by + // SecurityIdentity. Verify "anonymous" semantics match once the security layer is migrated. + return securityIdentity != null && !securityIdentity.isAnonymous(); } - private Optional> tryRedirectToSignedUrl( - StoredFile file, boolean inline) { + private Optional tryRedirectToSignedUrl(StoredFile file, boolean inline) { if (file == null || file.getStorageKey() == null || file.getStorageKey().isBlank()) { return Optional.empty(); } @@ -302,10 +356,7 @@ public class FileStorageController { if (signed.isEmpty()) { return Optional.empty(); } - HttpHeaders headers = new HttpHeaders(); - headers.setLocation(signed.get()); - ResponseEntity response = - ResponseEntity.status(HttpStatus.FOUND).headers(headers).build(); + Response response = Response.status(Response.Status.FOUND).location(signed.get()).build(); return Optional.of(response); } catch (IOException e) { log.warn( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java index 3b4485a086..e1ecef2723 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java @@ -4,18 +4,18 @@ import java.net.URI; import java.util.List; import java.util.UUID; -import org.springframework.http.HttpStatus; -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.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - +import jakarta.enterprise.context.ApplicationScoped; import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; @@ -31,39 +31,47 @@ import stirling.software.proprietary.storage.service.FolderService; * {@link FileStorageController} are left alone so the cert-signing and standard upload flows are * unaffected. */ -@RestController -@RequestMapping("/api/v1/storage/folders") +@ApplicationScoped +@Path("/api/v1/storage/folders") @RequiredArgsConstructor public class FolderController { private final FolderService folderService; - @GetMapping + @GET + @Produces(MediaType.APPLICATION_JSON) public List listFolders() { return folderService.listFolders(); } - @PostMapping - public ResponseEntity createFolder( - @Valid @RequestBody CreateFolderRequest request) { + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response createFolder(@Valid CreateFolderRequest request) { FolderResponse response = folderService.createFolder(request); // 201 Created with Location header - conventional REST. The idempotent re-return path // (same id resubmitted) also lands here; treating it as 201 keeps wire semantics simple. - return ResponseEntity.status(HttpStatus.CREATED) + return Response.status(Response.Status.CREATED) .location(URI.create("/api/v1/storage/folders/" + response.id())) - .body(response); + .entity(response) + .build(); } - @PatchMapping("/{folderId}") - public ResponseEntity updateFolder( - @PathVariable UUID folderId, @Valid @RequestBody UpdateFolderRequest request) { - return ResponseEntity.ok(folderService.updateFolder(folderId, request)); + @PATCH + @Path("/{folderId}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response updateFolder( + @PathParam("folderId") UUID folderId, @Valid UpdateFolderRequest request) { + return Response.ok(folderService.updateFolder(folderId, request)).build(); } - @DeleteMapping("/{folderId}") - public ResponseEntity deleteFolder(@PathVariable UUID folderId) { + @DELETE + @Path("/{folderId}") + @Produces(MediaType.APPLICATION_JSON) + public Response deleteFolder(@PathParam("folderId") UUID folderId) { List removed = folderService.deleteFolder(folderId); - return ResponseEntity.ok(new DeleteFolderResponse(removed)); + return Response.ok(new DeleteFolderResponse(removed)).build(); } public record DeleteFolderResponse(List removedFolderIds) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/DatabaseStorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/DatabaseStorageProvider.java index ed0fdfce58..3dc612a3a6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/DatabaseStorageProvider.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/DatabaseStorageProvider.java @@ -1,19 +1,23 @@ package stirling.software.proprietary.storage.provider; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.UUID; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.core.io.Resource; -import org.springframework.web.multipart.MultipartFile; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.RequiredArgsConstructor; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.io.InputStreamResource; +import stirling.software.common.model.io.Resource; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.storage.model.StoredFileBlob; import stirling.software.proprietary.storage.repository.StoredFileBlobRepository; -@RequiredArgsConstructor +@ApplicationScoped +@RequiredArgsConstructor(onConstructor_ = @Inject) public class DatabaseStorageProvider implements StorageProvider { private final StoredFileBlobRepository storedFileBlobRepository; @@ -24,6 +28,8 @@ public class DatabaseStorageProvider implements StorageProvider { StoredFileBlob blob = new StoredFileBlob(); blob.setStorageKey(storageKey); blob.setData(file.getBytes()); + // TODO: Migration required - StoredFileBlobRepository must extend Panache + // PanacheRepositoryBase; once migrated, save(blob) -> persist(blob). storedFileBlobRepository.save(blob); return StoredObject.builder() @@ -36,15 +42,20 @@ public class DatabaseStorageProvider implements StorageProvider { @Override public Resource load(String storageKey) throws IOException { + // TODO: Migration required - once StoredFileBlobRepository is a Panache repository, + // findById(storageKey) -> findByIdOptional(storageKey). StoredFileBlob blob = storedFileBlobRepository .findById(storageKey) .orElseThrow(() -> new IOException("File not found")); - return new ByteArrayResource(blob.getData()); + // Quarkus/Jakarta has no Spring ByteArrayResource; use the common InputStreamResource shim. + return new InputStreamResource(new ByteArrayInputStream(blob.getData()), storageKey); } @Override public void delete(String storageKey) throws IOException { + // TODO: Migration required - once StoredFileBlobRepository is a Panache repository, + // existsById(storageKey) -> count("storageKey", storageKey) > 0. if (!storedFileBlobRepository.existsById(storageKey)) { return; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java index 75f9eb3fd5..c52ad98ead 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java @@ -9,12 +9,11 @@ import java.nio.file.StandardCopyOption; import java.util.Optional; import java.util.UUID; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.web.multipart.MultipartFile; - import lombok.RequiredArgsConstructor; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.io.FileSystemResource; +import stirling.software.common.model.io.Resource; import stirling.software.proprietary.security.model.User; @RequiredArgsConstructor diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/S3StorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/S3StorageProvider.java index a1582458bc..7b95c4ca7f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/S3StorageProvider.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/S3StorageProvider.java @@ -9,12 +9,11 @@ import java.time.Duration; import java.util.Optional; import java.util.UUID; -import org.springframework.core.io.InputStreamResource; -import org.springframework.core.io.Resource; -import org.springframework.web.multipart.MultipartFile; - import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.io.InputStreamResource; +import stirling.software.common.model.io.Resource; import stirling.software.proprietary.security.model.User; import software.amazon.awssdk.core.ResponseInputStream; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java index cc659c1b9f..bbcb3b226b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java @@ -5,9 +5,8 @@ import java.net.URI; import java.time.Duration; import java.util.Optional; -import org.springframework.core.io.Resource; -import org.springframework.web.multipart.MultipartFile; - +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.io.Resource; import stirling.software.proprietary.security.model.User; public interface StorageProvider extends AutoCloseable { @@ -19,8 +18,8 @@ public interface StorageProvider extends AutoCloseable { /** * Releases any backend-specific resources. Default no-op so {@link LocalStorageProvider} and - * {@link DatabaseStorageProvider} (which hold no closeable handles) satisfy Spring's - * {@code @Bean(destroyMethod = "close")} signature requirement without ceremony. {@code + * {@link DatabaseStorageProvider} (which hold no closeable handles) satisfy the + * {@code @PreDestroy}/producer disposer close signature requirement without ceremony. {@code * S3StorageProvider} overrides this to close the underlying SDK client + presigner. */ @Override diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareAccessRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareAccessRepository.java index 1affa2229e..9224399d75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareAccessRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareAccessRepository.java @@ -2,33 +2,52 @@ package stirling.software.proprietary.storage.repository; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.storage.model.FileShare; import stirling.software.proprietary.storage.model.FileShareAccess; -public interface FileShareAccessRepository extends JpaRepository { - @Query( - "SELECT a FROM FileShareAccess a " - + "LEFT JOIN FETCH a.user " - + "WHERE a.fileShare = :fileShare " - + "ORDER BY a.accessedAt DESC") - List findByFileShareWithUserOrderByAccessedAtDesc( - @Param("fileShare") FileShare fileShare); +/** + * Quarkus Panache repository for {@link FileShareAccess}. + * + *

    Migrated from a Spring Data {@code JpaRepository}. The {@code @Query} + * methods keep their original JPQL strings passed to Panache {@code list}, and the derived + * {@code deleteByXxx} finders become Panache {@code delete} calls. + */ +@ApplicationScoped +public class FileShareAccessRepository implements PanacheRepositoryBase { - void deleteByFileShare(FileShare fileShare); + public List findByFileShareWithUserOrderByAccessedAtDesc(FileShare fileShare) { + return list( + "SELECT a FROM FileShareAccess a " + + "LEFT JOIN FETCH a.user " + + "WHERE a.fileShare = ?1 " + + "ORDER BY a.accessedAt DESC", + fileShare); + } - void deleteByUser(User user); + @Transactional + public void deleteByFileShare(FileShare fileShare) { + delete("fileShare", fileShare); + } - @Query( - "SELECT a FROM FileShareAccess a " - + "JOIN FETCH a.fileShare s " - + "JOIN FETCH s.file f " - + "LEFT JOIN FETCH f.owner " - + "WHERE a.user = :user " - + "ORDER BY a.accessedAt DESC") - List findByUserWithShareAndFile(@Param("user") User user); + @Transactional + public void deleteByUser(User user) { + delete("user", user); + } + + public List findByUserWithShareAndFile(User user) { + return list( + "SELECT a FROM FileShareAccess a " + + "JOIN FETCH a.fileShare s " + + "JOIN FETCH s.file f " + + "LEFT JOIN FETCH f.owner " + + "WHERE a.user = ?1 " + + "ORDER BY a.accessedAt DESC", + user); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareRepository.java index 5855f893fd..34ccd1b82d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareRepository.java @@ -3,37 +3,58 @@ package stirling.software.proprietary.storage.repository; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.storage.model.FileShare; import stirling.software.proprietary.storage.model.StoredFile; -public interface FileShareRepository extends JpaRepository { - Optional findByFileAndSharedWithUser(StoredFile file, User sharedWithUser); +@ApplicationScoped +public class FileShareRepository implements PanacheRepository { - Optional findByShareToken(String shareToken); + public Optional findByFileAndSharedWithUser(StoredFile file, User sharedWithUser) { + return find("file = ?1 and sharedWithUser = ?2", file, sharedWithUser) + .firstResultOptional(); + } - @Query( - "SELECT s FROM FileShare s " - + "JOIN FETCH s.file f " - + "LEFT JOIN FETCH f.owner " - + "WHERE s.shareToken = :shareToken") - Optional findByShareTokenWithFile(@Param("shareToken") String shareToken); + public Optional findByShareToken(String shareToken) { + return find("shareToken", shareToken).firstResultOptional(); + } - @Query("SELECT s FROM FileShare s WHERE s.file = :file AND s.shareToken IS NOT NULL") - List findShareLinks(@Param("file") StoredFile file); + public Optional findByShareTokenWithFile(String shareToken) { + return find( + "SELECT s FROM FileShare s " + + "JOIN FETCH s.file f " + + "LEFT JOIN FETCH f.owner " + + "WHERE s.shareToken = ?1", + shareToken) + .firstResultOptional(); + } - List findBySharedWithUser(User sharedWithUser); + public List findShareLinks(StoredFile file) { + return find( + "SELECT s FROM FileShare s WHERE s.file = ?1 AND s.shareToken IS NOT NULL", + file) + .list(); + } - List findByExpiresAtBeforeAndShareTokenNotNull(java.time.LocalDateTime now); + public List findBySharedWithUser(User sharedWithUser) { + return find("sharedWithUser", sharedWithUser).list(); + } - @Query( - "SELECT s FROM FileShare s " - + "JOIN FETCH s.file f " - + "WHERE s.sharedWithUser = :user AND f IN :files") - List findBySharedWithUserAndFileIn( - @Param("user") User user, @Param("files") List files); + public List findByExpiresAtBeforeAndShareTokenNotNull(java.time.LocalDateTime now) { + return find("expiresAt < ?1 and shareToken is not null", now).list(); + } + + public List findBySharedWithUserAndFileIn(User user, List files) { + return find( + "SELECT s FROM FileShare s " + + "JOIN FETCH s.file f " + + "WHERE s.sharedWithUser = ?1 AND f IN ?2", + user, + files) + .list(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java index 141223d92a..2c47c224f0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java @@ -4,32 +4,80 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.storage.model.Folder; -public interface FolderRepository extends JpaRepository { +/** + * Quarkus Panache repository for {@link Folder}. + * + *

    Migrated from a Spring Data {@code JpaRepository}. Derived finders are + * reimplemented as Panache queries and the original {@code @Modifying @Query} bulk UPDATE keeps its + * JPQL string passed to Panache {@code update}. + */ +@ApplicationScoped +public class FolderRepository implements PanacheRepositoryBase { - Optional findByIdAndOwner(UUID id, User owner); + public Optional findByIdAndOwner(UUID id, User owner) { + return find("id = ?1 and owner = ?2", id, owner).firstResultOptional(); + } - List findAllByOwnerOrderByName(User owner); + public List findAllByOwnerOrderByName(User owner) { + return list("owner = ?1 order by name", owner); + } - long countByOwner(User owner); + public long countByOwner(User owner) { + return count("owner", owner); + } + + /** Spring Data {@code existsById(id)} -> Panache count by id. */ + public boolean existsById(UUID id) { + return count("id", id) > 0; + } + + /** + * Spring Data {@code saveAndFlush(folder)}. Panache {@code persist} handles both insert and + * (for a managed/attached entity) the dirty-checking update; the explicit {@code flush} keeps + * the original eager-flush semantics the callers relied on. + */ + @Transactional + public Folder saveAndFlush(Folder folder) { + persist(folder); + flush(); + return folder; + } + + /** Spring Data {@code deleteAllByIdInBatch(ids)} -> Panache bulk delete by id collection. */ + @Transactional + public void deleteAllByIdInBatch(List ids) { + if (ids == null || ids.isEmpty()) { + return; + } + delete("id in ?1", ids); + } /** * Clear the folder reference on every file currently inside any of the given folders. Used when * a folder subtree is deleted - files fall back to the root rather than dangling. * - *

    {@code flushAutomatically + clearAutomatically} forces Hibernate to flush any cached dirty - * {@code StoredFile} entities before the bulk UPDATE runs, and clears the persistence context - * afterwards so a subsequent {@code deleteAllByIdInBatch} on the parent folders doesn't see - * stale entity state referencing the about-to-be-deleted folder. + *

    The original Spring Data method used {@code @Modifying(flushAutomatically = true, + * clearAutomatically = true)} to flush cached dirty {@code StoredFile} entities before the bulk + * UPDATE and clear the persistence context afterwards, so a subsequent {@code + * deleteAllByIdInBatch} on the parent folders wouldn't see stale state referencing the + * about-to-be-deleted folder. We reproduce that here with an explicit flush before and a clear + * after the Panache bulk {@code update}. */ - @Modifying(flushAutomatically = true, clearAutomatically = true) - @Query("UPDATE StoredFile sf SET sf.folder = null WHERE sf.folder.id IN :folderIds") - void clearFolderForFiles(@Param("folderIds") List folderIds); + @Transactional + public void clearFolderForFiles(List folderIds) { + if (folderIds == null || folderIds.isEmpty()) { + return; + } + getEntityManager().flush(); + update("UPDATE StoredFile sf SET sf.folder = null WHERE sf.folder.id IN ?1", folderIds); + getEntityManager().clear(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StorageCleanupEntryRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StorageCleanupEntryRepository.java index cff3a1ac13..a9266d6b11 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StorageCleanupEntryRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StorageCleanupEntryRepository.java @@ -2,10 +2,18 @@ package stirling.software.proprietary.storage.repository; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; + +import jakarta.enterprise.context.ApplicationScoped; import stirling.software.proprietary.storage.model.StorageCleanupEntry; -public interface StorageCleanupEntryRepository extends JpaRepository { - List findTop50ByOrderByUpdatedAtAsc(); +@ApplicationScoped +public class StorageCleanupEntryRepository implements PanacheRepository { + + public List findTop50ByOrderByUpdatedAtAsc() { + return find("", Sort.by("updatedAt").ascending()).page(Page.ofSize(50)).list(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileBlobRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileBlobRepository.java index 811a464089..c910685bc7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileBlobRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileBlobRepository.java @@ -1,7 +1,17 @@ package stirling.software.proprietary.storage.repository; -import org.springframework.data.jpa.repository.JpaRepository; +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import stirling.software.proprietary.storage.model.StoredFileBlob; -public interface StoredFileBlobRepository extends JpaRepository {} +/** + * Quarkus Panache repository for {@link StoredFileBlob}. + * + *

    Migrated from a Spring Data {@code JpaRepository}. The String id + * keeps {@link PanacheRepositoryBase} with {@code }; callers map + * {@code save -> persist}, {@code findById -> findByIdOptional}, {@code deleteById -> deleteById}. + */ +@ApplicationScoped +public class StoredFileBlobRepository implements PanacheRepositoryBase {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java index 47545ab58a..2203d8b6d8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java @@ -3,74 +3,143 @@ package stirling.software.proprietary.storage.repository; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.transaction.annotation.Transactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.storage.model.StoredFile; import stirling.software.proprietary.workflow.model.WorkflowSession; -public interface StoredFileRepository extends JpaRepository { - Optional findByIdAndOwner(Long id, User owner); +/** + * Quarkus Panache repository for {@link StoredFile}. + * + *

    Migrated from a Spring Data {@code JpaRepository}. Derived finders are + * reimplemented as Panache queries; each original {@code @Query} keeps its JPQL string. The + * {@code @Modifying @Query} bulk UPDATE keeps its JPQL passed to Panache {@code update}. Spring + * Data CRUD helpers the callers relied on ({@code save}, {@code saveAll}, {@code deleteAll}) are + * provided as thin shims over the Panache API so collaborating services compile unchanged. + */ +@ApplicationScoped +public class StoredFileRepository implements PanacheRepository { - @Query( - "SELECT DISTINCT f FROM StoredFile f " - + "LEFT JOIN FETCH f.owner " - + "LEFT JOIN FETCH f.shares s " - + "LEFT JOIN FETCH s.sharedWithUser " - + "WHERE f.id = :id AND f.owner = :owner") - Optional findByIdAndOwnerWithShares( - @Param("id") Long id, @Param("owner") User owner); + public Optional findByIdAndOwner(Long id, User owner) { + return find("id = ?1 and owner = ?2", id, owner).firstResultOptional(); + } - @Query( - "SELECT DISTINCT f FROM StoredFile f " - + "LEFT JOIN FETCH f.owner " - + "LEFT JOIN FETCH f.shares s " - + "LEFT JOIN FETCH s.sharedWithUser " - + "WHERE f.id = :id") - Optional findByIdWithShares(@Param("id") Long id); + public Optional findByIdAndOwnerWithShares(Long id, User owner) { + return find( + "SELECT DISTINCT f FROM StoredFile f " + + "LEFT JOIN FETCH f.owner " + + "LEFT JOIN FETCH f.shares s " + + "LEFT JOIN FETCH s.sharedWithUser " + + "WHERE f.id = ?1 AND f.owner = ?2", + id, + owner) + .firstResultOptional(); + } - @Query( - "SELECT DISTINCT f FROM StoredFile f " - + "LEFT JOIN FETCH f.owner " - + "LEFT JOIN FETCH f.shares s " - + "LEFT JOIN FETCH s.sharedWithUser " - + "WHERE f.owner = :user " - + "OR s.sharedWithUser = :user") - List findAccessibleFiles(@Param("user") User user); + public Optional findByIdWithShares(Long id) { + return find( + "SELECT DISTINCT f FROM StoredFile f " + + "LEFT JOIN FETCH f.owner " + + "LEFT JOIN FETCH f.shares s " + + "LEFT JOIN FETCH s.sharedWithUser " + + "WHERE f.id = ?1", + id) + .firstResultOptional(); + } - @Query( - "SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) " - + "+ COALESCE(f.auditLogSizeBytes, 0)), 0) " - + "FROM StoredFile f WHERE f.owner = :owner") - long sumStorageBytesByOwner(@Param("owner") User owner); + public List findAccessibleFiles(User user) { + return find( + "SELECT DISTINCT f FROM StoredFile f " + + "LEFT JOIN FETCH f.owner " + + "LEFT JOIN FETCH f.shares s " + + "LEFT JOIN FETCH s.sharedWithUser " + + "WHERE f.owner = ?1 " + + "OR s.sharedWithUser = ?1", + user) + .list(); + } - @Query( - "SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) " - + "+ COALESCE(f.auditLogSizeBytes, 0)), 0) " - + "FROM StoredFile f") - long sumStorageBytesTotal(); + public long sumStorageBytesByOwner(User owner) { + return find( + "SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) " + + "+ COALESCE(f.auditLogSizeBytes, 0)), 0) " + + "FROM StoredFile f WHERE f.owner = ?1", + owner) + .project(Long.class) + .firstResult(); + } + + public long sumStorageBytesTotal() { + return find( + "SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) " + + "+ COALESCE(f.auditLogSizeBytes, 0)), 0) " + + "FROM StoredFile f") + .project(Long.class) + .firstResult(); + } /** Finds all files associated with a workflow session. */ - List findByWorkflowSession(WorkflowSession workflowSession); + public List findByWorkflowSession(WorkflowSession workflowSession) { + return find("workflowSession", workflowSession).list(); + } - List findAllByOwner(User owner); + public List findAllByOwner(User owner) { + return find("owner", owner).list(); + } /** * Bulk lookup used by the folder-placement controller. Returns only files owned by {@code * owner}; ids that don't exist or that belong to another user are silently dropped so the * caller can compute the "skipped" set by subtraction. */ - List findAllByIdInAndOwner(List ids, User owner); + public List findAllByIdInAndOwner(List ids, User owner) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return find("id in ?1 and owner = ?2", ids, owner).list(); + } - @Modifying @Transactional - @Query( - "UPDATE StoredFile sf SET sf.workflowSession = null " - + "WHERE sf.workflowSession IN " - + "(SELECT ws FROM WorkflowSession ws WHERE ws.owner = :user)") - void clearWorkflowSessionReferencesByOwner(@Param("user") User user); + public void clearWorkflowSessionReferencesByOwner(User user) { + update( + "UPDATE StoredFile sf SET sf.workflowSession = null " + + "WHERE sf.workflowSession IN " + + "(SELECT ws FROM WorkflowSession ws WHERE ws.owner = ?1)", + user); + } + + // --- Spring Data CRUD shims kept so collaborating services compile unchanged --- + + /** + * Spring Data {@code save(file)}. Panache {@code persist} inserts a new entity and relies on + * dirty-checking to update a managed one; the returned instance keeps the original + * save-returns-entity contract. + */ + @Transactional + public StoredFile save(StoredFile file) { + persist(file); + return file; + } + + /** Spring Data {@code saveAll(files)} -> Panache {@code persist} over the collection. */ + @Transactional + public List saveAll(Iterable files) { + persist(files); + if (files instanceof List list) { + return list; + } + java.util.List result = new java.util.ArrayList<>(); + files.forEach(result::add); + return result; + } + + /** Spring Data {@code deleteAll(files)} -> delete each managed entity. */ + @Transactional + public void deleteAll(Iterable files) { + files.forEach(this::delete); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java index 03c3a9178f..4f7b57f3e1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java @@ -16,20 +16,28 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.springframework.http.HttpStatus; +// TODO: Migration required - Spring Security glue retained. SecurityContextHolder / +// org.springframework.security.core.Authentication back the public auth helpers +// (requireAuthenticatedUser, canAccessShareLink, recordShareAccess, extractAuthenticatedUser), +// which controllers in this module still call with a Spring Authentication. Replace with +// io.quarkus.security.identity.SecurityIdentity (injected) or @Context +// jakarta.ws.rs.core.SecurityContext once the storage controllers are migrated; the principal is +// expected to be a stirling.software.proprietary.security.model.User instance. import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.server.ResponseStatusException; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.mail.MessagingException; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.io.Resource; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.EmailService; @@ -51,7 +59,7 @@ import stirling.software.proprietary.storage.repository.FileShareRepository; import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; import stirling.software.proprietary.storage.repository.StoredFileRepository; -@Service +@ApplicationScoped @Transactional @RequiredArgsConstructor @Slf4j @@ -72,11 +80,10 @@ public class FileStorageService { public void ensureStorageEnabled() { if (!applicationProperties.getSecurity().isEnableLogin()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Storage requires login to be enabled"); + throw new WebApplicationException("Storage requires login to be enabled", Response.Status.FORBIDDEN); } if (!applicationProperties.getStorage().isEnabled()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Storage is disabled"); + throw new WebApplicationException("Storage is disabled", Response.Status.FORBIDDEN); } } @@ -85,7 +92,7 @@ public class FileStorageService { if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + throw new WebApplicationException("Not authenticated", Response.Status.UNAUTHORIZED); } Object principal = authentication.getPrincipal(); @@ -93,7 +100,7 @@ public class FileStorageService { return user; } - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unsupported user principal"); + throw new WebApplicationException("Unsupported user principal", Response.Status.UNAUTHORIZED); } /** @@ -164,8 +171,7 @@ public class FileStorageService { file != null ? file.getOriginalFilename() : null, file != null ? file.getSize() : null, e); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Failed to store file", e); + throw new WebApplicationException("Failed to store file", e, Response.Status.INTERNAL_SERVER_ERROR); } } @@ -181,7 +187,7 @@ public class FileStorageService { MultipartFile auditLog) { ensureStorageEnabled(); if (!isOwner(existing, owner)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can update"); + throw new WebApplicationException("Only the owner can update", Response.Status.FORBIDDEN); } validateMainUpload(file); @@ -242,8 +248,7 @@ public class FileStorageService { existing.getId(), owner != null ? owner.getId() : null, e); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Failed to update file", e); + throw new WebApplicationException("Failed to update file", e, Response.Status.INTERNAL_SERVER_ERROR); } } @@ -254,8 +259,7 @@ public class FileStorageService { .findByIdWithShares(fileId) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "File not found")); + new WebApplicationException("File not found", Response.Status.NOT_FOUND)); if (isOwner(file, user)) { return file; } @@ -269,7 +273,7 @@ public class FileStorageService { .getId() .equals(user.getId())); if (!sharedWithUser) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + throw new WebApplicationException("Access denied", Response.Status.FORBIDDEN); } return file; @@ -281,16 +285,14 @@ public class FileStorageService { } ShareAccessRole role = resolveUserShareRole(file, user); if (role != ShareAccessRole.EDITOR) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Insufficient permissions to download"); + throw new WebApplicationException("Insufficient permissions to download", Response.Status.FORBIDDEN); } } public void requireEditorAccess(FileShare share) { ShareAccessRole role = resolveShareRole(share); if (role != ShareAccessRole.EDITOR) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Insufficient permissions to download"); + throw new WebApplicationException("Insufficient permissions to download", Response.Status.FORBIDDEN); } } @@ -300,16 +302,14 @@ public class FileStorageService { } ShareAccessRole role = resolveUserShareRole(file, user); if (!hasReadAccess(role)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Insufficient permissions to access this file"); + throw new WebApplicationException("Insufficient permissions to access this file", Response.Status.FORBIDDEN); } } public void requireReadAccess(FileShare share) { ShareAccessRole role = resolveShareRole(share); if (!hasReadAccess(role)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Insufficient permissions to access this file"); + throw new WebApplicationException("Insufficient permissions to access this file", Response.Status.FORBIDDEN); } } @@ -318,7 +318,7 @@ public class FileStorageService { return storedFileRepository .findByIdAndOwnerWithShares(fileId, owner) .orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "File not found")); + () -> new WebApplicationException("File not found", Response.Status.NOT_FOUND)); } public StoredFileResponse storeFileResponse(User owner, MultipartFile file) { @@ -470,7 +470,7 @@ public class FileStorageService { try { return ShareAccessRole.valueOf(role.trim().toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException ex) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid share role"); + throw new WebApplicationException("Invalid share role", Response.Status.BAD_REQUEST); } } @@ -489,7 +489,7 @@ public class FileStorageService { return share.map(this::resolveShareRole).orElse(ShareAccessRole.VIEWER); } - public org.springframework.core.io.Resource loadFile(StoredFile file) { + public Resource loadFile(StoredFile file) { ensureStorageEnabled(); try { return storageProvider.load(file.getStorageKey()); @@ -499,15 +499,14 @@ public class FileStorageService { file != null ? file.getId() : null, file != null ? file.getStorageKey() : null, e); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Failed to load file", e); + throw new WebApplicationException("Failed to load file", e, Response.Status.INTERNAL_SERVER_ERROR); } } public void deleteFile(User owner, StoredFile file) { ensureStorageEnabled(); if (!isOwner(file, owner)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can delete"); + throw new WebApplicationException("Only the owner can delete", Response.Status.FORBIDDEN); } validateWorkflowDeletion(file, owner); List storageKeys = collectStorageKeys(file); @@ -526,7 +525,7 @@ public class FileStorageService { ensureStorageEnabled(); ensureSharingEnabled(); if (!isOwner(file, owner)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can share"); + throw new WebApplicationException("Only the owner can share", Response.Status.FORBIDDEN); } String normalizedUsername = username != null ? username.trim() : ""; @@ -536,8 +535,7 @@ public class FileStorageService { if (targetUserOpt.isPresent()) { User targetUser = targetUserOpt.get(); if (targetUser.getId().equals(owner.getId())) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Cannot share with yourself"); + throw new WebApplicationException("Cannot share with yourself", Response.Status.BAD_REQUEST); } FileShare share = @@ -559,13 +557,10 @@ public class FileStorageService { if (isEmail) { if (!isEmailSharingEnabled()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Email sharing is disabled"); + throw new WebApplicationException("Email sharing is disabled", Response.Status.BAD_REQUEST); } if (!isShareLinksEnabled()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Share links must be enabled for email sharing"); + throw new WebApplicationException("Share links must be enabled for email sharing", Response.Status.BAD_REQUEST); } String shareLinkUrl = null; FileShare linkShare = createShareLink(owner, file, role); @@ -577,14 +572,13 @@ public class FileStorageService { } if (!isEmail) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"); + throw new WebApplicationException("User not found", Response.Status.NOT_FOUND); } if (!isEmailSharingEnabled()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email sharing is disabled"); + throw new WebApplicationException("Email sharing is disabled", Response.Status.BAD_REQUEST); } if (!isShareLinksEnabled()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Share links must be enabled for email sharing"); + throw new WebApplicationException("Share links must be enabled for email sharing", Response.Status.BAD_REQUEST); } FileShare linkShare = createShareLink(owner, file, role); @@ -595,15 +589,14 @@ public class FileStorageService { public void revokeUserShare(User owner, StoredFile file, String username) { ensureStorageEnabled(); if (!isOwner(file, owner)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can revoke"); + throw new WebApplicationException("Only the owner can revoke", Response.Status.FORBIDDEN); } User targetUser = userRepository .findByUsernameIgnoreCase(username) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "User not found")); + new WebApplicationException("User not found", Response.Status.NOT_FOUND)); fileShareRepository .findByFileAndSharedWithUser(file, targetUser) .ifPresent(fileShareRepository::delete); @@ -612,16 +605,14 @@ public class FileStorageService { public void leaveUserShare(User user, StoredFile file) { ensureStorageEnabled(); if (isOwner(file, user)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Owners cannot leave their own file"); + throw new WebApplicationException("Owners cannot leave their own file", Response.Status.FORBIDDEN); } FileShare share = fileShareRepository .findByFileAndSharedWithUser(file, user) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "Share not found")); + new WebApplicationException("Share not found", Response.Status.NOT_FOUND)); fileShareRepository.delete(share); } @@ -629,7 +620,7 @@ public class FileStorageService { ensureStorageEnabled(); ensureShareLinksEnabled(); if (!isOwner(file, owner)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can share"); + throw new WebApplicationException("Only the owner can share", Response.Status.FORBIDDEN); } FileShare share = new FileShare(); @@ -643,17 +634,16 @@ public class FileStorageService { public void revokeShareLink(User owner, StoredFile file, String token) { ensureStorageEnabled(); if (!isOwner(file, owner)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can revoke"); + throw new WebApplicationException("Only the owner can revoke", Response.Status.FORBIDDEN); } FileShare share = fileShareRepository .findByShareToken(token) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "Share link not found")); + new WebApplicationException("Share link not found", Response.Status.NOT_FOUND)); if (!share.getFile().getId().equals(file.getId())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Share link mismatch"); + throw new WebApplicationException("Share link mismatch", Response.Status.FORBIDDEN); } fileShareAccessRepository.deleteByFileShare(share); fileShareRepository.delete(share); @@ -666,11 +656,10 @@ public class FileStorageService { .findByShareTokenWithFile(token) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "Share link not found")); + new WebApplicationException("Share link not found", Response.Status.NOT_FOUND)); if (isShareLinkExpired(share)) { log.debug("Share link access denied: token is expired"); - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Share link not found"); + throw new WebApplicationException("Share link not found", Response.Status.NOT_FOUND); } return share; } @@ -733,18 +722,16 @@ public class FileStorageService { public List listShareAccesses(User owner, StoredFile file, String token) { ensureStorageEnabled(); if (!isOwner(file, owner)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Only the owner can view access"); + throw new WebApplicationException("Only the owner can view access", Response.Status.FORBIDDEN); } FileShare share = fileShareRepository .findByShareToken(token) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, "Share link not found")); + new WebApplicationException("Share link not found", Response.Status.NOT_FOUND)); if (!share.getFile().getId().equals(file.getId())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Share link mismatch"); + throw new WebApplicationException("Share link mismatch", Response.Status.FORBIDDEN); } return fileShareAccessRepository.findByFileShareWithUserOrderByAccessedAtDesc(share); } @@ -824,14 +811,14 @@ public class FileStorageService { public void ensureSharingEnabled() { ensureStorageEnabled(); if (!applicationProperties.getStorage().getSharing().isEnabled()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Sharing is disabled"); + throw new WebApplicationException("Sharing is disabled", Response.Status.FORBIDDEN); } } public void ensureShareLinksEnabled() { ensureSharingEnabled(); if (!isShareLinksEnabled()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Share links are disabled"); + throw new WebApplicationException("Share links are disabled", Response.Status.FORBIDDEN); } } @@ -877,13 +864,12 @@ public class FileStorageService { private void validateMainUpload(MultipartFile file) { if (!isValidUpload(file)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is required"); + throw new WebApplicationException("File is required", Response.Status.BAD_REQUEST); } String contentType = file.getContentType(); if (contentType != null && BLOCKED_CONTENT_TYPES.contains(contentType.toLowerCase(Locale.ROOT))) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "File type not permitted: " + contentType); + throw new WebApplicationException("File type not permitted: " + contentType, Response.Status.BAD_REQUEST); } } @@ -943,8 +929,7 @@ public class FileStorageService { } long maxFileBytes = toBytes(quotas.getMaxFileMb()); if (maxFileBytes > 0 && newBytes > maxFileBytes) { - throw new ResponseStatusException( - HttpStatus.PAYLOAD_TOO_LARGE, "Stored file exceeds the maximum size"); + throw new WebApplicationException("Stored file exceeds the maximum size", 413); } long delta = newBytes - existingBytes; @@ -956,8 +941,7 @@ public class FileStorageService { if (maxUserBytes > 0) { long currentBytes = storedFileRepository.sumStorageBytesByOwner(owner); if (currentBytes + delta > maxUserBytes) { - throw new ResponseStatusException( - HttpStatus.PAYLOAD_TOO_LARGE, "User storage quota exceeded"); + throw new WebApplicationException("User storage quota exceeded", 413); } } @@ -965,8 +949,7 @@ public class FileStorageService { if (maxTotalBytes > 0) { long totalBytes = storedFileRepository.sumStorageBytesTotal(); if (totalBytes + delta > maxTotalBytes) { - throw new ResponseStatusException( - HttpStatus.PAYLOAD_TOO_LARGE, "System storage quota exceeded"); + throw new WebApplicationException("System storage quota exceeded", 413); } } } @@ -1170,13 +1153,11 @@ public class FileStorageService { * * @param file File to validate * @param user User attempting deletion - * @throws ResponseStatusException if deletion is not allowed + * @throws WebApplicationException if deletion is not allowed */ public void validateWorkflowDeletion(StoredFile file, User user) { if (isWorkflowFile(file)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Cannot delete file that is part of an active workflow"); + throw new WebApplicationException("Cannot delete file that is part of an active workflow", Response.Status.BAD_REQUEST); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java index c0f07f8ceb..ab0304d35f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.service; +import java.security.Principal; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; @@ -10,13 +11,13 @@ import java.util.Map; import java.util.Set; import java.util.UUID; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.PersistenceException; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +import io.quarkus.security.identity.SecurityIdentity; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,7 +36,7 @@ import stirling.software.proprietary.storage.repository.StoredFileRepository; * Phase A folder operations. Each call is scoped to the authenticated user - folders are private to * their owner. Folder-level sharing is a Phase 3 feature. */ -@Service +@ApplicationScoped @RequiredArgsConstructor @Slf4j public class FolderService { @@ -66,6 +67,7 @@ public class FolderService { private final FolderRepository folderRepository; private final StoredFileRepository storedFileRepository; private final ApplicationProperties applicationProperties; + private final SecurityIdentity securityIdentity; /** * Gate every public method on storage being enabled, mirroring {@code @@ -75,16 +77,20 @@ public class FolderService { */ private void ensureStorageEnabled() { if (!applicationProperties.getSecurity().isEnableLogin()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Storage requires login to be enabled"); + throw new WebApplicationException( + "Storage requires login to be enabled", Response.Status.FORBIDDEN); } if (!applicationProperties.getStorage().isEnabled()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Storage is disabled"); + throw new WebApplicationException("Storage is disabled", Response.Status.FORBIDDEN); } } /** List every folder owned by the current user, alphabetical. */ - @Transactional(readOnly = true) + // Spring @Transactional(readOnly = true) -> jakarta.transaction.Transactional. JTA's + // @Transactional has no readOnly attribute; the read-only optimization is a Hibernate/JDBC + // session hint with no jakarta equivalent. Behavior is preserved (still a single TX); the + // hint is dropped. + @Transactional public List listFolders() { ensureStorageEnabled(); User user = requireAuthenticatedUser(); @@ -103,8 +109,8 @@ public class FolderService { // parentFolderId they sent was ignored. For new ids the parent lookup would // 404, but the message is misleading. if (request.getId() != null && request.getId().equals(request.getParentFolderId())) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "A folder cannot be its own parent"); + throw new WebApplicationException( + "A folder cannot be its own parent", Response.Status.BAD_REQUEST); } Folder parent = resolveParent(request.getParentFolderId(), user, null); @@ -122,15 +128,15 @@ public class FolderService { // with a constraint-violation stack trace leaks far too much; convert to 409 Conflict so // the caller can pick a fresh id. if (folderRepository.existsById(id)) { - throw new ResponseStatusException( - HttpStatus.CONFLICT, - "A folder with this id already exists; choose a different id"); + throw new WebApplicationException( + "A folder with this id already exists; choose a different id", + Response.Status.CONFLICT); } if (folderRepository.countByOwner(user) >= MAX_FOLDERS_PER_USER) { - throw new ResponseStatusException( - HttpStatus.CONFLICT, - "Folder limit reached (max " + MAX_FOLDERS_PER_USER + " per user)"); + throw new WebApplicationException( + "Folder limit reached (max " + MAX_FOLDERS_PER_USER + " per user)", + Response.Status.CONFLICT); } Folder folder = new Folder(); @@ -166,8 +172,8 @@ public class FolderService { // Bean validation should already catch this via @Pattern, but be explicit so // an empty-after-trim payload reaches the user as a 400 instead of being // silently dropped. - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Folder name cannot be blank"); + throw new WebApplicationException( + "Folder name cannot be blank", Response.Status.BAD_REQUEST); } folder.setName(trimmed); } @@ -255,10 +261,13 @@ public class FolderService { .findByIdAndOwner(fileId, user) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, - "File not found or not owned by current user")); + new WebApplicationException( + "File not found or not owned by current user", + Response.Status.NOT_FOUND)); file.setFolder(resolveOwnedFolder(folderId, user)); + // TODO: Migration required - StoredFileRepository is still a Spring Data JpaRepository; + // save()/saveAll()/flush() resolve against it for now. When that repository is ported to + // a Panache repository, map these to persist()/flush() accordingly. storedFileRepository.save(file); } @@ -273,9 +282,9 @@ public class FolderService { return new BulkMoveResult(List.of(), List.of()); } if (fileIds.size() > BULK_MOVE_MAX_FILES) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "fileIds must contain between 1 and " + BULK_MOVE_MAX_FILES + " entries"); + throw new WebApplicationException( + "fileIds must contain between 1 and " + BULK_MOVE_MAX_FILES + " entries", + Response.Status.BAD_REQUEST); } User user = requireAuthenticatedUser(); Folder target = resolveOwnedFolder(folderId, user); @@ -287,16 +296,18 @@ public class FolderService { ownedIds.add(f.getId()); } // If the target folder was deleted concurrently between resolveOwnedFolder and the - // flush, the FK constraint fires as DataIntegrityViolationException. Surface that as - // 409 Conflict so the caller sees an actionable error instead of a 500 stack. + // flush, the FK constraint fires. Spring surfaced this as DataIntegrityViolationException; + // under Hibernate ORM the JPA equivalent is jakarta.persistence.PersistenceException (the + // root of constraint-violation exceptions). Surface as 409 Conflict so the caller sees an + // actionable error instead of a 500 stack. try { storedFileRepository.saveAll(owned); storedFileRepository.flush(); - } catch (DataIntegrityViolationException ex) { - throw new ResponseStatusException( - HttpStatus.CONFLICT, + } catch (PersistenceException ex) { + throw new WebApplicationException( "Target folder no longer exists; refresh and try again", - ex); + ex, + Response.Status.CONFLICT); } List moved = owned.stream().map(StoredFile::getId).toList(); @@ -327,9 +338,9 @@ public class FolderService { .findByIdAndOwner(folderId, user) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Folder does not exist or is not owned by you")); + new WebApplicationException( + "Folder does not exist or is not owned by you", + Response.Status.BAD_REQUEST)); } private Folder requireOwnedFolder(UUID id, User user) { @@ -337,25 +348,25 @@ public class FolderService { .findByIdAndOwner(id, user) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, - "Folder not found or not owned by current user")); + new WebApplicationException( + "Folder not found or not owned by current user", + Response.Status.NOT_FOUND)); } private Folder resolveParent(UUID parentId, User user, UUID forbidId) { if (parentId == null) return null; if (forbidId != null && parentId.equals(forbidId)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "A folder cannot be its own parent"); + throw new WebApplicationException( + "A folder cannot be its own parent", Response.Status.BAD_REQUEST); } Folder parent = folderRepository .findByIdAndOwner(parentId, user) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Parent folder does not exist or is not owned by you")); + new WebApplicationException( + "Parent folder does not exist or is not owned by you", + Response.Status.BAD_REQUEST)); // Reject before the child is created/moved if attaching it would push the chain past the // depth cap. Done in one pass that also returns the cycle answer so we don't walk the // lazy-proxy chain twice. @@ -379,39 +390,57 @@ public class FolderService { int depth = 0; while (cursor != null) { if (cursor.getOwner() == null || !cursor.getOwner().getId().equals(user.getId())) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support"); + throw new WebApplicationException( + "Folder hierarchy is corrupted; contact support", + Response.Status.BAD_REQUEST); } if (forbidId != null && cursor.getId().equals(forbidId)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Cannot move a folder inside one of its descendants"); + throw new WebApplicationException( + "Cannot move a folder inside one of its descendants", + Response.Status.BAD_REQUEST); } if (!seen.add(cursor.getId())) { // broken graph (cycle in stored data) - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support"); + throw new WebApplicationException( + "Folder hierarchy is corrupted; contact support", + Response.Status.BAD_REQUEST); } depth += 1; // candidateParent is at depth 1 from the new child's perspective. After the walk, // `depth` equals the number of ancestors including candidateParent, which is the // depth at which the new child would live. Reject before exceeding the cap. if (depth >= MAX_FOLDER_DEPTH) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Folder nesting limit reached (max " + MAX_FOLDER_DEPTH + " levels)"); + throw new WebApplicationException( + "Folder nesting limit reached (max " + MAX_FOLDER_DEPTH + " levels)", + Response.Status.BAD_REQUEST); } cursor = cursor.getParent(); } } + /** + * Resolve the current authenticated {@link User}. + * + *

    Spring's {@code SecurityContextHolder.getContext().getAuthentication().getPrincipal()} + * returned the {@link User} entity directly (it used to implement {@code UserDetails}). Under + * Quarkus the principal is exposed via {@link SecurityIdentity}. We pull the principal and + * adapt it to the {@link User} entity. + */ private User requireAuthenticatedUser() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null - || !authentication.isAuthenticated() - || !(authentication.getPrincipal() instanceof User user)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); + if (securityIdentity == null || securityIdentity.isAnonymous()) { + throw new WebApplicationException("Authentication required", Response.Status.UNAUTHORIZED); } - return user; + Principal principal = securityIdentity.getPrincipal(); + // TODO: Migration required - a Quarkus SecurityIdentityAugmentor/IdentityProvider must + // attach the stirling.software.proprietary.security.model.User entity as the + // SecurityIdentity principal (Spring exposed it directly via Authentication#getPrincipal, + // since User used to implement UserDetails). Until that augmentor exists, this only + // resolves when the principal IS the User entity; otherwise it rejects as 401 rather than + // guessing at a username->User lookup. + if (principal instanceof User user) { + return user; + } + throw new WebApplicationException( + "Authentication required", Response.Status.UNAUTHORIZED); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java index 5ea5f28def..4a11d14183 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java @@ -3,10 +3,11 @@ package stirling.software.proprietary.storage.service; import java.io.IOException; import java.time.LocalDateTime; import java.util.List; -import java.util.concurrent.TimeUnit; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; +import io.quarkus.scheduler.Scheduled; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,7 +17,7 @@ import stirling.software.proprietary.storage.provider.StorageProvider; import stirling.software.proprietary.storage.repository.FileShareRepository; import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; -@Service +@ApplicationScoped @RequiredArgsConstructor @Slf4j public class StorageCleanupService { @@ -27,7 +28,8 @@ public class StorageCleanupService { private final StorageCleanupEntryRepository cleanupEntryRepository; private final FileShareRepository fileShareRepository; - @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + @Scheduled(every = "24h") + @Transactional public void cleanupOrphanedStorage() { List entries = cleanupEntryRepository.findTop50ByOrderByUpdatedAtAsc(); if (entries.isEmpty()) { @@ -49,7 +51,7 @@ public class StorageCleanupService { cleanupEntryRepository.delete(entry); } else { entry.setAttemptCount(attempts); - cleanupEntryRepository.save(entry); + cleanupEntryRepository.persist(entry); log.warn( "Failed to cleanup storage key {} (attempt {}/{})", entry.getStorageKey(), @@ -61,13 +63,16 @@ public class StorageCleanupService { } } - @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + @Scheduled(every = "24h") + @Transactional public void cleanupExpiredShareLinks() { List expired = fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now()); if (expired.isEmpty()) { return; } - fileShareRepository.deleteAll(expired); + for (stirling.software.proprietary.storage.model.FileShare share : expired) { + fileShareRepository.delete(share); + } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java index b6f5b47f3b..287985a2f6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -4,38 +4,48 @@ import java.io.IOException; import java.util.Map; import org.slf4j.MDC; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; +import io.quarkus.security.identity.SecurityIdentity; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** Filter that stores additional request information for audit purposes */ +// Servlet filter retained (quarkus-undertow). Spring's OncePerRequestFilter replaced by a plain +// jakarta.servlet.Filter registered as a CDI bean via @WebFilter so it covers all requests. +// TODO: Migration required - Spring's @Order(Ordered.HIGHEST_PRECEDENCE + 10) ordering has no +// direct @WebFilter equivalent; if this filter must run before other servlet filters, configure +// ordering explicitly (e.g. via a FilterRegistrationBean equivalent / quarkus.http.filter.* in +// application.properties). @Slf4j -@Component -@Order(Ordered.HIGHEST_PRECEDENCE + 10) -@RequiredArgsConstructor -public class AuditWebFilter extends OncePerRequestFilter { +@ApplicationScoped +@WebFilter("/*") +public class AuditWebFilter implements jakarta.servlet.Filter { private static final String USER_AGENT_HEADER = "User-Agent"; private static final String REFERER_HEADER = "Referer"; private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language"; private static final String CONTENT_TYPE_HEADER = "Content-Type"; + // Instance<> so the filter still works on unauthenticated requests where no identity is bound. + @Inject Instance securityIdentity; + @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException { + HttpServletRequest request = (HttpServletRequest) servletRequest; + // Store key request info in MDC for logging and later audit use try { // Store request headers @@ -60,14 +70,15 @@ public class AuditWebFilter extends OncePerRequestFilter { } // Store authenticated user roles if available - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.getAuthorities() != null) { - String roles = - auth.getAuthorities().stream() - .map(a -> a.getAuthority()) - .reduce((a, b) -> a + "," + b) - .orElse(""); - MDC.put("userRoles", roles); + if (securityIdentity.isResolvable()) { + SecurityIdentity identity = securityIdentity.get(); + if (identity != null && !identity.isAnonymous() && identity.getRoles() != null) { + String roles = + identity.getRoles().stream() + .reduce((a, b) -> a + "," + b) + .orElse(""); + MDC.put("userRoles", roles); + } } // Store query parameters (without values for privacy) @@ -78,7 +89,7 @@ public class AuditWebFilter extends OncePerRequestFilter { } // Continue with the filter chain - filterChain.doFilter(request, response); + filterChain.doFilter(servletRequest, servletResponse); } finally { // Clear MDC after request is processed diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java index a60a531e3b..4339d3eb2c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java @@ -4,42 +4,48 @@ import java.io.IOException; import java.util.UUID; import org.slf4j.MDC; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; import io.github.pixee.security.Newlines; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; /** Guarantees every request carries a stable X-Request-Id; propagates to MDC. */ +// TODO: Migration required - quarkus-undertow provides jakarta.servlet support. Register this +// filter and its URL mapping/ordering via a @WebFilter annotation or a ServletExtension if order +// matters (Spring auto-registered @Component filters; Quarkus does not). @Slf4j -@Component -public class CorrelationIdFilter extends OncePerRequestFilter { +@ApplicationScoped +public class CorrelationIdFilter implements Filter { public static final String HEADER = "X-Request-Id"; public static final String MDC_KEY = "requestId"; @Override - protected void doFilterInternal( - HttpServletRequest req, HttpServletResponse res, FilterChain chain) + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + try { String id = req.getHeader(HEADER); - if (!StringUtils.hasText(id)) { + if (id == null || id.isBlank()) { id = UUID.randomUUID().toString(); } req.setAttribute(MDC_KEY, id); MDC.put(MDC_KEY, id); res.setHeader(HEADER, Newlines.stripAll(id)); - chain.doFilter(req, res); + chain.doFilter(request, response); } finally { MDC.remove(MDC_KEY); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 756fba1fe0..0d6b950651 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -4,33 +4,35 @@ import java.io.IOException; import java.security.Principal; import java.util.List; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.server.ResponseStatusException; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import jakarta.validation.constraints.NotBlank; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.swagger.StandardPdfResponse; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; import stirling.software.proprietary.security.model.User; @@ -45,8 +47,8 @@ import stirling.software.proprietary.workflow.service.SigningFinalizationService import stirling.software.proprietary.workflow.service.WorkflowSessionService; @Slf4j -@RestController -@RequestMapping("/api/v1/security") +@ApplicationScoped +@Path("/api/v1/security") @Tag( name = "Signing Sessions", description = "Signing session lifecycle and participant management") @@ -59,13 +61,19 @@ public class SigningSessionController { private final CertificateSubmissionValidator certificateSubmissionValidator; private final ObjectMapper objectMapper = new ObjectMapper(); + // JAX-RS injects the current security context; replaces Spring's Principal method parameters. + // securityContext.getUserPrincipal() is null when unauthenticated. + @Context SecurityContext securityContext; + @Operation(summary = "List all signing sessions for current user") - @Transactional(readOnly = true) - @GetMapping(value = "/cert-sign/sessions") - public ResponseEntity listSessions(Principal principal) { + @Transactional + @GET + @Path("/cert-sign/sessions") + public Response listSessions() { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User user = getCurrentUser(principal); @@ -77,139 +85,161 @@ public class SigningSessionController { stirling.software.proprietary.workflow.util.WorkflowMapper ::toResponse) .collect(java.util.stream.Collectors.toList()); - return ResponseEntity.ok(responses); + return Response.ok(responses).build(); } catch (Exception e) { log.error("Error listing sessions for user {}", principal.getName(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Error listing sessions"); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Error listing sessions") + .build(); } } - @PostMapping( - consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}, - value = "/cert-sign/sessions", - produces = MediaType.APPLICATION_JSON_VALUE) + @POST + @Path("/cert-sign/sessions") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Create a shared signing session", description = "Starts a collaboration session, distributes share links, and optionally notifies" + " participants. Input:PDF Output:JSON Type:SISO") - public ResponseEntity createSession( - @org.springframework.web.bind.annotation.RequestParam("file") - org.springframework.web.multipart.MultipartFile file, - @ModelAttribute WorkflowCreationRequest request, - Principal principal) + public Response createSession( + @RestForm("file") FileUpload file, + // TODO: Migration required - WorkflowCreationRequest is bound here via Spring's + // @ModelAttribute. RESTEasy Reactive @MultipartForm/@BeanParam can populate this POJO + // only if its fields are annotated with @RestForm (and any file fields are + // FileUpload, not the common MultipartFile shim). Verify/annotate + // WorkflowCreationRequest's fields in the DTO (a collaborator-owned file) for form + // binding to work. + @org.jboss.resteasy.reactive.MultipartForm WorkflowCreationRequest request) throws Exception { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User owner = getCurrentUser(principal); - WorkflowSession session = workflowSessionService.createSession(owner, file, request); - return ResponseEntity.ok( - stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(session)); + WorkflowSession session = + workflowSessionService.createSession( + owner, FileUploadMultipartFile.of(file), request); + return Response.ok( + stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse( + session)) + .build(); } catch (Exception e) { log.error("Error creating signing session", e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build(); } } @Operation(summary = "Fetch signing session details") - @Transactional(readOnly = true) - @GetMapping(value = "/cert-sign/sessions/{sessionId}") - public ResponseEntity getSession( - @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + @Transactional + @GET + @Path("/cert-sign/sessions/{sessionId}") + public Response getSession(@PathParam("sessionId") @NotBlank String sessionId) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User owner = getCurrentUser(principal); WorkflowSession session = workflowSessionService.getSessionForOwner(sessionId, owner); // Include wet signatures in response for owner preview - return ResponseEntity.ok( - stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse( - session, objectMapper)); + return Response.ok( + stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse( + session, objectMapper)) + .build(); } catch (Exception e) { log.error("Error fetching session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body("Access denied or session not found"); + return Response.status(Response.Status.FORBIDDEN) + .entity("Access denied or session not found") + .build(); } } @Operation(summary = "Delete a signing session") - @DeleteMapping(value = "/cert-sign/sessions/{sessionId}") - public ResponseEntity deleteSession( - @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + @DELETE + @Path("/cert-sign/sessions/{sessionId}") + public Response deleteSession(@PathParam("sessionId") @NotBlank String sessionId) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User owner = getCurrentUser(principal); workflowSessionService.deleteSession(sessionId, owner); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } catch (Exception e) { log.error("Error deleting session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body("Cannot delete session: " + e.getMessage()); + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot delete session: " + e.getMessage()) + .build(); } } @Operation(summary = "Add participants to an existing session") - @PostMapping(value = "/cert-sign/sessions/{sessionId}/participants") - public ResponseEntity addParticipants( - @PathVariable("sessionId") @NotBlank String sessionId, - @RequestBody List participants, - Principal principal) { + @POST + @Path("/cert-sign/sessions/{sessionId}/participants") + public Response addParticipants( + @PathParam("sessionId") @NotBlank String sessionId, + List participants) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User owner = getCurrentUser(principal); workflowSessionService.addParticipants(sessionId, participants, owner); WorkflowSession session = workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner); - return ResponseEntity.ok( - stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(session)); + return Response.ok( + stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse( + session)) + .build(); } catch (Exception e) { log.error("Error adding participants to session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body("Cannot add participants: " + e.getMessage()); + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot add participants: " + e.getMessage()) + .build(); } } @Operation(summary = "Remove a participant from a session") - @DeleteMapping(value = "/cert-sign/sessions/{sessionId}/participants/{participantId}") - public ResponseEntity removeParticipant( - @PathVariable("sessionId") @NotBlank String sessionId, - @PathVariable("participantId") Long participantId, - Principal principal) { + @DELETE + @Path("/cert-sign/sessions/{sessionId}/participants/{participantId}") + public Response removeParticipant( + @PathParam("sessionId") @NotBlank String sessionId, + @PathParam("participantId") Long participantId) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User owner = getCurrentUser(principal); workflowSessionService.removeParticipant(sessionId, participantId, owner); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } catch (Exception e) { log.error("Error removing participant {} from session {}", participantId, sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body("Cannot remove participant: " + e.getMessage()); + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot remove participant: " + e.getMessage()) + .build(); } } @Operation(summary = "Get session PDF for participant view") - @GetMapping(value = "/cert-sign/sessions/{sessionId}/pdf") - public ResponseEntity getSessionPdf( - @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + @GET + @Path("/cert-sign/sessions/{sessionId}/pdf") + public Response getSessionPdf(@PathParam("sessionId") @NotBlank String sessionId) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return Response.status(Response.Status.UNAUTHORIZED).build(); } try { User owner = getCurrentUser(principal); @@ -218,23 +248,24 @@ public class SigningSessionController { return WebResponseUtils.bytesToWebResponse(pdfBytes, "document.pdf"); } catch (Exception e) { log.error("Error fetching PDF for session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return Response.status(Response.Status.FORBIDDEN).build(); } } - @PostMapping(value = "/cert-sign/sessions/{sessionId}/finalize") + @POST + @Path("/cert-sign/sessions/{sessionId}/finalize") @Operation( summary = "Finalize signing session", description = "Applies collected wet signatures and digital certificates, then returns the" + " signed document.") @StandardPdfResponse - public ResponseEntity finalizeSession( - @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) + public Response finalizeSession(@PathParam("sessionId") @NotBlank String sessionId) throws Exception { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return Response.status(Response.Status.UNAUTHORIZED).build(); } try { @@ -262,34 +293,36 @@ public class SigningSessionController { ? session.getParticipants().stream().map(p -> p.getEmail()).toList() : "unknown", e); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, + throw new WebApplicationException( "Document signed successfully but post-signing cleanup failed. " - + "Contact your administrator to complete the cleanup."); + + "Contact your administrator to complete the cleanup.", + Response.Status.INTERNAL_SERVER_ERROR); } return WebResponseUtils.bytesToWebResponse(pdf, filename); } catch (Exception e) { log.error("Error finalizing session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); } } @Operation(summary = "Get signed PDF from finalized session") - @GetMapping(value = "/cert-sign/sessions/{sessionId}/signed-pdf") + @GET + @Path("/cert-sign/sessions/{sessionId}/signed-pdf") @StandardPdfResponse - public ResponseEntity getSignedPdf( - @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + public Response getSignedPdf(@PathParam("sessionId") @NotBlank String sessionId) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return Response.status(Response.Status.UNAUTHORIZED).build(); } try { User owner = getCurrentUser(principal); byte[] signedPdf = workflowSessionService.getProcessedFile(sessionId, owner); if (signedPdf == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("Session not finalized".getBytes()); + return Response.status(Response.Status.NOT_FOUND) + .entity("Session not finalized".getBytes()) + .build(); } WorkflowSession session = workflowSessionService.getSessionForOwner(sessionId, owner); return WebResponseUtils.bytesToWebResponse( @@ -297,56 +330,62 @@ public class SigningSessionController { GeneralUtils.generateFilename(session.getDocumentName(), "_shared_signed.pdf")); } catch (Exception e) { log.error("Error fetching signed PDF for session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return Response.status(Response.Status.FORBIDDEN).build(); } } // ===== SIGN REQUESTS (Participant View) ===== @Operation(summary = "List sign requests for authenticated user") - @Transactional(readOnly = true) - @GetMapping(value = "/cert-sign/sign-requests") - public ResponseEntity listSignRequests(Principal principal) { + @Transactional + @GET + @Path("/cert-sign/sign-requests") + public Response listSignRequests() { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User user = getCurrentUser(principal); - return ResponseEntity.ok(workflowSessionService.listSignRequests(user)); + return Response.ok(workflowSessionService.listSignRequests(user)).build(); } catch (Exception e) { log.error("Error listing sign requests for user {}", principal.getName(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Cannot list sign requests: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Cannot list sign requests: " + e.getMessage()) + .build(); } } - @Transactional(readOnly = true) + @Transactional @Operation(summary = "Get sign request detail for participant") - @GetMapping(value = "/cert-sign/sign-requests/{sessionId}") - public ResponseEntity getSignRequestDetail( - @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + @GET + @Path("/cert-sign/sign-requests/{sessionId}") + public Response getSignRequestDetail(@PathParam("sessionId") @NotBlank String sessionId) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User user = getCurrentUser(principal); - return ResponseEntity.ok(workflowSessionService.getSignRequestDetail(sessionId, user)); + return Response.ok(workflowSessionService.getSignRequestDetail(sessionId, user)).build(); } catch (Exception e) { log.error("Error fetching sign request detail for session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body("Access denied or sign request not found: " + e.getMessage()); + return Response.status(Response.Status.FORBIDDEN) + .entity("Access denied or sign request not found: " + e.getMessage()) + .build(); } } @Operation(summary = "Get document for sign request") - @GetMapping(value = "/cert-sign/sign-requests/{sessionId}/document") - public ResponseEntity getSignRequestDocument( - @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + @GET + @Path("/cert-sign/sign-requests/{sessionId}/document") + public Response getSignRequestDocument(@PathParam("sessionId") @NotBlank String sessionId) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return Response.status(Response.Status.UNAUTHORIZED).build(); } try { User user = getCurrentUser(principal); @@ -354,55 +393,62 @@ public class SigningSessionController { return WebResponseUtils.bytesToWebResponse(document, "document.pdf"); } catch (Exception e) { log.error("Error fetching document for sign request {}", sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return Response.status(Response.Status.FORBIDDEN).build(); } } @Operation(summary = "Sign a document with certificate and optional wet signature") - @PostMapping( - value = "/cert-sign/sign-requests/{sessionId}/sign", - consumes = { - MediaType.MULTIPART_FORM_DATA_VALUE, - MediaType.APPLICATION_FORM_URLENCODED_VALUE - }) - public ResponseEntity signDocument( - @PathVariable("sessionId") @NotBlank String sessionId, - @ModelAttribute stirling.software.proprietary.workflow.dto.SignDocumentRequest request, - Principal principal) { + @POST + @Path("/cert-sign/sign-requests/{sessionId}/sign") + @Consumes({MediaType.MULTIPART_FORM_DATA, MediaType.APPLICATION_FORM_URLENCODED}) + public Response signDocument( + @PathParam("sessionId") @NotBlank String sessionId, + // TODO: Migration required - SignDocumentRequest is bound here via Spring's + // @ModelAttribute. Its file fields (p12File/privateKeyFile/certFile) are typed as the + // common MultipartFile shim, which RESTEasy Reactive @MultipartForm cannot populate + // directly (it binds FileUpload + @RestForm). The DTO (collaborator-owned) must expose + // FileUpload fields with @RestForm and adapt to MultipartFile, or this method must + // accept the individual @RestForm parts and build the DTO here. + @org.jboss.resteasy.reactive.MultipartForm + stirling.software.proprietary.workflow.dto.SignDocumentRequest request) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User user = getCurrentUser(principal); workflowSessionService.signDocument(sessionId, user, request); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } catch (IllegalArgumentException e) { log.error("Invalid sign request for session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build(); } catch (Exception e) { log.error("Error signing document for session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Cannot sign document: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Cannot sign document: " + e.getMessage()) + .build(); } } @Operation(summary = "Decline a sign request") - @PostMapping(value = "/cert-sign/sign-requests/{sessionId}/decline") - public ResponseEntity declineSignRequest( - @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + @POST + @Path("/cert-sign/sign-requests/{sessionId}/decline") + public Response declineSignRequest(@PathParam("sessionId") @NotBlank String sessionId) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + return Response.status(Response.Status.UNAUTHORIZED).entity("Authentication required").build(); } try { User user = getCurrentUser(principal); workflowSessionService.declineSignRequest(sessionId, user); - return ResponseEntity.noContent().build(); + return Response.noContent().build(); } catch (Exception e) { log.error("Error declining sign request for session {}", sessionId, e); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body("Cannot decline sign request: " + e.getMessage()); + return Response.status(Response.Status.FORBIDDEN) + .entity("Cannot decline sign request: " + e.getMessage()) + .build(); } } @@ -412,36 +458,41 @@ public class SigningSessionController { "Validates that the provided certificate is loadable, not expired, and can " + "successfully sign a document. Returns validation details so the " + "user can confirm the correct certificate before committing.") - @PostMapping( - value = "/cert-sign/validate-certificate", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity validateCertificate( - @RequestParam("certType") String certType, - @RequestParam(value = "password", required = false) String password, - @RequestParam(value = "p12File", required = false) MultipartFile p12File, - @RequestParam(value = "jksFile", required = false) MultipartFile jksFile, - Principal principal) { + @POST + @Path("/cert-sign/validate-certificate") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + public Response validateCertificate( + @RestForm("certType") String certType, + @RestForm("password") String password, + @RestForm("p12File") FileUpload p12File, + @RestForm("jksFile") FileUpload jksFile) { workflowSessionService.ensureSigningEnabled(); + Principal principal = securityContext.getUserPrincipal(); if (principal == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return Response.status(Response.Status.UNAUTHORIZED).build(); } + stirling.software.common.model.MultipartFile p12 = + p12File != null ? FileUploadMultipartFile.of(p12File) : null; + stirling.software.common.model.MultipartFile jks = + jksFile != null ? FileUploadMultipartFile.of(jksFile) : null; + if (!"SERVER".equalsIgnoreCase(certType) && !"USER_CERT".equalsIgnoreCase(certType) - && (p12File == null || p12File.isEmpty()) - && (jksFile == null || jksFile.isEmpty())) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "No certificate file provided"); + && (p12 == null || p12.isEmpty()) + && (jks == null || jks.isEmpty())) { + throw new WebApplicationException( + "No certificate file provided", Response.Status.BAD_REQUEST); } try { byte[] keystoreBytes = null; - if (p12File != null && !p12File.isEmpty()) { - keystoreBytes = p12File.getBytes(); - } else if (jksFile != null && !jksFile.isEmpty()) { - keystoreBytes = jksFile.getBytes(); + if (p12 != null && !p12.isEmpty()) { + keystoreBytes = p12.getBytes(); + } else if (jks != null && !jks.isEmpty()) { + keystoreBytes = jks.getBytes(); } CertificateInfo info = @@ -449,38 +500,44 @@ public class SigningSessionController { keystoreBytes, certType, password); if (info == null) { - return ResponseEntity.ok( - new CertificateValidationResponse( - true, null, null, null, null, false, null)); + return Response.ok( + new CertificateValidationResponse( + true, null, null, null, null, false, null)) + .build(); } - return ResponseEntity.ok( - new CertificateValidationResponse( - true, - info.subjectName(), - info.issuerName(), - info.notAfter() != null ? info.notAfter().toInstant().toString() : null, - info.notBefore() != null - ? info.notBefore().toInstant().toString() - : null, - info.selfSigned(), - null)); + return Response.ok( + new CertificateValidationResponse( + true, + info.subjectName(), + info.issuerName(), + info.notAfter() != null + ? info.notAfter().toInstant().toString() + : null, + info.notBefore() != null + ? info.notBefore().toInstant().toString() + : null, + info.selfSigned(), + null)) + .build(); - } catch (ResponseStatusException e) { - return ResponseEntity.ok( - new CertificateValidationResponse( - false, null, null, null, null, false, e.getReason())); + } catch (WebApplicationException e) { + return Response.ok( + new CertificateValidationResponse( + false, null, null, null, null, false, e.getMessage())) + .build(); } catch (IOException e) { log.error("Error reading certificate file during pre-validation", e); - return ResponseEntity.ok( - new CertificateValidationResponse( - false, - null, - null, - null, - null, - false, - "Failed to read certificate file")); + return Response.ok( + new CertificateValidationResponse( + false, + null, + null, + null, + null, + false, + "Failed to read certificate file")) + .build(); } } @@ -490,6 +547,8 @@ public class SigningSessionController { return userService .findByUsernameIgnoreCase(principal.getName()) .orElseThrow( - () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized")); + () -> + new WebApplicationException( + "Unauthorized", Response.Status.UNAUTHORIZED)); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 45d3228fdb..0af291facc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -7,33 +7,39 @@ import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; +// TODO: Migration required - org.springframework.web.server.ResponseStatusException is still +// thrown by the not-yet-migrated CertificateSubmissionValidator service (validateAndExtractInfo). +// Keep catching it here until that collaborator is converted to throw WebApplicationException; +// then this import and the catch blocks below can be replaced. import org.springframework.web.server.ResponseStatusException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.multipart.FileUploadMultipartFile; import stirling.software.proprietary.workflow.dto.CertificateInfo; import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantResponse; -import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest; import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; import stirling.software.proprietary.workflow.model.ParticipantStatus; @@ -55,17 +61,16 @@ import tools.jackson.databind.ObjectMapper; *

    Access is controlled via share tokens, not requiring authentication. */ @Slf4j -@RestController -@RequestMapping("/api/v1/workflow/participant") +@ApplicationScoped +@jakarta.ws.rs.Path("/api/v1/workflow/participant") @Tag(name = "Workflow Participant", description = "Participant Action APIs") -@RequiredArgsConstructor public class WorkflowParticipantController { - private final WorkflowSessionService workflowSessionService; - private final WorkflowParticipantRepository participantRepository; - private final ObjectMapper objectMapper; - private final MetadataEncryptionService metadataEncryptionService; - private final CertificateSubmissionValidator certificateSubmissionValidator; + @Inject WorkflowSessionService workflowSessionService; + @Inject WorkflowParticipantRepository participantRepository; + @Inject ObjectMapper objectMapper; + @Inject MetadataEncryptionService metadataEncryptionService; + @Inject CertificateSubmissionValidator certificateSubmissionValidator; private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC); @@ -73,9 +78,10 @@ public class WorkflowParticipantController { @Operation( summary = "Get workflow session details by participant token", description = "Allows participants to view session details using their share token") - @GetMapping(value = "/session", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity getSessionByToken( - @RequestParam("token") @NotBlank String token) { + @GET + @jakarta.ws.rs.Path("/session") + @Produces(MediaType.APPLICATION_JSON) + public Response getSessionByToken(@QueryParam("token") @NotBlank String token) { workflowSessionService.ensureSigningEnabled(); @@ -84,13 +90,14 @@ public class WorkflowParticipantController { .findByShareToken(token) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Invalid or expired participant token")); + new WebApplicationException( + "Invalid or expired participant token", + Response.Status.FORBIDDEN)); // Check if participant is expired if (participant.isExpired()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired"); + throw new WebApplicationException( + "Participant access expired", Response.Status.FORBIDDEN); } // Mark as viewed if not already @@ -103,15 +110,16 @@ public class WorkflowParticipantController { WorkflowSession session = participant.getWorkflowSession(); // Strip peer share tokens — a single participant token must not enumerate peer bearer // tokens (GHSA-qgg6-mxw4-xg62). - return ResponseEntity.ok(WorkflowMapper.toResponse(session, null, false)); + return Response.ok(WorkflowMapper.toResponse(session, null, false)).build(); } @Operation( summary = "Get participant details by token", description = "Returns participant-specific information") - @GetMapping(value = "/details", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity getParticipantDetails( - @RequestParam("token") @NotBlank String token) { + @GET + @jakarta.ws.rs.Path("/details") + @Produces(MediaType.APPLICATION_JSON) + public Response getParticipantDetails(@QueryParam("token") @NotBlank String token) { workflowSessionService.ensureSigningEnabled(); @@ -120,58 +128,83 @@ public class WorkflowParticipantController { .findByShareToken(token) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Invalid or expired participant token")); + new WebApplicationException( + "Invalid or expired participant token", + Response.Status.FORBIDDEN)); - return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false)); + return Response.ok(WorkflowMapper.toParticipantResponse(participant, false)).build(); } @Operation( summary = "Submit signature (wet signature and/or certificate)", description = "Participants submit their signature data and certificate information for signing") - @PostMapping( - value = "/submit-signature", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity submitSignature( - @ModelAttribute SignatureSubmissionRequest request) { + @POST + @jakarta.ws.rs.Path("/submit-signature") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + public Response submitSignature( + @RestForm("certType") String certType, + @RestForm("password") String password, + @RestForm("p12File") FileUpload p12FileUpload, + @RestForm("jksFile") FileUpload jksFileUpload, + @RestForm("showSignature") Boolean showSignature, + @RestForm("pageNumber") Integer pageNumber, + @RestForm("location") String location, + @RestForm("reason") String reason, + @RestForm("showLogo") Boolean showLogo, + @RestForm("wetSignaturesData") String wetSignaturesData, + @RestForm("participantToken") String participantToken) { workflowSessionService.ensureSigningEnabled(); - if (request.getParticipantToken() == null || request.getParticipantToken().isBlank()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Participant token is required"); + if (participantToken == null || participantToken.isBlank()) { + throw new WebApplicationException( + "Participant token is required", Response.Status.BAD_REQUEST); } + MultipartFile p12File = FileUploadMultipartFile.of(p12FileUpload); + MultipartFile jksFile = FileUploadMultipartFile.of(jksFileUpload); + WorkflowParticipant participant = participantRepository - .findByShareToken(request.getParticipantToken()) + .findByShareToken(participantToken) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Invalid or expired participant token")); + new WebApplicationException( + "Invalid or expired participant token", + Response.Status.FORBIDDEN)); // Check if participant can still submit if (participant.isExpired()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired"); + throw new WebApplicationException( + "Participant access expired", Response.Status.FORBIDDEN); } if (participant.hasCompleted()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Participant has already completed their action"); + throw new WebApplicationException( + "Participant has already completed their action", Response.Status.BAD_REQUEST); } if (!participant.getWorkflowSession().isActive()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Workflow session is no longer active"); + throw new WebApplicationException( + "Workflow session is no longer active", Response.Status.BAD_REQUEST); } try { // Build metadata map with certificate and wet signature data - Map metadata = buildSubmissionMetadata(request); + Map metadata = + buildSubmissionMetadata( + certType, + password, + p12File, + jksFile, + showSignature, + pageNumber, + location, + reason, + showLogo, + wetSignaturesData); participant.setParticipantMetadata(metadata); // Update status to SIGNED @@ -183,24 +216,33 @@ public class WorkflowParticipantController { participant.getEmail(), participant.getWorkflowSession().getSessionId()); - return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false)); + return Response.ok(WorkflowMapper.toParticipantResponse(participant, false)).build(); - } catch (ResponseStatusException e) { + } catch (WebApplicationException e) { throw e; + } catch (ResponseStatusException e) { + // Thrown by the not-yet-migrated CertificateSubmissionValidator on validation failure. + // TODO: Migration required - replace with WebApplicationException once that service is + // converted. + throw new WebApplicationException( + e.getReason(), + Response.Status.fromStatusCode(e.getStatusCode().value())); } catch (Exception e) { log.error("Error submitting signature for participant {}", participant.getEmail(), e); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Failed to submit signature", e); + throw new WebApplicationException( + "Failed to submit signature", e, Response.Status.INTERNAL_SERVER_ERROR); } } @Operation( summary = "Decline participation", description = "Participant declines to sign or participate in the workflow") - @PostMapping(value = "/decline", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity declineParticipation( - @RequestParam("token") @NotBlank String token, - @RequestParam(value = "reason", required = false) @Size(max = 500) String reason) { + @POST + @jakarta.ws.rs.Path("/decline") + @Produces(MediaType.APPLICATION_JSON) + public Response declineParticipation( + @RestForm("token") @NotBlank String token, + @RestForm("reason") @Size(max = 500) String reason) { workflowSessionService.ensureSigningEnabled(); @@ -209,13 +251,13 @@ public class WorkflowParticipantController { .findByShareToken(token) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Invalid or expired participant token")); + new WebApplicationException( + "Invalid or expired participant token", + Response.Status.FORBIDDEN)); if (participant.hasCompleted()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Participant has already completed their action"); + throw new WebApplicationException( + "Participant has already completed their action", Response.Status.BAD_REQUEST); } // Update status to DECLINED @@ -237,14 +279,16 @@ public class WorkflowParticipantController { participant.getEmail(), participant.getWorkflowSession().getSessionId()); - return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false)); + return Response.ok(WorkflowMapper.toParticipantResponse(participant, false)).build(); } @Operation( summary = "Get original PDF for review", description = "Participant downloads the original document") - @GetMapping(value = "/document", produces = MediaType.APPLICATION_PDF_VALUE) - public ResponseEntity getDocument(@RequestParam("token") @NotBlank String token) { + @GET + @jakarta.ws.rs.Path("/document") + @Produces("application/pdf") + public Response getDocument(@QueryParam("token") @NotBlank String token) { workflowSessionService.ensureSigningEnabled(); @@ -253,32 +297,29 @@ public class WorkflowParticipantController { .findByShareToken(token) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Invalid or expired participant token")); + new WebApplicationException( + "Invalid or expired participant token", + Response.Status.FORBIDDEN)); if (participant.isExpired()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired"); + throw new WebApplicationException( + "Participant access expired", Response.Status.FORBIDDEN); } try { WorkflowSession session = participant.getWorkflowSession(); byte[] pdf = workflowSessionService.getOriginalFile(session.getSessionId()); - return ResponseEntity.ok() + return Response.ok(pdf, "application/pdf") .header( HttpHeaders.CONTENT_DISPOSITION, - ContentDisposition.attachment() - .filename(session.getDocumentName(), StandardCharsets.UTF_8) - .build() - .toString()) - .contentType(org.springframework.http.MediaType.APPLICATION_PDF) - .body(pdf); + contentDispositionAttachment(session.getDocumentName())) + .build(); } catch (IOException e) { log.error("Error retrieving document for participant", e); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document", e); + throw new WebApplicationException( + "Failed to retrieve document", e, Response.Status.INTERNAL_SERVER_ERROR); } } @@ -288,27 +329,30 @@ public class WorkflowParticipantController { "Validates that the provided certificate is loadable, not expired, and can " + "successfully sign a document. Returns validation details so the " + "participant can confirm the correct certificate before committing.") - @PostMapping( - value = "/validate-certificate", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity validateCertificate( - @RequestParam("participantToken") @NotBlank String participantToken, - @RequestParam("certType") String certType, - @RequestParam(value = "password", required = false) String password, - @RequestParam(value = "p12File", required = false) MultipartFile p12File, - @RequestParam(value = "jksFile", required = false) MultipartFile jksFile) { + @POST + @jakarta.ws.rs.Path("/validate-certificate") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + public Response validateCertificate( + @RestForm("participantToken") @NotBlank String participantToken, + @RestForm("certType") String certType, + @RestForm("password") String password, + @RestForm("p12File") FileUpload p12FileUpload, + @RestForm("jksFile") FileUpload jksFileUpload) { workflowSessionService.ensureSigningEnabled(); + MultipartFile p12File = FileUploadMultipartFile.of(p12FileUpload); + MultipartFile jksFile = FileUploadMultipartFile.of(jksFileUpload); + participantRepository .findByShareToken(participantToken) .filter(p -> !p.isExpired()) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Invalid or expired participant token")); + new WebApplicationException( + "Invalid or expired participant token", + Response.Status.FORBIDDEN)); // Require a file for non-SERVER/non-USER_CERT types — this is a request error, not a // validation failure @@ -316,8 +360,8 @@ public class WorkflowParticipantController { && !"USER_CERT".equalsIgnoreCase(certType) && (p12File == null || p12File.isEmpty()) && (jksFile == null || jksFile.isEmpty())) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "No certificate file provided"); + throw new WebApplicationException( + "No certificate file provided", Response.Status.BAD_REQUEST); } try { @@ -334,107 +378,133 @@ public class WorkflowParticipantController { if (info == null) { // SERVER type — nothing to validate - return ResponseEntity.ok( - new CertificateValidationResponse( - true, null, null, null, null, false, null)); + return Response.ok( + new CertificateValidationResponse( + true, null, null, null, null, false, null)) + .build(); } - return ResponseEntity.ok( - new CertificateValidationResponse( - true, - info.subjectName(), - info.issuerName(), - info.notAfter() != null ? info.notAfter().toInstant().toString() : null, - info.notBefore() != null - ? info.notBefore().toInstant().toString() - : null, - info.selfSigned(), - null)); + return Response.ok( + new CertificateValidationResponse( + true, + info.subjectName(), + info.issuerName(), + info.notAfter() != null + ? info.notAfter().toInstant().toString() + : null, + info.notBefore() != null + ? info.notBefore().toInstant().toString() + : null, + info.selfSigned(), + null)) + .build(); } catch (ResponseStatusException e) { - // Validation failure — return 200 with valid:false so the frontend can display inline - return ResponseEntity.ok( - new CertificateValidationResponse( - false, null, null, null, null, false, e.getReason())); + // Validation failure — return 200 with valid:false so the frontend can display inline. + // TODO: Migration required - CertificateSubmissionValidator still throws Spring's + // ResponseStatusException; switch to WebApplicationException once it is converted. + return Response.ok( + new CertificateValidationResponse( + false, null, null, null, null, false, e.getReason())) + .build(); } catch (IOException e) { log.error("Error reading certificate file during pre-validation", e); - return ResponseEntity.ok( - new CertificateValidationResponse( - false, - null, - null, - null, - null, - false, - "Failed to read certificate file")); + return Response.ok( + new CertificateValidationResponse( + false, + null, + null, + null, + null, + false, + "Failed to read certificate file")) + .build(); } } /** - * Builds metadata map from signature submission request. Includes certificate submission and - * wet signature data. + * Builds the {@code Content-Disposition: attachment} header value with an RFC 5987 UTF-8 encoded + * filename, mirroring Spring's {@code ContentDisposition.attachment().filename(name, UTF_8)}. */ - private Map buildSubmissionMetadata(SignatureSubmissionRequest request) + private static String contentDispositionAttachment(String filename) { + String encoded = + java.net.URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + return "attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encoded; + } + + /** + * Builds metadata map from signature submission request fields. Includes certificate submission + * and wet signature data. + */ + private Map buildSubmissionMetadata( + String certType, + String password, + MultipartFile p12File, + MultipartFile jksFile, + Boolean showSignature, + Integer pageNumber, + String location, + String reason, + Boolean showLogo, + String wetSignaturesData) throws IOException { Map metadata = new HashMap<>(); // Validate certificate before storing — throws 400 if invalid, expired, or wrong password - if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) { + if (certType != null && !"SERVER".equalsIgnoreCase(certType)) { byte[] keystoreBytes = null; - if (request.getP12File() != null && !request.getP12File().isEmpty()) { - keystoreBytes = request.getP12File().getBytes(); - } else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) { - keystoreBytes = request.getJksFile().getBytes(); + if (p12File != null && !p12File.isEmpty()) { + keystoreBytes = p12File.getBytes(); + } else if (jksFile != null && !jksFile.isEmpty()) { + keystoreBytes = jksFile.getBytes(); } if (keystoreBytes != null) { certificateSubmissionValidator.validateAndExtractInfo( - keystoreBytes, request.getCertType(), request.getPassword()); + keystoreBytes, certType, password); } } // Add certificate submission if provided - if (request.getCertType() != null) { + if (certType != null) { Map certSubmission = new HashMap<>(); - certSubmission.put("certType", request.getCertType()); - certSubmission.put( - "password", metadataEncryptionService.encrypt(request.getPassword())); - certSubmission.put("showSignature", request.getShowSignature()); - certSubmission.put("pageNumber", request.getPageNumber()); - certSubmission.put("location", request.getLocation()); - certSubmission.put("reason", request.getReason()); - certSubmission.put("showLogo", request.getShowLogo()); + certSubmission.put("certType", certType); + certSubmission.put("password", metadataEncryptionService.encrypt(password)); + certSubmission.put("showSignature", showSignature); + certSubmission.put("pageNumber", pageNumber); + certSubmission.put("location", location); + certSubmission.put("reason", reason); + certSubmission.put("showLogo", showLogo); // Store certificate files as base64 - if (request.getP12File() != null && !request.getP12File().isEmpty()) { + if (p12File != null && !p12File.isEmpty()) { certSubmission.put( "p12Keystore", - java.util.Base64.getEncoder() - .encodeToString(request.getP12File().getBytes())); + java.util.Base64.getEncoder().encodeToString(p12File.getBytes())); } - if (request.getJksFile() != null && !request.getJksFile().isEmpty()) { + if (jksFile != null && !jksFile.isEmpty()) { certSubmission.put( "jksKeystore", - java.util.Base64.getEncoder() - .encodeToString(request.getJksFile().getBytes())); + java.util.Base64.getEncoder().encodeToString(jksFile.getBytes())); } metadata.put("certificateSubmission", certSubmission); } // Add wet signatures data if provided - parse once and store as List directly - if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) { - if (request.getWetSignaturesData().length() > 5 * 1024 * 1024) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Wet signatures data exceeds maximum allowed size"); + if (wetSignaturesData != null && !wetSignaturesData.isBlank()) { + if (wetSignaturesData.length() > 5 * 1024 * 1024) { + throw new WebApplicationException( + "Wet signatures data exceeds maximum allowed size", + Response.Status.BAD_REQUEST); } @SuppressWarnings("unchecked") java.util.List> wetSigs = objectMapper.readValue( - request.getWetSignaturesData(), + wetSignaturesData, new TypeReference>>() {}); if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); + throw new WebApplicationException( + "Too many wet signatures submitted", Response.Status.BAD_REQUEST); } metadata.put("wetSignatures", wetSigs); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java index fcc8684057..f30ba57998 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java @@ -3,8 +3,6 @@ package stirling.software.proprietary.workflow.dto; import java.util.ArrayList; import java.util.List; -import org.springframework.web.multipart.MultipartFile; - import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -12,6 +10,8 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import stirling.software.common.model.MultipartFile; + /** * Request object for signing a document. Combines certificate submission data with optional wet * signature (visual signature) metadata. Supports multiple wet signatures. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignatureSubmissionRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignatureSubmissionRequest.java index c5d2e95c68..d34948c24c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignatureSubmissionRequest.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignatureSubmissionRequest.java @@ -1,6 +1,6 @@ package stirling.software.proprietary.workflow.dto; -import org.springframework.web.multipart.MultipartFile; +import stirling.software.common.model.MultipartFile; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/UserServerCertificateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/UserServerCertificateRepository.java index fead8da9b9..d7cf339be8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/UserServerCertificateRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/UserServerCertificateRepository.java @@ -2,22 +2,25 @@ package stirling.software.proprietary.workflow.repository; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; import stirling.software.proprietary.workflow.model.UserServerCertificateEntity; -@Repository -public interface UserServerCertificateRepository - extends JpaRepository { +@ApplicationScoped +public class UserServerCertificateRepository + implements PanacheRepository { - @Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.id = :userId") - Optional findByUserId(@Param("userId") Long userId); + public Optional findByUserId(Long userId) { + return find("user.id = ?1", userId).firstResultOptional(); + } - @Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.username = :username") - Optional findByUsername(@Param("username") String username); + public Optional findByUsername(String username) { + return find("user.username = ?1", username).firstResultOptional(); + } - boolean existsByUserId(Long userId); + public boolean existsByUserId(Long userId) { + return count("user.id = ?1", userId) > 0; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowParticipantRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowParticipantRepository.java index 8f31d8c4a4..b3d277f753 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowParticipantRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowParticipantRepository.java @@ -3,73 +3,101 @@ package stirling.software.proprietary.workflow.repository; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import io.quarkus.panache.common.Parameters; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.workflow.model.ParticipantStatus; import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; -@Repository -public interface WorkflowParticipantRepository extends JpaRepository { +/** + * Quarkus Panache repository for {@link WorkflowParticipant}. + * + *

    Migrated from a Spring Data {@code JpaRepository}. Derived finders + * are reimplemented as Panache queries; the {@code @Query}-annotated methods preserve their original + * JPQL strings via {@code find(...)} / {@code update(...)} / {@code delete(...)}. + */ +@ApplicationScoped +public class WorkflowParticipantRepository implements PanacheRepository { /** Find participant by share token */ - Optional findByShareToken(String shareToken); + public Optional findByShareToken(String shareToken) { + return find("shareToken", shareToken).firstResultOptional(); + } /** Find all participants in a workflow session */ - List findByWorkflowSession(WorkflowSession session); + public List findByWorkflowSession(WorkflowSession session) { + return list("workflowSession", session); + } /** Find participant by session and user */ - Optional findByWorkflowSessionAndUser(WorkflowSession session, User user); + public Optional findByWorkflowSessionAndUser( + WorkflowSession session, User user) { + return find("workflowSession = ?1 and user = ?2", session, user).firstResultOptional(); + } /** Find participant by session and email */ - Optional findByWorkflowSessionAndEmail( - WorkflowSession session, String email); + public Optional findByWorkflowSessionAndEmail( + WorkflowSession session, String email) { + return find("workflowSession = ?1 and email = ?2", session, email).firstResultOptional(); + } /** Find all participants with a specific status in a session */ - List findByWorkflowSessionAndStatus( - WorkflowSession session, ParticipantStatus status); + public List findByWorkflowSessionAndStatus( + WorkflowSession session, ParticipantStatus status) { + return list("workflowSession = ?1 and status = ?2", session, status); + } /** Find all sessions where a user is a participant */ - List findByUserOrderByLastUpdatedDesc(User user); + public List findByUserOrderByLastUpdatedDesc(User user) { + return list("user = ?1 order by lastUpdated desc", user); + } /** Find all sessions where an email is a participant */ - List findByEmailOrderByLastUpdatedDesc(String email); + public List findByEmailOrderByLastUpdatedDesc(String email) { + return list("email = ?1 order by lastUpdated desc", email); + } /** Check if a participant exists by share token */ - boolean existsByShareToken(String shareToken); + public boolean existsByShareToken(String shareToken) { + return count("shareToken", shareToken) > 0; + } /** Count participants in a session by status */ - long countByWorkflowSessionAndStatus(WorkflowSession session, ParticipantStatus status); + public long countByWorkflowSessionAndStatus(WorkflowSession session, ParticipantStatus status) { + return count("workflowSession = ?1 and status = ?2", session, status); + } /** Find expired participants that haven't completed */ - @Query( - "SELECT p FROM WorkflowParticipant p WHERE p.expiresAt < CURRENT_TIMESTAMP AND p.status NOT IN ('SIGNED', 'DECLINED')") - List findExpiredIncompleteParticipants(); + public List findExpiredIncompleteParticipants() { + return list( + "expiresAt < CURRENT_TIMESTAMP AND status NOT IN ('SIGNED', 'DECLINED')"); + } /** Find all participants pending notification */ - @Query( - "SELECT p FROM WorkflowParticipant p WHERE p.status = 'PENDING' AND p.workflowSession.status = 'IN_PROGRESS'") - List findPendingNotifications(); + public List findPendingNotifications() { + return list("status = 'PENDING' AND workflowSession.status = 'IN_PROGRESS'"); + } /** Delete participant by ID and session owner (for authorization) */ - @Query( - "DELETE FROM WorkflowParticipant p WHERE p.id = :participantId AND p.workflowSession.owner = :owner") - void deleteByIdAndSessionOwner( - @Param("participantId") Long participantId, @Param("owner") User owner); + @Transactional + public void deleteByIdAndSessionOwner(Long participantId, User owner) { + delete( + "id = :participantId AND workflowSession.owner = :owner", + Parameters.with("participantId", participantId).and("owner", owner)); + } /** * Null out the user reference for all participants linked to the given user. Used during user * deletion to preserve workflow audit history while removing the personal data link. * Participants in sessions owned by others are retained but de-linked from the deleted account. */ - @Modifying @Transactional - @Query("UPDATE WorkflowParticipant wp SET wp.user = null WHERE wp.user = :user") - void clearUserReferences(@Param("user") User user); + public void clearUserReferences(User user) { + update("user = null WHERE user = :user", Parameters.with("user", user)); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowSessionRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowSessionRepository.java index 7605caab8b..c49f4411b9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowSessionRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowSessionRepository.java @@ -3,59 +3,89 @@ package stirling.software.proprietary.workflow.repository; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import io.quarkus.panache.common.Sort; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.workflow.model.WorkflowSession; import stirling.software.proprietary.workflow.model.WorkflowStatus; import stirling.software.proprietary.workflow.model.WorkflowType; -@Repository -public interface WorkflowSessionRepository extends JpaRepository { +@ApplicationScoped +public class WorkflowSessionRepository implements PanacheRepository { /** Find workflow session by unique session ID */ - Optional findBySessionId(String sessionId); + public Optional findBySessionId(String sessionId) { + return find("sessionId", sessionId).firstResultOptional(); + } /** Find workflow session by unique session ID with participants eagerly loaded */ - @Query( - "SELECT ws FROM WorkflowSession ws LEFT JOIN FETCH ws.participants WHERE ws.sessionId = :sessionId") - Optional findBySessionIdWithParticipants(@Param("sessionId") String sessionId); + public Optional findBySessionIdWithParticipants(String sessionId) { + return find( + "SELECT ws FROM WorkflowSession ws LEFT JOIN FETCH ws.participants WHERE ws.sessionId = :sessionId", + io.quarkus.panache.common.Parameters.with("sessionId", sessionId)) + .firstResultOptional(); + } /** Find all workflow sessions owned by a specific user */ - List findByOwnerOrderByCreatedAtDesc(User owner); + public List findByOwnerOrderByCreatedAtDesc(User owner) { + return list("owner", Sort.by("createdAt", Sort.Direction.Descending), owner); + } /** Find all workflow sessions of a specific type for a user */ - List findByOwnerAndWorkflowTypeOrderByCreatedAtDesc( - User owner, WorkflowType workflowType); + public List findByOwnerAndWorkflowTypeOrderByCreatedAtDesc( + User owner, WorkflowType workflowType) { + return list( + "owner = ?1 and workflowType = ?2", + Sort.by("createdAt", Sort.Direction.Descending), + owner, + workflowType); + } /** Find all workflow sessions with a specific status */ - List findByStatusOrderByCreatedAtDesc(WorkflowStatus status); + public List findByStatusOrderByCreatedAtDesc(WorkflowStatus status) { + return list("status", Sort.by("createdAt", Sort.Direction.Descending), status); + } /** Find all active (non-finalized, in-progress) sessions for a user */ - @Query( - "SELECT ws FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false ORDER BY ws.createdAt DESC") - List findActiveSessionsByOwner(@Param("owner") User owner); + public List findActiveSessionsByOwner(User owner) { + return list( + "SELECT ws FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false ORDER BY ws.createdAt DESC", + io.quarkus.panache.common.Parameters.with("owner", owner)); + } /** Find all finalized sessions for a user */ - List findByOwnerAndFinalizedTrueOrderByCreatedAtDesc(User owner); + public List findByOwnerAndFinalizedTrueOrderByCreatedAtDesc(User owner) { + return list( + "owner = ?1 and finalized = true", + Sort.by("createdAt", Sort.Direction.Descending), + owner); + } /** Check if a session exists by session ID */ - boolean existsBySessionId(String sessionId); + public boolean existsBySessionId(String sessionId) { + return count("sessionId", sessionId) > 0; + } /** Find sessions that need cleanup (e.g., old cancelled sessions) */ - @Query( - "SELECT ws FROM WorkflowSession ws WHERE ws.status = 'CANCELLED' AND ws.updatedAt < :cutoffDate") - List findCancelledSessionsOlderThan( - @Param("cutoffDate") java.time.LocalDateTime cutoffDate); + public List findCancelledSessionsOlderThan( + java.time.LocalDateTime cutoffDate) { + return list( + "SELECT ws FROM WorkflowSession ws WHERE ws.status = 'CANCELLED' AND ws.updatedAt < :cutoffDate", + io.quarkus.panache.common.Parameters.with("cutoffDate", cutoffDate)); + } /** Count active sessions for a user */ - @Query( - "SELECT COUNT(ws) FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false") - long countActiveSessionsByOwner(@Param("owner") User owner); + public long countActiveSessionsByOwner(User owner) { + return count( + "owner = :owner and status = 'IN_PROGRESS' and finalized = false", + io.quarkus.panache.common.Parameters.with("owner", owner)); + } /** Delete session by session ID and owner (for authorization) */ - void deleteBySessionIdAndOwner(String sessionId, User owner); + public void deleteBySessionIdAndOwner(String sessionId, User owner) { + delete("sessionId = ?1 and owner = ?2", sessionId, owner); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidator.java index a833ac0343..f9106c849d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/CertificateSubmissionValidator.java @@ -16,9 +16,10 @@ import java.util.Enumeration; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,7 +37,7 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo; * is caught here first. */ @Slf4j -@Service +@ApplicationScoped @RequiredArgsConstructor public class CertificateSubmissionValidator { @@ -58,7 +59,7 @@ public class CertificateSubmissionValidator { * @param certType "P12", "PKCS12", "PFX", or "JKS" (case-insensitive) * @param password keystore password (may be null or empty) * @return {@link CertificateInfo} with subject, issuer, and validity dates on success - * @throws ResponseStatusException HTTP 400 with a user-friendly message on any failure + * @throws WebApplicationException HTTP 400 with a user-friendly message on any failure */ public CertificateInfo validateAndExtractInfo( byte[] keystoreBytes, String certType, String password) { @@ -99,14 +100,14 @@ public class CertificateSubmissionValidator { // PKCS12: wrong password produces an IOException with "keystore password was incorrect" // JKS: wrong password produces IOException wrapping UnrecoverableKeyException log.debug("Failed to load {} keystore: {}", keystoreType, e.getMessage()); - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Invalid certificate password or corrupt keystore file"); + throw new WebApplicationException( + "Invalid certificate password or corrupt keystore file", + Response.Status.BAD_REQUEST); } catch (Exception e) { log.debug("Failed to instantiate {} keystore: {}", keystoreType, e.getMessage()); - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Invalid certificate password or corrupt keystore file"); + throw new WebApplicationException( + "Invalid certificate password or corrupt keystore file", + Response.Status.BAD_REQUEST); } } @@ -119,9 +120,9 @@ public class CertificateSubmissionValidator { try { key = (PrivateKey) keystore.getKey(alias, password); } catch (UnrecoverableKeyException | java.security.NoSuchAlgorithmException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Invalid certificate password or corrupt keystore file"); + throw new WebApplicationException( + "Invalid certificate password or corrupt keystore file", + Response.Status.BAD_REQUEST); } if (key == null) continue; @@ -130,30 +131,30 @@ public class CertificateSubmissionValidator { return (X509Certificate) chain[0]; } } - } catch (ResponseStatusException e) { + } catch (WebApplicationException e) { throw e; } catch (KeyStoreException e) { log.debug("KeyStore alias enumeration failed: {}", e.getMessage()); } - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "No private key found in the provided keystore"); + throw new WebApplicationException( + "No private key found in the provided keystore", Response.Status.BAD_REQUEST); } private void validateCertValidity(X509Certificate cert) { try { cert.checkValidity(); } catch (CertificateExpiredException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, + throw new WebApplicationException( "Certificate has expired (expired: " + DATE_FORMAT.format(cert.getNotAfter().toInstant()) - + ")"); + + ")", + Response.Status.BAD_REQUEST); } catch (CertificateNotYetValidException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, + throw new WebApplicationException( "Certificate is not yet valid (valid from: " + DATE_FORMAT.format(cert.getNotBefore().toInstant()) - + ")"); + + ")", + Response.Status.BAD_REQUEST); } } @@ -162,13 +163,13 @@ public class CertificateSubmissionValidator { byte[] blankPdf = createBlankPdf(); pdfSigningService.signWithKeystore( blankPdf, keystore, password, false, null, signerName, null, null, false); - } catch (ResponseStatusException e) { + } catch (WebApplicationException e) { throw e; } catch (Exception e) { log.debug("Certificate test-sign failed: {}", e.getMessage()); - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Certificate is not compatible with the signing algorithm: " + e.getMessage()); + throw new WebApplicationException( + "Certificate is not compatible with the signing algorithm: " + e.getMessage(), + Response.Status.BAD_REQUEST); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java index 9241a9ca4e..3a7fd36803 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java @@ -10,7 +10,7 @@ import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +25,7 @@ import stirling.software.common.model.ApplicationProperties; *

    Encrypted values are prefixed with {@value #ENC_PREFIX} so that legacy plaintext values * written before this service was introduced can still be decrypted transparently. */ -@Service +@ApplicationScoped @RequiredArgsConstructor @Slf4j public class MetadataEncryptionService { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index aac9c180fc..26324d361b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -29,15 +29,16 @@ import org.bouncycastle.asn1.x500.RDN; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x500.style.BCStyle; import org.bouncycastle.asn1.x500.style.IETFUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.io.ClassPathResource; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfSigningService; import stirling.software.common.service.ServerCertificateServiceInterface; @@ -55,7 +56,7 @@ import tools.jackson.databind.ObjectMapper; * (wet signatures, summary page, digital certificate application) that was previously spread across * the controller. */ -@Service +@ApplicationScoped @RequiredArgsConstructor @Slf4j public class SigningFinalizationService { @@ -66,11 +67,24 @@ public class SigningFinalizationService { private final PdfSigningService pdfSigningService; private final MetadataEncryptionService metadataEncryptionService; - @Autowired(required = false) - private final ServerCertificateServiceInterface serverCertificateService; + // @Autowired(required = false) -> CDI Instance for optional/unsatisfied beans + private final Instance serverCertificateServiceInstance; - @Autowired(required = false) - private final UserServerCertificateService userServerCertificateService; + private final Instance userServerCertificateServiceInstance; + + /** Resolves the optional server certificate service, or null if no bean is available. */ + private ServerCertificateServiceInterface serverCertificateService() { + return serverCertificateServiceInstance.isResolvable() + ? serverCertificateServiceInstance.get() + : null; + } + + /** Resolves the optional user server certificate service, or null if no bean is available. */ + private UserServerCertificateService userServerCertificateService() { + return userServerCertificateServiceInstance.isResolvable() + ? userServerCertificateServiceInstance.get() + : null; + } // ===== PUBLIC API ===== @@ -128,13 +142,12 @@ public class SigningFinalizationService { // Reload from DB to get fresh metadata WorkflowParticipant fresh = participantRepository - .findById(participant.getId()) + .findByIdOptional(participant.getId()) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, - "Participant not found: " - + participant.getId())); + new WebApplicationException( + "Participant not found: " + participant.getId(), + Response.Status.INTERNAL_SERVER_ERROR)); CertificateSubmission submission = extractCertificateSubmission(fresh); if (submission == null) { @@ -172,6 +185,7 @@ public class SigningFinalizationService { * Clears sensitive metadata from all participants after finalization (GDPR compliance). Removes * wet signature image data and certificate submission data (keystores + passwords). */ + @jakarta.transaction.Transactional public void clearSensitiveMetadata(WorkflowSession session) { log.info("Clearing sensitive metadata for session {}", session.getSessionId()); @@ -192,7 +206,7 @@ public class SigningFinalizationService { } if (modified) { participant.setParticipantMetadata(metadata); - participantRepository.save(participant); + participantRepository.persist(participant); log.debug("Cleared sensitive metadata for participant {}", participant.getEmail()); } } @@ -248,9 +262,9 @@ public class SigningFinalizationService { // Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix String base64Data = wetSig.extractBase64Data(); if (base64Data == null || base64Data.isBlank()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Wet signature image data is missing or empty for participant"); + throw new WebApplicationException( + "Wet signature image data is missing or empty for participant", + Response.Status.BAD_REQUEST); } byte[] imageBytes = java.util.Base64.getDecoder().decode(base64Data); @@ -825,8 +839,8 @@ public class SigningFinalizationService { case "PKCS12": case "PFX": if (submission.getP12Keystore() == null) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "P12 keystore data is required"); + throw new WebApplicationException( + "P12 keystore data is required", Response.Status.BAD_REQUEST); } try { KeyStore p12Store = KeyStore.getInstance("PKCS12"); @@ -835,15 +849,15 @@ public class SigningFinalizationService { password != null ? password.toCharArray() : new char[0]); return p12Store; } catch (Exception e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Failed to open P12 keystore — check that the file is valid and the password is correct"); + throw new WebApplicationException( + "Failed to open P12 keystore - check that the file is valid and the password is correct", + Response.Status.BAD_REQUEST); } case "JKS": if (submission.getJksKeystore() == null) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "JKS keystore data is required"); + throw new WebApplicationException( + "JKS keystore data is required", Response.Status.BAD_REQUEST); } try { KeyStore jksStore = KeyStore.getInstance("JKS"); @@ -852,29 +866,35 @@ public class SigningFinalizationService { password != null ? password.toCharArray() : new char[0]); return jksStore; } catch (Exception e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Failed to open JKS keystore — check that the file is valid and the password is correct"); + throw new WebApplicationException( + "Failed to open JKS keystore - check that the file is valid and the password is correct", + Response.Status.BAD_REQUEST); } case "SERVER": + ServerCertificateServiceInterface serverCertificateService = + serverCertificateService(); if (serverCertificateService == null || !serverCertificateService.isEnabled() || !serverCertificateService.hasServerCertificate()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Server certificate is not available or not configured"); + throw new WebApplicationException( + "Server certificate is not available or not configured", + Response.Status.BAD_REQUEST); } return serverCertificateService.getServerKeyStore(); case "USER_CERT": + UserServerCertificateService userServerCertificateService = + userServerCertificateService(); if (userServerCertificateService == null) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "User certificate service is not available"); + throw new WebApplicationException( + "User certificate service is not available", + Response.Status.BAD_REQUEST); } if (participant.getUser() == null) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "User certificate requires authenticated user"); + throw new WebApplicationException( + "User certificate requires authenticated user", + Response.Status.BAD_REQUEST); } try { userServerCertificateService.getOrCreateUserCertificate( @@ -886,14 +906,14 @@ public class SigningFinalizationService { "Failed to get user certificate for user {}: {}", participant.getUser().getId(), e.getMessage()); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, - "Failed to generate or retrieve user certificate: " + e.getMessage()); + throw new WebApplicationException( + "Failed to generate or retrieve user certificate: " + e.getMessage(), + Response.Status.INTERNAL_SERVER_ERROR); } default: - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Invalid certificate type: " + certType); + throw new WebApplicationException( + "Invalid certificate type: " + certType, Response.Status.BAD_REQUEST); } } @@ -907,17 +927,17 @@ public class SigningFinalizationService { try { x509.checkValidity(); } catch (java.security.cert.CertificateExpiredException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, + throw new WebApplicationException( "Certificate for participant '" + participantEmail - + "' has expired. Please upload a valid certificate."); + + "' has expired. Please upload a valid certificate.", + Response.Status.BAD_REQUEST); } catch (java.security.cert.CertificateNotYetValidException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, + throw new WebApplicationException( "Certificate for participant '" + participantEmail - + "' is not yet valid."); + + "' is not yet valid.", + Response.Status.BAD_REQUEST); } } } @@ -927,10 +947,12 @@ public class SigningFinalizationService { CertificateSubmission submission, WorkflowParticipant participant) { String certType = submission.getCertType(); + ServerCertificateServiceInterface serverCertificateService = serverCertificateService(); if ("SERVER".equalsIgnoreCase(certType) && serverCertificateService != null) { return serverCertificateService.getServerCertificatePassword(); } + UserServerCertificateService userServerCertificateService = userServerCertificateService(); if ("USER_CERT".equalsIgnoreCase(certType) && userServerCertificateService != null && participant.getUser() != null) { @@ -1176,7 +1198,7 @@ public class SigningFinalizationService { try { fresh = participantRepository - .findById(participant.getId()) + .findByIdOptional(participant.getId()) .orElseThrow( () -> new RuntimeException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java index 57ad1c97e7..6209aaa8a5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java @@ -3,8 +3,8 @@ package stirling.software.proprietary.workflow.service; import java.time.LocalDateTime; import java.util.Optional; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,10 +25,12 @@ import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepo *

    This service bridges the gap between the file sharing infrastructure and workflow-specific * access control. */ -@Service +@ApplicationScoped @RequiredArgsConstructor @Slf4j -@Transactional(readOnly = true) +// TODO: Migration note - jakarta.transaction.Transactional has no readOnly attribute; +// mapped to SUPPORTS so read methods join an existing tx without forcing a new one. +@Transactional(Transactional.TxType.SUPPORTS) public class UnifiedAccessControlService { private final FileShareRepository fileShareRepository; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java index 5f73deb322..93b2c53026 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java @@ -23,10 +23,11 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,7 +38,7 @@ import stirling.software.proprietary.workflow.model.CertificateType; import stirling.software.proprietary.workflow.model.UserServerCertificateEntity; import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository; -@Service +@ApplicationScoped @Slf4j @RequiredArgsConstructor public class UserServerCertificateService { @@ -64,7 +65,7 @@ public class UserServerCertificateService { User user = userRepository - .findById(userId) + .findByIdOptional(userId) .orElseThrow(() -> new IllegalArgumentException("User not found")); return generateUserCertificate(user); } @@ -160,7 +161,8 @@ public class UserServerCertificateService { entity.setValidTo( LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault())); - return certificateRepository.save(entity); + certificateRepository.persist(entity); + return entity; } /** Upload user-provided certificate */ @@ -172,8 +174,9 @@ public class UserServerCertificateService { // Validate keystore byte[] keystoreBytes = p12Stream.readNBytes(10 * 1024 * 1024 + 1); // read at most 10 MB + 1 if (keystoreBytes.length > 10 * 1024 * 1024) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Keystore file exceeds maximum allowed size of 10 MB"); + throw new WebApplicationException( + "Keystore file exceeds maximum allowed size of 10 MB", + Response.Status.BAD_REQUEST); } KeyStore keyStore = KeyStore.getInstance("PKCS12"); keyStore.load(new ByteArrayInputStream(keystoreBytes), password.toCharArray()); @@ -203,11 +206,12 @@ public class UserServerCertificateService { entity.setValidTo( LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault())); - return certificateRepository.save(entity); + certificateRepository.persist(entity); + return entity; } /** Get user's KeyStore for signing operations */ - @Transactional(readOnly = true) + @Transactional public KeyStore getUserKeyStore(Long userId) throws Exception { UserServerCertificateEntity cert = certificateRepository @@ -223,7 +227,7 @@ public class UserServerCertificateService { } /** Get user's keystore password */ - @Transactional(readOnly = true) + @Transactional public String getUserKeystorePassword(Long userId) { UserServerCertificateEntity cert = certificateRepository @@ -240,13 +244,13 @@ public class UserServerCertificateService { } /** Check if user has certificate */ - @Transactional(readOnly = true) + @Transactional public boolean hasUserCertificate(Long userId) { return certificateRepository.findByUserId(userId).isPresent(); } /** Get certificate info (without keystore data) */ - @Transactional(readOnly = true) + @Transactional public Optional getCertificateInfo(Long userId) { return certificateRepository.findByUserId(userId); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index e8887e2f55..8ca23b3a22 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -8,16 +8,18 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.server.ResponseStatusException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.MultipartFile; +import stirling.software.common.model.io.Resource; +import stirling.software.common.model.multipart.ByteArrayMultipartFile; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.storage.model.FilePurpose; @@ -47,7 +49,7 @@ import tools.jackson.databind.ObjectMapper; *

    Delegates file storage to FileStorageService/StorageProvider and integrates with the file * sharing infrastructure. */ -@Service +@ApplicationScoped @RequiredArgsConstructor @Slf4j @Transactional @@ -66,7 +68,8 @@ public class WorkflowSessionService { public void ensureSigningEnabled() { if (!applicationProperties.getStorage().isEnabled() || !applicationProperties.getStorage().getSigning().isEnabled()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Group signing is disabled"); + throw new WebApplicationException( + "Group signing is disabled", Response.Status.FORBIDDEN); } } @@ -83,11 +86,12 @@ public class WorkflowSessionService { // Validate request if (file == null || file.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is required"); + throw new WebApplicationException("File is required", Response.Status.BAD_REQUEST); } if (request.getWorkflowType() == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Workflow type is required"); + throw new WebApplicationException( + "Workflow type is required", Response.Status.BAD_REQUEST); } // Store original file using StorageProvider @@ -116,9 +120,9 @@ public class WorkflowSessionService { objectMapper.readValue(request.getWorkflowMetadata(), Map.class); session.setWorkflowMetadata(metadataMap); } catch (JacksonException e) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Invalid workflowMetadata: must be a valid JSON object"); + throw new WebApplicationException( + "Invalid workflowMetadata: must be a valid JSON object", + Response.Status.BAD_REQUEST); } } @@ -126,7 +130,9 @@ public class WorkflowSessionService { originalFile.setWorkflowSession(session); originalFile.setPurpose(FilePurpose.SIGNING_ORIGINAL); - session = workflowSessionRepository.save(session); + // Panache persist replaces Spring Data save; the same managed instance is reused (no + // reassignment needed because persist() is void and mutates the entity in place). + workflowSessionRepository.persist(session); storedFileRepository.save(originalFile); // Add participants @@ -210,12 +216,12 @@ public class WorkflowSessionService { if (request.getUserId() != null) { User user = userRepository - .findById(request.getUserId()) + .findByIdOptional(request.getUserId()) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, - "User not found: " + request.getUserId())); + new WebApplicationException( + "User not found: " + request.getUserId(), + Response.Status.NOT_FOUND)); participant.setUser(user); participant.setEmail(user.getUsername()); // User entity uses username, not email participant.setName(user.getUsername()); @@ -224,12 +230,13 @@ public class WorkflowSessionService { participant.setName( request.getName() != null ? request.getName() : request.getEmail()); } else { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Participant must have either userId or email"); + throw new WebApplicationException( + "Participant must have either userId or email", + Response.Status.BAD_REQUEST); } session.addParticipant(participant); - participant = workflowParticipantRepository.save(participant); + workflowParticipantRepository.persist(participant); } } @@ -252,59 +259,71 @@ public class WorkflowSessionService { } /** Retrieves a workflow session by session ID. */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public WorkflowSession getSession(String sessionId) { return workflowSessionRepository .findBySessionId(sessionId) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, - "Workflow session not found: " + sessionId)); + new WebApplicationException( + "Workflow session not found: " + sessionId, + Response.Status.NOT_FOUND)); } /** Retrieves a workflow session with authorization check. */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public WorkflowSession getSessionForOwner(String sessionId, User owner) { WorkflowSession session = getSession(sessionId); if (!session.getOwner().equals(owner)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Not authorized to access this workflow session"); + throw new WebApplicationException( + "Not authorized to access this workflow session", Response.Status.FORBIDDEN); } return session; } /** Retrieves a workflow session with participants eagerly loaded for finalization. */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public WorkflowSession getSessionWithParticipants(String sessionId) { return workflowSessionRepository .findBySessionIdWithParticipants(sessionId) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, - "Workflow session not found: " + sessionId)); + new WebApplicationException( + "Workflow session not found: " + sessionId, + Response.Status.NOT_FOUND)); } /** Retrieves a workflow session with participants, with authorization check. */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public WorkflowSession getSessionWithParticipantsForOwner(String sessionId, User owner) { WorkflowSession session = getSessionWithParticipants(sessionId); if (!session.getOwner().equals(owner)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Not authorized to access this workflow session"); + throw new WebApplicationException( + "Not authorized to access this workflow session", Response.Status.FORBIDDEN); } return session; } /** Lists all workflow sessions owned by a user. */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public List listUserSessions(User owner) { return workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner); } /** Lists active workflow sessions for a user. */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public List listActiveSessions(User owner) { return workflowSessionRepository.findActiveSessionsByOwner(owner); } @@ -316,8 +335,8 @@ public class WorkflowSessionService { WorkflowSession session = getSessionForOwner(sessionId, owner); if (!session.isActive()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Cannot add participants to inactive workflow"); + throw new WebApplicationException( + "Cannot add participants to inactive workflow", Response.Status.BAD_REQUEST); } addParticipantsToSession(session, participants); @@ -331,16 +350,16 @@ public class WorkflowSessionService { WorkflowParticipant participant = workflowParticipantRepository - .findById(participantId) + .findByIdOptional(participantId) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, - "Participant not found: " + participantId)); + new WebApplicationException( + "Participant not found: " + participantId, + Response.Status.NOT_FOUND)); if (!participant.getWorkflowSession().equals(session)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Participant not in this workflow session"); + throw new WebApplicationException( + "Participant not in this workflow session", Response.Status.BAD_REQUEST); } session.removeParticipant(participant); @@ -352,15 +371,15 @@ public class WorkflowSessionService { public void updateParticipantStatus(Long participantId, ParticipantStatus newStatus) { WorkflowParticipant participant = workflowParticipantRepository - .findById(participantId) + .findByIdOptional(participantId) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, - "Participant not found: " + participantId)); + new WebApplicationException( + "Participant not found: " + participantId, + Response.Status.NOT_FOUND)); participant.setStatus(newStatus); - workflowParticipantRepository.save(participant); + workflowParticipantRepository.persist(participant); log.debug("Updated participant {} status to {}", participantId, newStatus); } @@ -368,16 +387,16 @@ public class WorkflowSessionService { public void addParticipantNotification(Long participantId, String message) { WorkflowParticipant participant = workflowParticipantRepository - .findById(participantId) + .findByIdOptional(participantId) .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.NOT_FOUND, - "Participant not found: " + participantId)); + new WebApplicationException( + "Participant not found: " + participantId, + Response.Status.NOT_FOUND)); String timestampedMessage = LocalDateTime.now().toString() + ": " + message; participant.addNotification(timestampedMessage); - workflowParticipantRepository.save(participant); + workflowParticipantRepository.persist(participant); } /** Stores the processed/finalized file for a workflow session. */ @@ -385,8 +404,9 @@ public class WorkflowSessionService { throws IOException { log.info("Storing processed file for session {}", session.getSessionId()); - // Create a temporary multipart file wrapper - MultipartFile processedFile = new ByteArrayMultipartFile(fileData, filename); + // Create a temporary multipart file wrapper (common migration shim) + MultipartFile processedFile = + new ByteArrayMultipartFile("file", filename, "application/pdf", fileData); // Store using StorageProvider StoredFile storedFile = @@ -397,7 +417,7 @@ public class WorkflowSessionService { session.setProcessedFile(storedFile); storedFileRepository.save(storedFile); - workflowSessionRepository.save(session); + workflowSessionRepository.persist(session); } /** Marks a workflow session as finalized. */ @@ -405,44 +425,52 @@ public class WorkflowSessionService { WorkflowSession session = getSessionForOwner(sessionId, owner); if (session.isFinalized()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Workflow session already finalized"); + throw new WebApplicationException( + "Workflow session already finalized", Response.Status.BAD_REQUEST); } session.setFinalized(true); session.setStatus(WorkflowStatus.COMPLETED); - workflowSessionRepository.save(session); + workflowSessionRepository.persist(session); log.info("Finalized workflow session {}", sessionId); } /** Retrieves the processed file data for a workflow session. */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public byte[] getProcessedFile(String sessionId, User owner) throws IOException { WorkflowSession session = getSessionForOwner(sessionId, owner); if (session.getProcessedFile() == null) { - throw new ResponseStatusException( - HttpStatus.NOT_FOUND, "No processed file available for this session"); + throw new WebApplicationException( + "No processed file available for this session", Response.Status.NOT_FOUND); } String storageKey = session.getProcessedFile().getStorageKey(); - org.springframework.core.io.Resource resource = storageProvider.load(storageKey); - return resource.getContentAsByteArray(); + Resource resource = storageProvider.load(storageKey); + try (java.io.InputStream in = resource.getInputStream()) { + return in.readAllBytes(); + } } /** Retrieves the original file data for a workflow session. */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public byte[] getOriginalFile(String sessionId) throws IOException { WorkflowSession session = getSession(sessionId); if (session.getOriginalFile() == null) { - throw new ResponseStatusException( - HttpStatus.NOT_FOUND, - "Original file no longer available (session may be finalized)"); + throw new WebApplicationException( + "Original file no longer available (session may be finalized)", + Response.Status.NOT_FOUND); } String storageKey = session.getOriginalFile().getStorageKey(); - org.springframework.core.io.Resource resource = storageProvider.load(storageKey); - return resource.getContentAsByteArray(); + Resource resource = storageProvider.load(storageKey); + try (java.io.InputStream in = resource.getInputStream()) { + return in.readAllBytes(); + } } /** Deletes a workflow session and associated files. */ @@ -451,9 +479,9 @@ public class WorkflowSessionService { WorkflowSession session = getSessionForOwner(sessionId, owner); if (session.isFinalized()) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Cannot delete a finalized session. The signed PDF remains accessible from your session history."); + throw new WebApplicationException( + "Cannot delete a finalized session. The signed PDF remains accessible from your session history.", + Response.Status.BAD_REQUEST); } // Delete physical storage files (non-fatal; may already be absent) @@ -510,7 +538,7 @@ public class WorkflowSessionService { storageProvider.delete(session.getOriginalFile().getStorageKey()); StoredFile originalFile = session.getOriginalFile(); session.setOriginalFile(null); - workflowSessionRepository.save(session); + workflowSessionRepository.persist(session); storedFileRepository.delete(originalFile); log.info("Deleted original presigned file for session {}", session.getSessionId()); } catch (Exception e) { @@ -529,7 +557,9 @@ public class WorkflowSessionService { * @param user The participant user * @return List of sign request summaries */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public List listSignRequests( User user) { List participations = @@ -563,7 +593,9 @@ public class WorkflowSessionService { * @param user The participant user * @return Sign request detail */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public stirling.software.proprietary.workflow.dto.SignRequestDetailDTO getSignRequestDetail( String sessionId, User user) { WorkflowSession session = getSession(sessionId); @@ -607,7 +639,7 @@ public class WorkflowSessionService { // Update status to VIEWED if it was NOTIFIED if (participant.getStatus() == ParticipantStatus.NOTIFIED) { participant.setStatus(ParticipantStatus.VIEWED); - workflowParticipantRepository.save(participant); + workflowParticipantRepository.persist(participant); } return dto; @@ -623,7 +655,9 @@ public class WorkflowSessionService { * @param user The participant user * @return PDF document bytes */ - @Transactional(readOnly = true) + // jakarta.transaction.Transactional has no readOnly attribute; mapped to SUPPORTS so read + // methods join an existing tx without forcing a new one. + @Transactional(Transactional.TxType.SUPPORTS) public byte[] getSignRequestDocument(String sessionId, User user) { WorkflowSession session = getSession(sessionId); getParticipantForUser(session, user); // Verify participant access @@ -635,18 +669,19 @@ public class WorkflowSessionService { : session.getOriginalFile(); if (fileToServe == null) { - throw new ResponseStatusException( - HttpStatus.NOT_FOUND, "Document not available for this session"); + throw new WebApplicationException( + "Document not available for this session", Response.Status.NOT_FOUND); } try { - org.springframework.core.io.Resource resource = - storageProvider.load(fileToServe.getStorageKey()); - return resource.getContentAsByteArray(); + Resource resource = storageProvider.load(fileToServe.getStorageKey()); + try (java.io.InputStream in = resource.getInputStream()) { + return in.readAllBytes(); + } } catch (IOException e) { log.error("Failed to retrieve document for session {}", sessionId, e); - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document"); + throw new WebApplicationException( + "Failed to retrieve document", Response.Status.INTERNAL_SERVER_ERROR); } } @@ -665,13 +700,13 @@ public class WorkflowSessionService { WorkflowParticipant participant = getParticipantForUser(session, user); if (participant.getStatus() == ParticipantStatus.SIGNED) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Document already signed by this user"); + throw new WebApplicationException( + "Document already signed by this user", Response.Status.BAD_REQUEST); } if (participant.getStatus() == ParticipantStatus.DECLINED) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Cannot sign after declining"); + throw new WebApplicationException( + "Cannot sign after declining", Response.Status.BAD_REQUEST); } // Build metadata JSON containing certificate submission and wet signature data @@ -696,12 +731,12 @@ public class WorkflowSessionService { request.getP12File().getBytes(), request.getCertType(), request.getPassword()); - } catch (ResponseStatusException e) { + } catch (WebApplicationException e) { throw e; } catch (IOException e) { log.error("Failed to read P12 keystore file for validation", e); - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Failed to process certificate file"); + throw new WebApplicationException( + "Failed to process certificate file", Response.Status.BAD_REQUEST); } } @@ -718,8 +753,8 @@ public class WorkflowSessionService { certSubmission.put("p12Keystore", base64Keystore); } catch (IOException e) { log.error("Failed to read P12 keystore file", e); - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Failed to process certificate file"); + throw new WebApplicationException( + "Failed to process certificate file", Response.Status.BAD_REQUEST); } } @@ -738,15 +773,15 @@ public class WorkflowSessionService { request.getWetSignaturesData(), new TypeReference>() {}); if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); + throw new WebApplicationException( + "Too many wet signatures submitted", Response.Status.BAD_REQUEST); } request.setWetSignatures(wetSigs); log.info("Parsed {} wet signatures from wetSignaturesData", wetSigs.size()); } catch (JacksonException e) { log.error("Failed to parse wetSignaturesData: {}", e.getMessage()); - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Invalid wet signatures data"); + throw new WebApplicationException( + "Invalid wet signatures data", Response.Status.BAD_REQUEST); } } @@ -789,7 +824,7 @@ public class WorkflowSessionService { // 5. Update participant status participant.setStatus(ParticipantStatus.SIGNED); - workflowParticipantRepository.save(participant); + workflowParticipantRepository.persist(participant); log.info( "User {} signed document in session {} - certificate and signature data stored", @@ -808,12 +843,12 @@ public class WorkflowSessionService { WorkflowParticipant participant = getParticipantForUser(session, user); if (participant.getStatus() == ParticipantStatus.SIGNED) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Cannot decline after signing"); + throw new WebApplicationException( + "Cannot decline after signing", Response.Status.BAD_REQUEST); } participant.setStatus(ParticipantStatus.DECLINED); - workflowParticipantRepository.save(participant); // updatedAt is auto-updated + workflowParticipantRepository.persist(participant); // updatedAt is auto-updated log.info("User {} declined sign request for session {}", user.getUsername(), sessionId); } @@ -824,7 +859,7 @@ public class WorkflowSessionService { * @param session The workflow session * @param user The user * @return Participant record - * @throws ResponseStatusException if user is not a participant + * @throws WebApplicationException if user is not a participant */ private WorkflowParticipant getParticipantForUser(WorkflowSession session, User user) { return session.getParticipants().stream() @@ -832,59 +867,8 @@ public class WorkflowSessionService { .findFirst() .orElseThrow( () -> - new ResponseStatusException( - HttpStatus.FORBIDDEN, - "User is not a participant in this session")); - } - - /** Helper class to wrap byte array as MultipartFile. */ - private static class ByteArrayMultipartFile implements MultipartFile { - private final byte[] content; - private final String filename; - - public ByteArrayMultipartFile(byte[] content, String filename) { - this.content = content; - this.filename = filename; - } - - @Override - public String getName() { - return "file"; - } - - @Override - public String getOriginalFilename() { - return filename; - } - - @Override - public String getContentType() { - return "application/pdf"; - } - - @Override - public boolean isEmpty() { - return content == null || content.length == 0; - } - - @Override - public long getSize() { - return content.length; - } - - @Override - public byte[] getBytes() { - return content; - } - - @Override - public java.io.InputStream getInputStream() { - return new java.io.ByteArrayInputStream(content); - } - - @Override - public void transferTo(java.io.File dest) throws IOException { - java.nio.file.Files.write(dest.toPath(), content); - } + new WebApplicationException( + "User is not a participant in this session", + Response.Status.FORBIDDEN)); } }