diff --git a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java index d37d4bfb6..9038e3a15 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java @@ -20,6 +20,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor { "endpoints", "logout", "error", + "days", + "date", "errorOAuth", "file", "messageType", diff --git a/app/core/src/main/java/stirling/software/common/controller/JobController.java b/app/core/src/main/java/stirling/software/common/controller/JobController.java index 44b15265b..ad346f145 100644 --- a/app/core/src/main/java/stirling/software/common/controller/JobController.java +++ b/app/core/src/main/java/stirling/software/common/controller/JobController.java @@ -10,8 +10,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -27,6 +31,8 @@ import stirling.software.common.service.TaskManager; @RestController @RequiredArgsConstructor @Slf4j +@RequestMapping("/api/v1/general") +@Tag(name = "Job Management", description = "Job Management API") public class JobController { private final TaskManager taskManager; @@ -40,7 +46,8 @@ public class JobController { * @param jobId The job ID * @return The job result */ - @GetMapping("/api/v1/general/job/{jobId}") + @GetMapping("/job/{jobId}") + @Operation(summary = "Get job status") public ResponseEntity getJobStatus(@PathVariable("jobId") String jobId) { JobResult result = taskManager.getJobResult(jobId); if (result == null) { @@ -68,7 +75,8 @@ public class JobController { * @param jobId The job ID * @return The job result */ - @GetMapping("/api/v1/general/job/{jobId}/result") + @GetMapping("/job/{jobId}/result") + @Operation(summary = "Get job result") public ResponseEntity getJobResult(@PathVariable("jobId") String jobId) { JobResult result = taskManager.getJobResult(jobId); if (result == null) { @@ -130,7 +138,8 @@ public class JobController { * @param jobId The job ID * @return Response indicating whether the job was cancelled */ - @DeleteMapping("/api/v1/general/job/{jobId}") + @DeleteMapping("/job/{jobId}") + @Operation(summary = "Cancel a job") public ResponseEntity cancelJob(@PathVariable("jobId") String jobId) { log.debug("Request to cancel job: {}", jobId); @@ -197,7 +206,8 @@ public class JobController { * @param jobId The job ID * @return List of files for the job */ - @GetMapping("/api/v1/general/job/{jobId}/result/files") + @GetMapping("/job/{jobId}/result/files") + @Operation(summary = "Get job result files") public ResponseEntity getJobFiles(@PathVariable("jobId") String jobId) { JobResult result = taskManager.getJobResult(jobId); if (result == null) { @@ -226,7 +236,8 @@ public class JobController { * @param fileId The file ID * @return The file metadata */ - @GetMapping("/api/v1/general/files/{fileId}/metadata") + @GetMapping("/files/{fileId}/metadata") + @Operation(summary = "Get file metadata") public ResponseEntity getFileMetadata(@PathVariable("fileId") String fileId) { try { // Verify file exists @@ -266,7 +277,8 @@ public class JobController { * @param fileId The file ID * @return The file content */ - @GetMapping("/api/v1/general/files/{fileId}") + @GetMapping("/files/{fileId}") + @Operation(summary = "Download a file") public ResponseEntity downloadFile(@PathVariable("fileId") String fileId) { try { // Verify file exists diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AdminJobController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java similarity index 80% rename from app/proprietary/src/main/java/stirling/software/proprietary/controller/AdminJobController.java rename to app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java index cdb8f24a3..d68237dfb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AdminJobController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.controller; +package stirling.software.proprietary.controller.api; import java.util.Map; @@ -6,8 +6,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +26,9 @@ import stirling.software.common.service.TaskManager; @RestController @RequiredArgsConstructor @Slf4j +@RequestMapping("/api/v1/admin") +@PreAuthorize("hasRole('ROLE_ADMIN')") +@Tag(name = "Admin Job Management", description = "Admin-only Job Management APIs") public class AdminJobController { private final TaskManager taskManager; @@ -32,7 +39,8 @@ public class AdminJobController { * * @return Job statistics */ - @GetMapping("/api/v1/admin/job/stats") + @GetMapping("/job/stats") + @Operation(summary = "Get job statistics") @PreAuthorize("hasRole('ROLE_ADMIN')") public ResponseEntity getJobStats() { JobStats stats = taskManager.getJobStats(); @@ -48,7 +56,8 @@ public class AdminJobController { * * @return Queue statistics */ - @GetMapping("/api/v1/admin/job/queue/stats") + @GetMapping("/job/queue/stats") + @Operation(summary = "Get job queue statistics") @PreAuthorize("hasRole('ROLE_ADMIN')") public ResponseEntity getQueueStats() { Map queueStats = jobQueue.getQueueStats(); @@ -61,7 +70,8 @@ public class AdminJobController { * * @return A response indicating how many jobs were cleaned up */ - @PostMapping("/api/v1/admin/job/cleanup") + @PostMapping("/job/cleanup") + @Operation(summary = "Cleanup old jobs") @PreAuthorize("hasRole('ROLE_ADMIN')") public ResponseEntity cleanupOldJobs() { int beforeCount = taskManager.getJobStats().getTotalJobs(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java similarity index 56% rename from app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java rename to app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java index 67b71ccd8..cd1c8b9e5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.controller; +package stirling.software.proprietary.controller.api; import java.time.Instant; import java.time.LocalDate; @@ -6,13 +6,13 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Arrays; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -22,133 +22,101 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.audit.AuditEventType; -import stirling.software.proprietary.audit.AuditLevel; -import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.api.audit.AuditDataRequest; +import stirling.software.proprietary.model.api.audit.AuditDataResponse; +import stirling.software.proprietary.model.api.audit.AuditExportRequest; +import stirling.software.proprietary.model.api.audit.AuditStatsResponse; import stirling.software.proprietary.model.security.PersistentAuditEvent; import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.security.config.EnterpriseEndpoint; -/** Controller for the audit dashboard. Admin-only access. */ +/** REST endpoints for the audit dashboard. */ @Slf4j -@Controller -@RequestMapping("/audit") -@PreAuthorize("hasRole('ADMIN')") +@RestController +@RequestMapping("/api/v1/audit") +@PreAuthorize("hasRole('ROLE_ADMIN')") @RequiredArgsConstructor @EnterpriseEndpoint +@Tag(name = "Audit", description = "Only Enterprise - Audit related operations") public class AuditDashboardController { private final PersistentAuditEventRepository auditRepository; - private final AuditConfigurationProperties auditConfig; private final ObjectMapper objectMapper; - /** Display the audit dashboard. */ - @GetMapping - public String showDashboard(Model model) { - model.addAttribute("auditEnabled", auditConfig.isEnabled()); - model.addAttribute("auditLevel", auditConfig.getAuditLevel()); - model.addAttribute("auditLevelInt", auditConfig.getLevel()); - model.addAttribute("retentionDays", auditConfig.getRetentionDays()); - - // Add audit level enum values for display - model.addAttribute("auditLevels", AuditLevel.values()); - - // Add audit event types for the dropdown - model.addAttribute("auditEventTypes", AuditEventType.values()); - - return "audit/dashboard"; - } - /** Get audit events data for the dashboard tables. */ @GetMapping("/data") - @ResponseBody - public Map getAuditData( - @RequestParam(value = "page", defaultValue = "0") int page, - @RequestParam(value = "size", defaultValue = "30") int size, - @RequestParam(value = "type", required = false) String type, - @RequestParam(value = "principal", required = false) String principal, - @RequestParam(value = "startDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate startDate, - @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate endDate, - HttpServletRequest request) { + @Operation(summary = "Get audit events data") + public AuditDataResponse getAuditData(@ParameterObject AuditDataRequest request) { - Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending()); + Pageable pageable = + PageRequest.of( + request.getPage(), request.getSize(), Sort.by("timestamp").descending()); Page events; - String mode; + String type = request.getType(); + String principal = request.getPrincipal(); + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); if (type != null && principal != null && startDate != null && endDate != null) { - mode = "principal + type + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByPrincipalAndTypeAndTimestampBetween( principal, type, start, end, pageable); } else if (type != null && principal != null) { - mode = "principal + type"; events = auditRepository.findByPrincipalAndType(principal, type, pageable); } else if (type != null && startDate != null && endDate != null) { - mode = "type + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable); } else if (principal != null && startDate != null && endDate != null) { - mode = "principal + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByPrincipalAndTimestampBetween( principal, start, end, pageable); } else if (startDate != null && endDate != null) { - mode = "startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByTimestampBetween(start, end, pageable); } else if (type != null) { - mode = "type"; events = auditRepository.findByType(type, pageable); } else if (principal != null) { - mode = "principal"; events = auditRepository.findByPrincipal(principal, pageable); } else { - mode = "all"; events = auditRepository.findAll(pageable); } // Logging List content = events.getContent(); - Map response = new HashMap<>(); - response.put("content", content); - response.put("totalPages", events.getTotalPages()); - response.put("totalElements", events.getTotalElements()); - response.put("currentPage", events.getNumber()); - - return response; + return new AuditDataResponse( + content, events.getTotalPages(), events.getTotalElements(), events.getNumber()); } - /** Get statistics for charts. */ + /** Get statistics for charts (last X days). Existing behavior preserved. */ @GetMapping("/stats") - @ResponseBody - public Map getAuditStats( + @Operation(summary = "Get audit statistics for the last N days") + public AuditStatsResponse getAuditStats( + @Schema(description = "Number of days to look back for audit events", example = "7", required = true) @RequestParam(value = "days", defaultValue = "7") int days) { // Get events from the last X days @@ -181,18 +149,53 @@ public class AuditDashboardController { .format(DateTimeFormatter.ISO_LOCAL_DATE), Collectors.counting())); - Map stats = new HashMap<>(); - stats.put("eventsByType", eventsByType); - stats.put("eventsByPrincipal", eventsByPrincipal); - stats.put("eventsByDay", eventsByDay); - stats.put("totalEvents", events.size()); - - return stats; + return new AuditStatsResponse(eventsByType, eventsByPrincipal, eventsByDay, events.size()); } + // /** Advanced statistics using repository aggregations, with explicit date range. */ + // @GetMapping("/stats/range") + // @Operation(summary = "Get audit statistics for a date range (aggregated in DB)") + // public Map getAuditStatsRange(@ParameterObject AuditDateExportRequest + // request) { + + // LocalDate startDate = request.getStartDate(); + // LocalDate endDate = request.getEndDate(); + // Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + // Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + + // Map byType = toStringLongMap(auditRepository.countByTypeBetween(start, + // end)); + // Map byPrincipal = + // toStringLongMap(auditRepository.countByPrincipalBetween(start, end)); + + // Map byDay = new HashMap<>(); + // for (Object[] row : auditRepository.histogramByDayBetween(start, end)) { + // int y = ((Number) row[0]).intValue(); + // int m = ((Number) row[1]).intValue(); + // int d = ((Number) row[2]).intValue(); + // long count = ((Number) row[3]).longValue(); + // String key = String.format("%04d-%02d-%02d", y, m, d); + // byDay.put(key, count); + // } + + // Map byHour = new HashMap<>(); + // for (Object[] row : auditRepository.histogramByHourBetween(start, end)) { + // int hour = ((Number) row[0]).intValue(); + // long count = ((Number) row[1]).longValue(); + // byHour.put(String.format("%02d:00", hour), count); + // } + + // Map payload = new HashMap<>(); + // payload.put("byType", byType); + // payload.put("byPrincipal", byPrincipal); + // payload.put("byDay", byDay); + // payload.put("byHour", byHour); + // return payload; + // } + /** Get all unique event types from the database for filtering. */ @GetMapping("/types") - @ResponseBody + @Operation(summary = "Get all unique audit event types") public List getAuditTypes() { // Get distinct event types from the database List dbTypes = auditRepository.findDistinctEventTypes(); @@ -212,49 +215,11 @@ public class AuditDashboardController { } /** Export audit data as CSV. */ - @GetMapping("/export") - public ResponseEntity exportAuditData( - @RequestParam(value = "type", required = false) String type, - @RequestParam(value = "principal", required = false) String principal, - @RequestParam(value = "startDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate startDate, - @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate endDate) { + @GetMapping("/export/csv") + @Operation(summary = "Export audit data as CSV") + public ResponseEntity exportAuditData(@ParameterObject AuditExportRequest request) { - // Get data with same filtering as getAuditData - List events; - - if (type != null && principal != null && startDate != null && endDate != null) { - Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = - auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport( - principal, type, start, end); - } else if (type != null && principal != null) { - events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type); - } else if (type != null && startDate != null && endDate != null) { - Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end); - } else if (principal != null && startDate != null && endDate != null) { - Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = - auditRepository.findAllByPrincipalAndTimestampBetweenForExport( - principal, start, end); - } else if (startDate != null && endDate != null) { - Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = auditRepository.findAllByTimestampBetweenForExport(start, end); - } else if (type != null) { - events = auditRepository.findByTypeForExport(type); - } else if (principal != null) { - events = auditRepository.findAllByPrincipalForExport(principal); - } else { - events = auditRepository.findAll(); - } + List events = getAuditEventsByCriteria(request); // Convert to CSV StringBuilder csv = new StringBuilder(); @@ -282,15 +247,113 @@ public class AuditDashboardController { /** Export audit data as JSON. */ @GetMapping("/export/json") - public ResponseEntity exportAuditDataJson( - @RequestParam(value = "type", required = false) String type, - @RequestParam(value = "principal", required = false) String principal, - @RequestParam(value = "startDate", required = false) + @Operation(summary = "Export audit data as JSON") + public ResponseEntity exportAuditDataJson(@ParameterObject AuditExportRequest request) { + + List events = getAuditEventsByCriteria(request); + + // Convert to JSON + try { + byte[] jsonBytes = objectMapper.writeValueAsBytes(events); + + // Set up HTTP headers for download + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setContentDispositionFormData("attachment", "audit_export.json"); + + return ResponseEntity.ok().headers(headers).body(jsonBytes); + } catch (JsonProcessingException e) { + log.error("Error serializing audit events to JSON", e); + return ResponseEntity.internalServerError().build(); + } + } + + // /** Get all unique principals. */ + // @GetMapping("/principals") + // @Operation(summary = "Get all distinct principals") + // public List getPrincipals() { + // return auditRepository.findDistinctPrincipals(); + // } + + // /** Get principals by event type. */ + // @GetMapping("/types/{type}/principals") + // @Operation(summary = "Get distinct principals for a given type") + // public List getPrincipalsByType(@PathVariable("type") String type) { + // return auditRepository.findDistinctPrincipalsByType(type); + // } + + // /** Latest helpers */ + // @GetMapping("/latest") + // @Operation(summary = "Get the latest audit event, optionally filtered by type or principal") + // public ResponseEntity getLatest( + // @RequestParam(value = "type", required = false) String type, + // @RequestParam(value = "principal", required = false) String principal) { + // if (type != null) { + // return auditRepository + // .findTopByTypeOrderByTimestampDesc(type) + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } else if (principal != null) { + // return auditRepository + // .findTopByPrincipalOrderByTimestampDesc(principal) + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } + // return auditRepository + // .findTopByOrderByTimestampDesc() + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } + + /** Cleanup endpoints data before a certain date */ + @DeleteMapping("/cleanup/before") + @Operation( + summary = "Cleanup audit events before a certain date", + description = "Deletes all audit events before the specified date.") + public Map cleanupBefore( + @RequestParam(value = "date", required = true) + @Schema( + description = "The cutoff date for cleanup", + example = "2025-01-01", + format = "date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate startDate, - @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate endDate) { + LocalDate date) { + if (date != null && !date.isAfter(LocalDate.now())) { + Instant cutoff = date.atStartOfDay(ZoneId.systemDefault()).toInstant(); + int deleted = auditRepository.deleteByTimestampBefore(cutoff); + return Map.of("deleted", deleted, "cutoffDate", date.toString()); + } + return Map.of( + "error", + "Invalid date format. Use ISO date format (YYYY-MM-DD). Date must be in the past."); + } + + // // ===== Helpers ===== + + // private Map toStringLongMap(List rows) { + // Map map = new HashMap<>(); + // for (Object[] row : rows) { + // String key = String.valueOf(row[0]); + // long val = ((Number) row[1]).longValue(); + // map.put(key, val); + // } + // return map; + // } + + /** Helper method to escape CSV fields. */ + private String escapeCSV(String field) { + if (field == null) { + return ""; + } + // Replace double quotes with two double quotes and wrap in quotes + return "\"" + field.replace("\"", "\"\"") + "\""; + } + + private List getAuditEventsByCriteria(AuditExportRequest request) { + String type = request.getType(); + String principal = request.getPrincipal(); + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); // Get data with same filtering as getAuditData List events; @@ -324,29 +387,6 @@ public class AuditDashboardController { } else { events = auditRepository.findAll(); } - - // Convert to JSON - try { - byte[] jsonBytes = objectMapper.writeValueAsBytes(events); - - // Set up HTTP headers for download - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setContentDispositionFormData("attachment", "audit_export.json"); - - return ResponseEntity.ok().headers(headers).body(jsonBytes); - } catch (JsonProcessingException e) { - log.error("Error serializing audit events to JSON", e); - return ResponseEntity.internalServerError().build(); - } - } - - /** Helper method to escape CSV fields. */ - private String escapeCSV(String field) { - if (field == null) { - return ""; - } - // Replace double quotes with two double quotes and wrap in quotes - return "\"" + field.replace("\"", "\"\"") + "\""; + return events; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/web/AuditDashboardWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/web/AuditDashboardWebController.java new file mode 100644 index 000000000..e5c80e162 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/web/AuditDashboardWebController.java @@ -0,0 +1,41 @@ +package stirling.software.proprietary.controller.web; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +@Controller +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +@EnterpriseEndpoint +public class AuditDashboardWebController { + private final AuditConfigurationProperties auditConfig; + + /** Display the audit dashboard. */ + @GetMapping("/audit") + @Hidden + public String showDashboard(Model model) { + model.addAttribute("auditEnabled", auditConfig.isEnabled()); + model.addAttribute("auditLevel", auditConfig.getAuditLevel()); + model.addAttribute("auditLevelInt", auditConfig.getLevel()); + model.addAttribute("retentionDays", auditConfig.getRetentionDays()); + + // Add audit level enum values for display + model.addAttribute("auditLevels", AuditLevel.values()); + + // Add audit event types for the dropdown + model.addAttribute("auditEventTypes", AuditEventType.values()); + + return "audit/dashboard"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataRequest.java new file mode 100644 index 000000000..c9a792119 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataRequest.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.model.api.audit; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** Request object used for querying audit events. */ +@Data +@EnterpriseEndpoint +@EqualsAndHashCode(callSuper = true) +public class AuditDataRequest extends AuditExportRequest { + + @Schema(description = "Page number for pagination", example = "0", defaultValue = "0") + private int page = 0; + + @Schema(description = "Page size for pagination", example = "30", defaultValue = "30") + private int size = 30; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataResponse.java new file mode 100644 index 000000000..3d207af39 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataResponse.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.model.api.audit; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** Response object returned when querying audit data. */ +@Data +@EnterpriseEndpoint +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AuditDataResponse { + + @Schema(description = "List of audit events matching the query") + private List content; + + @Schema(description = "Total number of pages available", example = "5") + private int totalPages; + + @Schema(description = "Total number of events", example = "150") + private long totalElements; + + @Schema(description = "Current page index", example = "0") + private int currentPage; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java new file mode 100644 index 000000000..6ce947d09 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.model.api.audit; + +import java.time.LocalDate; + +import org.springframework.format.annotation.DateTimeFormat; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +@Data +@EnterpriseEndpoint +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AuditDateExportRequest { + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Schema(description = "Start date for the export range", example = "2025-01-01") + private LocalDate startDate; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Schema(description = "End date for the export range", example = "2025-12-31") + private LocalDate endDate; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditExportRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditExportRequest.java new file mode 100644 index 000000000..c484fe43c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditExportRequest.java @@ -0,0 +1,37 @@ +package stirling.software.proprietary.model.api.audit; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** Request object used for exporting audit data with filters. */ +@Data +@EnterpriseEndpoint +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class AuditExportRequest extends AuditDateExportRequest { + + @Schema( + description = "Audit event type to filter by", + example = "USER_LOGIN", + allowableValues = { + "USER_LOGIN", + "USER_LOGOUT", + "USER_FAILED_LOGIN", + "USER_PROFILE_UPDATE", + "SETTINGS_CHANGED", + "FILE_OPERATION", + "PDF_PROCESS", + "HTTP_REQUEST" + }) + private String type; + + @Schema(description = "Principal (username) to filter by", example = "admin") + private String principal; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditStatsResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditStatsResponse.java new file mode 100644 index 000000000..933661cb4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditStatsResponse.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.model.api.audit; + +import java.util.Map; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** Response object for audit statistics. */ +@Data +@EnterpriseEndpoint +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AuditStatsResponse { + + @Schema(description = "Count of events grouped by type") + private Map eventsByType; + + @Schema(description = "Count of events grouped by principal") + private Map eventsByPrincipal; + + @Schema(description = "Count of events grouped by day") + private Map eventsByDay; + + @Schema(description = "Total number of events in the period", example = "42") + private int totalEvents; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java index af6d7d554..121d8a95c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.repository; import java.time.Instant; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -19,7 +20,8 @@ public interface PersistentAuditEventRepository extends JpaRepository findByPrincipal( @Param("principal") String principal, Pageable pageable); @@ -29,12 +31,14 @@ public interface PersistentAuditEventRepository extends JpaRepository findByPrincipalAndType( @Param("principal") String principal, @Param("type") String type, Pageable pageable); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") Page findByPrincipalAndTimestampBetween( @Param("principal") String principal, @Param("startDate") Instant startDate, @@ -45,7 +49,9 @@ public interface PersistentAuditEventRepository extends JpaRepository findByPrincipalAndTypeAndTimestampBetween( @Param("principal") String principal, @Param("type") String type, @@ -55,7 +61,8 @@ public interface PersistentAuditEventRepository extends JpaRepository findAllByPrincipalForExport(@Param("principal") String principal); @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type") @@ -69,26 +76,31 @@ public interface PersistentAuditEventRepository extends JpaRepository findByTimestampAfter(@Param("startDate") Instant startDate); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type") + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.type = :type") List findAllByPrincipalAndTypeForExport( @Param("principal") String principal, @Param("type") String type); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") List findAllByPrincipalAndTimestampBetweenForExport( @Param("principal") String principal, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate") + "SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN" + + " :startDate AND :endDate") List findAllByTypeAndTimestampBetweenForExport( @Param("type") String type, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate") + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND" + + " :endDate") List findAllByPrincipalAndTypeAndTimestampBetweenForExport( @Param("principal") String principal, @Param("type") String type, @@ -112,7 +124,51 @@ public interface PersistentAuditEventRepository extends JpaRepository countByPrincipal(); + @Query( + "SELECT e.type, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN" + + " :startDate AND :endDate GROUP BY e.type") + List countByTypeBetween( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query( + "SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN" + + " :startDate AND :endDate GROUP BY e.principal") + List countByPrincipalBetween( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + // Portable time-bucketing using YEAR/MONTH/DAY functions (works across most dialects) + @Query( + "SELECT YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp), COUNT(e) " + + "FROM PersistentAuditEvent e " + + "WHERE e.timestamp BETWEEN :startDate AND :endDate " + + "GROUP BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp) " + + "ORDER BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp)") + List histogramByDayBetween( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query( + "SELECT HOUR(e.timestamp), COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp" + + " BETWEEN :startDate AND :endDate GROUP BY HOUR(e.timestamp) ORDER BY" + + " HOUR(e.timestamp)") + List histogramByHourBetween( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + // Get distinct event types for filtering @Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type") List findDistinctEventTypes(); + + @Query("SELECT DISTINCT e.principal FROM PersistentAuditEvent e ORDER BY e.principal") + List findDistinctPrincipals(); + + @Query( + "SELECT DISTINCT e.principal FROM PersistentAuditEvent e WHERE e.type = :type ORDER BY" + + " e.principal") + List findDistinctPrincipalsByType(@Param("type") String type); + + // Top/Latest helpers & existence checks + Optional findTopByOrderByTimestampDesc(); + + Optional findTopByPrincipalOrderByTimestampDesc(String principal); + + Optional findTopByTypeOrderByTimestampDesc(String type); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java index 15baef7db..987d5fb6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java @@ -11,7 +11,6 @@ import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.util.GeneralUtils; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; @Slf4j @@ -86,12 +85,6 @@ public class LicenseKeyChecker { return keyOrFilePath; } - public void updateLicenseKey(String newKey) throws IOException { - applicationProperties.getPremium().setKey(newKey); - GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey); - checkLicense(); - } - public License getPremiumLicenseEnabledResult() { return premiumEnabledResult; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 4401403c6..f89290e93 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -33,6 +33,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.AuthenticationType; @@ -82,6 +85,7 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-username") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) public RedirectView changeUsername( Principal principal, @RequestParam(name = "currentPasswordChangeUsername") String currentPassword, @@ -125,6 +129,7 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-password-on-login") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) public RedirectView changePasswordOnLogin( Principal principal, @RequestParam(name = "currentPassword") String currentPassword, @@ -153,6 +158,7 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-password") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) public RedirectView changePassword( Principal principal, @RequestParam(name = "currentPassword") String currentPassword, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 7175a5b5d..2f85bd566 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -16,11 +16,16 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; + @Slf4j public class CustomOAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override + @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 4e7ed9d9e..eba2bcc62 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -24,6 +24,9 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; @@ -39,6 +42,7 @@ public class CustomOAuth2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; @Override + @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java index 7bf0c3a3b..21c1da953 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java @@ -14,11 +14,16 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; + @Slf4j @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override + @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 3255cbc15..0f0c50d7d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -23,6 +23,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; @@ -39,6 +42,7 @@ public class CustomSaml2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; @Override + @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { diff --git a/app/proprietary/src/main/resources/static/js/audit/dashboard.js b/app/proprietary/src/main/resources/static/js/audit/dashboard.js index c0b93bd8e..35f0ab3d5 100644 --- a/app/proprietary/src/main/resources/static/js/audit/dashboard.js +++ b/app/proprietary/src/main/resources/static/js/audit/dashboard.js @@ -218,7 +218,7 @@ function loadAuditData(targetPage, realPageSize) { showLoading('table-loading'); // Always request page 0 from server, but with increased page size if needed - let url = `/audit/data?page=${requestedPage}&size=${realPageSize}`; + let url = `/api/v1/audit/data?page=${requestedPage}&size=${realPageSize}`; if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`; if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`; @@ -302,7 +302,7 @@ function loadStats(days) { showLoading('user-chart-loading'); showLoading('time-chart-loading'); - fetchWithCsrf(`/audit/stats?days=${days}`) + fetchWithCsrf(`/api/v1/audit/stats?days=${days}`) .then(response => response.json()) .then(data => { document.getElementById('total-events').textContent = data.totalEvents; @@ -328,7 +328,7 @@ function exportAuditData(format) { const startDate = exportStartDateFilter.value; const endDate = exportEndDateFilter.value; - let url = format === 'json' ? '/audit/export/json?' : '/audit/export?'; + let url = format === 'json' ? '/api/v1/audit/export/json?' : '/api/v1/audit/export/csv?'; if (type) url += `&type=${encodeURIComponent(type)}`; if (principal) url += `&principal=${encodeURIComponent(principal)}`; @@ -835,7 +835,7 @@ function hideLoading(id) { // Load event types from the server for filter dropdowns function loadEventTypes() { - fetchWithCsrf('/audit/types') + fetchWithCsrf('/api/v1/audit/types') .then(response => response.json()) .then(types => { if (!types || types.length === 0) {