From a0c8b874793cd48719864aacc9b92044009482db Mon Sep 17 00:00:00 2001 From: Lal Pushpakumaran <161225569+LPK98@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:37:05 +0530 Subject: [PATCH 1/3] feat: add database backup and restore functionality with system settings management and admin controls. --- .../analyzer/config/SecurityConfig.java | 7 + .../analyzer/controller/AdminController.java | 101 ++++++++- .../analyzer/entity/BackupMetadata.java | 42 ++++ .../analyzer/entity/SystemSetting.java | 35 +++ .../repository/BackupMetadataRepository.java | 16 ++ .../repository/SystemSettingRepository.java | 13 ++ .../analyzer/service/BackupService.java | 211 ++++++++++++++++++ .../service/SystemSettingsService.java | 104 +++++++++ src/main/resources/application.properties | 3 + 9 files changed, 520 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/crimeLink/analyzer/entity/BackupMetadata.java create mode 100644 src/main/java/com/crimeLink/analyzer/entity/SystemSetting.java create mode 100644 src/main/java/com/crimeLink/analyzer/repository/BackupMetadataRepository.java create mode 100644 src/main/java/com/crimeLink/analyzer/repository/SystemSettingRepository.java create mode 100644 src/main/java/com/crimeLink/analyzer/service/BackupService.java create mode 100644 src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java diff --git a/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java b/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java index 8939518..7b84314 100644 --- a/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java +++ b/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java @@ -88,6 +88,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // Admin/OIC/Investigator routes (officer data, locations, users) .requestMatchers("/api/users/field-officers").hasAnyRole("Admin", "OIC", "Investigator") .requestMatchers("/api/admin/officers/*/locations/**").hasAnyRole("Admin", "OIC", "Investigator") + + // Admin-only: backup, restore, settings (must come before general /api/admin/**) + .requestMatchers("/api/admin/backup").hasRole("Admin") + .requestMatchers("/api/admin/restore").hasRole("Admin") + .requestMatchers("/api/admin/backups").hasRole("Admin") + .requestMatchers("/api/admin/settings").hasRole("Admin") + .requestMatchers("/api/admin/**").hasAnyRole("OIC", "Admin") .anyRequest().authenticated()) diff --git a/src/main/java/com/crimeLink/analyzer/controller/AdminController.java b/src/main/java/com/crimeLink/analyzer/controller/AdminController.java index 39bf072..473e015 100644 --- a/src/main/java/com/crimeLink/analyzer/controller/AdminController.java +++ b/src/main/java/com/crimeLink/analyzer/controller/AdminController.java @@ -1,14 +1,19 @@ package com.crimeLink.analyzer.controller; import com.crimeLink.analyzer.dto.AuditLogDTO; +import com.crimeLink.analyzer.entity.BackupMetadata; import com.crimeLink.analyzer.entity.LoginAudit; import com.crimeLink.analyzer.entity.User; import com.crimeLink.analyzer.repository.LoginAuditRepository; import com.crimeLink.analyzer.repository.UserRepository; +import com.crimeLink.analyzer.service.BackupService; +import com.crimeLink.analyzer.service.SystemSettingsService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; @@ -24,6 +29,12 @@ public class AdminController { private final UserRepository userRepo; private final LoginAuditRepository auditRepo; private final PasswordEncoder passwordEncoder; + private final BackupService backupService; + private final SystemSettingsService settingsService; + + // ════════════════════════════════════════════════════════════════ + // USER MANAGEMENT (Admin + OIC) + // ════════════════════════════════════════════════════════════════ /** * Get all users or filter by role/status @@ -124,6 +135,10 @@ public ResponseEntity deactivateUser(@PathVariable Integer id) { .orElse(ResponseEntity.notFound().build()); } + // ════════════════════════════════════════════════════════════════ + // AUDIT LOGS (Admin + OIC) + // ════════════════════════════════════════════════════════════════ + /** * Get audit logs * GET /api/admin/audit-logs?limit=100&offset=0 @@ -180,23 +195,26 @@ public ResponseEntity> getAuditLogs( return ResponseEntity.ok(dtoList); } + // ════════════════════════════════════════════════════════════════ + // BACKUP & RESTORE (Admin only) + // ════════════════════════════════════════════════════════════════ + /** * Trigger database backup * POST /api/admin/backup */ @PostMapping("/backup") - public ResponseEntity triggerBackup() { + @PreAuthorize("hasRole('Admin')") + public ResponseEntity triggerBackup(Authentication authentication) { try { - String timestamp = java.time.LocalDateTime.now() - .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); - String filename = "backup_" + timestamp + ".sql"; - - // TODO: Implement actual backup logic - // For Railway PostgreSQL, use pg_dump or Spring's backup mechanisms + String userEmail = authentication.getName(); + BackupMetadata metadata = backupService.createBackup(userEmail); return ResponseEntity.ok(Map.of( "message", "Backup created successfully", - "file", filename + "file", metadata.getFilename(), + "sizeBytes", metadata.getSizeBytes(), + "createdAt", metadata.getCreatedAt().toString() )); } catch (Exception e) { return ResponseEntity.status(500).body( @@ -210,20 +228,27 @@ public ResponseEntity triggerBackup() { * POST /api/admin/restore */ @PostMapping("/restore") - public ResponseEntity restoreBackup(@RequestBody Map request) { + @PreAuthorize("hasRole('Admin')") + public ResponseEntity restoreBackup( + @RequestBody Map request, + Authentication authentication) { try { String filename = request.get("filename"); - if (filename == null || filename.isEmpty()) { + if (filename == null || filename.isBlank()) { return ResponseEntity.badRequest() .body(Map.of("message", "Filename is required")); } - // TODO: Implement actual restore logic - // For Railway PostgreSQL, use psql or Spring's restore mechanisms + String userEmail = authentication.getName(); + backupService.restoreBackup(filename, userEmail); return ResponseEntity.ok(Map.of( "message", "Database restored successfully from " + filename )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body( + Map.of("message", e.getMessage()) + ); } catch (Exception e) { return ResponseEntity.status(500).body( Map.of("message", "Restore failed: " + e.getMessage()) @@ -231,6 +256,58 @@ public ResponseEntity restoreBackup(@RequestBody Map request) } } + /** + * List all available backups + * GET /api/admin/backups + */ + @GetMapping("/backups") + @PreAuthorize("hasRole('Admin')") + public ResponseEntity> listBackups() { + return ResponseEntity.ok(backupService.listBackups()); + } + + // ════════════════════════════════════════════════════════════════ + // SYSTEM SETTINGS (Admin only) + // ════════════════════════════════════════════════════════════════ + + /** + * Get all system settings + * GET /api/admin/settings + */ + @GetMapping("/settings") + @PreAuthorize("hasRole('Admin')") + public ResponseEntity> getSettings() { + return ResponseEntity.ok(settingsService.getAllSettings()); + } + + /** + * Update system settings + * PUT /api/admin/settings + */ + @PutMapping("/settings") + @PreAuthorize("hasRole('Admin')") + public ResponseEntity updateSettings(@RequestBody Map settings) { + try { + Map updated = settingsService.updateSettings(settings); + return ResponseEntity.ok(Map.of( + "message", "Settings saved successfully", + "settings", updated + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body( + Map.of("message", e.getMessage()) + ); + } catch (Exception e) { + return ResponseEntity.status(500).body( + Map.of("message", "Failed to save settings: " + e.getMessage()) + ); + } + } + + // ════════════════════════════════════════════════════════════════ + // SYSTEM HEALTH (public) + // ════════════════════════════════════════════════════════════════ + /** * Get system health * GET /api/admin/health diff --git a/src/main/java/com/crimeLink/analyzer/entity/BackupMetadata.java b/src/main/java/com/crimeLink/analyzer/entity/BackupMetadata.java new file mode 100644 index 0000000..67175b1 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/entity/BackupMetadata.java @@ -0,0 +1,42 @@ +package com.crimeLink.analyzer.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "backup_metadata") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BackupMetadata { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "filename", nullable = false, unique = true, length = 255) + private String filename; + + @Column(name = "size_bytes") + private Long sizeBytes; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "created_by", length = 100) + private String createdBy; + + @Column(name = "status", length = 50) + private String status; // SUCCESS, FAILED + + @PrePersist + public void prePersist() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/crimeLink/analyzer/entity/SystemSetting.java b/src/main/java/com/crimeLink/analyzer/entity/SystemSetting.java new file mode 100644 index 0000000..94a7077 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/entity/SystemSetting.java @@ -0,0 +1,35 @@ +package com.crimeLink.analyzer.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "system_settings") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SystemSetting { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "setting_key", nullable = false, unique = true, length = 100) + private String settingKey; + + @Column(name = "setting_value", nullable = false, length = 500) + private String settingValue; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + @PreUpdate + public void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/crimeLink/analyzer/repository/BackupMetadataRepository.java b/src/main/java/com/crimeLink/analyzer/repository/BackupMetadataRepository.java new file mode 100644 index 0000000..511d14b --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/repository/BackupMetadataRepository.java @@ -0,0 +1,16 @@ +package com.crimeLink.analyzer.repository; + +import com.crimeLink.analyzer.entity.BackupMetadata; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface BackupMetadataRepository extends JpaRepository { + + List findAllByOrderByCreatedAtDesc(); + + Optional findByFilename(String filename); +} diff --git a/src/main/java/com/crimeLink/analyzer/repository/SystemSettingRepository.java b/src/main/java/com/crimeLink/analyzer/repository/SystemSettingRepository.java new file mode 100644 index 0000000..d80765b --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/repository/SystemSettingRepository.java @@ -0,0 +1,13 @@ +package com.crimeLink.analyzer.repository; + +import com.crimeLink.analyzer.entity.SystemSetting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SystemSettingRepository extends JpaRepository { + + Optional findBySettingKey(String settingKey); +} diff --git a/src/main/java/com/crimeLink/analyzer/service/BackupService.java b/src/main/java/com/crimeLink/analyzer/service/BackupService.java new file mode 100644 index 0000000..a929d88 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/BackupService.java @@ -0,0 +1,211 @@ +package com.crimeLink.analyzer.service; + +import com.crimeLink.analyzer.entity.BackupMetadata; +import com.crimeLink.analyzer.repository.BackupMetadataRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class BackupService { + + private final BackupMetadataRepository backupRepo; + + // Whitelist pattern: only alphanumeric, underscores, hyphens, and a single .sql extension + private static final Pattern SAFE_FILENAME = Pattern.compile("^[a-zA-Z0-9_\\-]+\\.sql$"); + + @Value("${spring.datasource.url}") + private String dbUrl; + + @Value("${spring.datasource.username}") + private String dbUsername; + + @Value("${spring.datasource.password}") + private String dbPassword; + + @Value("${backup.directory:./backups}") + private String backupDirectory; + + public BackupService(BackupMetadataRepository backupRepo) { + this.backupRepo = backupRepo; + } + + /** + * Create a full database backup using pg_dump. + */ + public BackupMetadata createBackup(String userEmail) { + String timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); + String filename = "backup_" + timestamp + ".sql"; + + Path backupDir = Paths.get(backupDirectory); + Path backupFile = backupDir.resolve(filename); + + BackupMetadata metadata = new BackupMetadata(); + metadata.setFilename(filename); + metadata.setCreatedBy(userEmail); + metadata.setCreatedAt(LocalDateTime.now()); + + try { + // Ensure backup directory exists + Files.createDirectories(backupDir); + + // Parse JDBC URL to extract host, port, dbName + DbConnectionInfo connInfo = parseJdbcUrl(dbUrl); + + ProcessBuilder pb = new ProcessBuilder( + "pg_dump", + "-h", connInfo.host, + "-p", String.valueOf(connInfo.port), + "-U", dbUsername, + "-F", "p", // plain SQL format + "-f", backupFile.toAbsolutePath().toString(), + connInfo.database + ); + + // Pass password via environment variable (more secure than command line) + pb.environment().put("PGPASSWORD", dbPassword); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + // Read process output for logging + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream()))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + + if (exitCode != 0) { + log.error("pg_dump failed with exit code {}: {}", exitCode, output); + metadata.setStatus("FAILED"); + metadata.setSizeBytes(0L); + backupRepo.save(metadata); + throw new RuntimeException("pg_dump failed (exit code " + exitCode + "): " + output); + } + + // Record file size + File resultFile = backupFile.toFile(); + metadata.setSizeBytes(resultFile.length()); + metadata.setStatus("SUCCESS"); + backupRepo.save(metadata); + + log.info("Backup created successfully: {} ({} bytes) by {}", + filename, resultFile.length(), userEmail); + + return metadata; + + } catch (RuntimeException e) { + throw e; // re-throw RuntimeExceptions as-is + } catch (Exception e) { + log.error("Backup failed: {}", e.getMessage(), e); + metadata.setStatus("FAILED"); + metadata.setSizeBytes(0L); + backupRepo.save(metadata); + throw new RuntimeException("Backup failed: " + e.getMessage(), e); + } + } + + /** + * Restore database from a backup file using psql. + */ + public void restoreBackup(String filename, String userEmail) { + // Sanitize filename to prevent path traversal + if (!SAFE_FILENAME.matcher(filename).matches()) { + throw new IllegalArgumentException( + "Invalid filename. Only alphanumeric characters, underscores, hyphens, and .sql extension are allowed."); + } + + Path backupFile = Paths.get(backupDirectory).resolve(filename); + + if (!Files.exists(backupFile)) { + throw new IllegalArgumentException("Backup file not found: " + filename); + } + + try { + DbConnectionInfo connInfo = parseJdbcUrl(dbUrl); + + ProcessBuilder pb = new ProcessBuilder( + "psql", + "-h", connInfo.host, + "-p", String.valueOf(connInfo.port), + "-U", dbUsername, + "-d", connInfo.database, + "-f", backupFile.toAbsolutePath().toString() + ); + + pb.environment().put("PGPASSWORD", dbPassword); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream()))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + + if (exitCode != 0) { + log.error("psql restore failed with exit code {}: {}", exitCode, output); + throw new RuntimeException("Restore failed (exit code " + exitCode + "): " + output); + } + + log.info("Database restored from {} by {}", filename, userEmail); + + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + log.error("Restore failed: {}", e.getMessage(), e); + throw new RuntimeException("Restore failed: " + e.getMessage(), e); + } + } + + /** + * List all backup metadata, most recent first. + */ + public List listBackups() { + return backupRepo.findAllByOrderByCreatedAtDesc(); + } + + /** + * Parse a JDBC PostgreSQL URL into host, port, and database components. + * Supports: jdbc:postgresql://host:port/database and ?param=value query strings. + */ + private DbConnectionInfo parseJdbcUrl(String jdbcUrl) { + try { + // Remove "jdbc:" prefix so URI can parse it + String uriString = jdbcUrl.substring(5); + URI uri = new URI(uriString); + + String host = uri.getHost() != null ? uri.getHost() : "localhost"; + int port = uri.getPort() > 0 ? uri.getPort() : 5432; + String path = uri.getPath(); + String database = (path != null && path.length() > 1) ? path.substring(1) : "postgres"; + + return new DbConnectionInfo(host, port, database); + } catch (Exception e) { + log.warn("Could not parse JDBC URL '{}', using defaults. Error: {}", jdbcUrl, e.getMessage()); + return new DbConnectionInfo("localhost", 5432, "postgres"); + } + } + + private record DbConnectionInfo(String host, int port, String database) {} +} diff --git a/src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java b/src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java new file mode 100644 index 0000000..5ab93fa --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java @@ -0,0 +1,104 @@ +package com.crimeLink.analyzer.service; + +import com.crimeLink.analyzer.entity.SystemSetting; +import com.crimeLink.analyzer.repository.SystemSettingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SystemSettingsService { + + private final SystemSettingRepository settingRepo; + + // Default values for all supported settings + private static final Map DEFAULTS = Map.of( + "jwtExpiry", "24", + "passwordMinLength", "8", + "maxLoginAttempts", "5", + "sessionTimeout", "30", + "backupRetentionDays", "30" + ); + + // Validation rules: key -> [min, max] + private static final Map VALIDATION_RULES = Map.of( + "jwtExpiry", new int[]{1, 720}, + "passwordMinLength", new int[]{6, 128}, + "maxLoginAttempts", new int[]{1, 20}, + "sessionTimeout", new int[]{5, 1440}, + "backupRetentionDays", new int[]{1, 365} + ); + + /** + * Get all settings, merging stored values with defaults. + */ + public Map getAllSettings() { + Map result = new LinkedHashMap<>(DEFAULTS); + + // Override defaults with DB-stored values + settingRepo.findAll().forEach(setting -> + result.put(setting.getSettingKey(), setting.getSettingValue())); + + return result; + } + + /** + * Validate and save settings. Only known keys are accepted. + * Returns the updated map of all settings. + */ + public Map updateSettings(Map incoming) { + for (Map.Entry entry : incoming.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + // Ignore unknown keys + if (!DEFAULTS.containsKey(key)) { + log.warn("Ignoring unknown setting key: {}", key); + continue; + } + + // Validate numeric value + int numericValue; + try { + numericValue = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Setting '" + key + "' must be a valid integer, got: " + value); + } + + int[] range = VALIDATION_RULES.get(key); + if (range != null && (numericValue < range[0] || numericValue > range[1])) { + throw new IllegalArgumentException( + "Setting '" + key + "' must be between " + range[0] + " and " + range[1] + + ", got: " + numericValue); + } + + // Upsert + SystemSetting setting = settingRepo.findBySettingKey(key) + .orElseGet(() -> { + SystemSetting s = new SystemSetting(); + s.setSettingKey(key); + return s; + }); + setting.setSettingValue(value); + settingRepo.save(setting); + } + + log.info("System settings updated: {}", incoming.keySet()); + return getAllSettings(); + } + + /** + * Get a single setting value with fallback to default. + */ + public String getSetting(String key) { + return settingRepo.findBySettingKey(key) + .map(SystemSetting::getSettingValue) + .orElse(DEFAULTS.get(key)); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index abf4b5e..5ed118c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -47,3 +47,6 @@ supabase.bucket=${SUPABASE_BUCKET:criminal-photos} spring.servlet.multipart.enabled=true spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=50MB + +# Backup Configuration +backup.directory=${BACKUP_DIR:./backups} From c9fbe22feff846b7690a0a543c4d267db9386b2b Mon Sep 17 00:00:00 2001 From: Lal Pushpakumaran <161225569+LPK98@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:47:32 +0530 Subject: [PATCH 2/3] feat: implement database backup and restore service with SQL file generation. --- .gitignore | 2 + .../analyzer/service/BackupService.java | 210 ++++++++++-------- 2 files changed, 114 insertions(+), 98 deletions(-) diff --git a/.gitignore b/.gitignore index 2731e7f..14fb072 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ build/ ### Environment Variables ### .env .env.local + +backups diff --git a/src/main/java/com/crimeLink/analyzer/service/BackupService.java b/src/main/java/com/crimeLink/analyzer/service/BackupService.java index a929d88..a19ec2e 100644 --- a/src/main/java/com/crimeLink/analyzer/service/BackupService.java +++ b/src/main/java/com/crimeLink/analyzer/service/BackupService.java @@ -4,48 +4,42 @@ import com.crimeLink.analyzer.repository.BackupMetadataRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; -import java.io.BufferedReader; -import java.io.File; -import java.io.InputStreamReader; -import java.net.URI; +import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; -import java.util.stream.Collectors; @Slf4j @Service public class BackupService { private final BackupMetadataRepository backupRepo; + private final JdbcTemplate jdbcTemplate; // Whitelist pattern: only alphanumeric, underscores, hyphens, and a single .sql extension private static final Pattern SAFE_FILENAME = Pattern.compile("^[a-zA-Z0-9_\\-]+\\.sql$"); - @Value("${spring.datasource.url}") - private String dbUrl; - - @Value("${spring.datasource.username}") - private String dbUsername; - - @Value("${spring.datasource.password}") - private String dbPassword; - @Value("${backup.directory:./backups}") private String backupDirectory; - public BackupService(BackupMetadataRepository backupRepo) { + public BackupService(BackupMetadataRepository backupRepo, JdbcTemplate jdbcTemplate) { this.backupRepo = backupRepo; + this.jdbcTemplate = jdbcTemplate; } /** - * Create a full database backup using pg_dump. + * Create a full database backup using pure JDBC. + * Exports all user tables as SQL INSERT statements. */ public BackupMetadata createBackup(String userEmail) { String timestamp = LocalDateTime.now() @@ -64,40 +58,25 @@ public BackupMetadata createBackup(String userEmail) { // Ensure backup directory exists Files.createDirectories(backupDir); - // Parse JDBC URL to extract host, port, dbName - DbConnectionInfo connInfo = parseJdbcUrl(dbUrl); - - ProcessBuilder pb = new ProcessBuilder( - "pg_dump", - "-h", connInfo.host, - "-p", String.valueOf(connInfo.port), - "-U", dbUsername, - "-F", "p", // plain SQL format - "-f", backupFile.toAbsolutePath().toString(), - connInfo.database - ); - - // Pass password via environment variable (more secure than command line) - pb.environment().put("PGPASSWORD", dbPassword); - pb.redirectErrorStream(true); - - Process process = pb.start(); - - // Read process output for logging - String output; - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream()))) { - output = reader.lines().collect(Collectors.joining("\n")); - } + // Get all user table names (exclude system tables) + List tables = getTableNames(); + + try (BufferedWriter writer = new BufferedWriter(new FileWriter(backupFile.toFile()))) { + writer.write("-- CrimeLink Analyzer Database Backup\n"); + writer.write("-- Created: " + LocalDateTime.now() + "\n"); + writer.write("-- Created by: " + userEmail + "\n"); + writer.write("-- Tables: " + tables.size() + "\n\n"); - int exitCode = process.waitFor(); + for (String table : tables) { + writer.write("\n-- ========================================\n"); + writer.write("-- Table: " + table + "\n"); + writer.write("-- ========================================\n\n"); - if (exitCode != 0) { - log.error("pg_dump failed with exit code {}: {}", exitCode, output); - metadata.setStatus("FAILED"); - metadata.setSizeBytes(0L); - backupRepo.save(metadata); - throw new RuntimeException("pg_dump failed (exit code " + exitCode + "): " + output); + // Export table data as INSERT statements + exportTableData(writer, table); + } + + writer.write("\n-- Backup complete\n"); } // Record file size @@ -111,8 +90,6 @@ public BackupMetadata createBackup(String userEmail) { return metadata; - } catch (RuntimeException e) { - throw e; // re-throw RuntimeExceptions as-is } catch (Exception e) { log.error("Backup failed: {}", e.getMessage(), e); metadata.setStatus("FAILED"); @@ -123,7 +100,7 @@ public BackupMetadata createBackup(String userEmail) { } /** - * Restore database from a backup file using psql. + * Restore database from a backup file by executing its SQL statements. */ public void restoreBackup(String filename, String userEmail) { // Sanitize filename to prevent path traversal @@ -139,40 +116,32 @@ public void restoreBackup(String filename, String userEmail) { } try { - DbConnectionInfo connInfo = parseJdbcUrl(dbUrl); - - ProcessBuilder pb = new ProcessBuilder( - "psql", - "-h", connInfo.host, - "-p", String.valueOf(connInfo.port), - "-U", dbUsername, - "-d", connInfo.database, - "-f", backupFile.toAbsolutePath().toString() - ); - - pb.environment().put("PGPASSWORD", dbPassword); - pb.redirectErrorStream(true); - - Process process = pb.start(); - - String output; - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream()))) { - output = reader.lines().collect(Collectors.joining("\n")); - } - - int exitCode = process.waitFor(); - - if (exitCode != 0) { - log.error("psql restore failed with exit code {}: {}", exitCode, output); - throw new RuntimeException("Restore failed (exit code " + exitCode + "): " + output); + String sql = Files.readString(backupFile); + + // Split by semicolons and execute each statement + String[] statements = sql.split(";"); + int executed = 0; + + for (String stmt : statements) { + String trimmed = stmt.trim(); + // Skip empty lines and comments + if (trimmed.isEmpty() || trimmed.startsWith("--")) { + continue; + } + try { + jdbcTemplate.execute(trimmed); + executed++; + } catch (Exception stmtEx) { + // Log and continue — some statements may fail on duplicates etc. + log.warn("Skipping failed statement: {}... Error: {}", + trimmed.substring(0, Math.min(80, trimmed.length())), + stmtEx.getMessage()); + } } - log.info("Database restored from {} by {}", filename, userEmail); + log.info("Database restored from {} by {} ({} statements executed)", filename, userEmail, executed); - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { + } catch (IOException e) { log.error("Restore failed: {}", e.getMessage(), e); throw new RuntimeException("Restore failed: " + e.getMessage(), e); } @@ -185,27 +154,72 @@ public List listBackups() { return backupRepo.findAllByOrderByCreatedAtDesc(); } + // ════════════════════════════════════════════════════════════════ + // Private helpers + // ════════════════════════════════════════════════════════════════ + /** - * Parse a JDBC PostgreSQL URL into host, port, and database components. - * Supports: jdbc:postgresql://host:port/database and ?param=value query strings. + * Get all user table names from the public schema. */ - private DbConnectionInfo parseJdbcUrl(String jdbcUrl) { - try { - // Remove "jdbc:" prefix so URI can parse it - String uriString = jdbcUrl.substring(5); - URI uri = new URI(uriString); + private List getTableNames() { + return jdbcTemplate.queryForList( + "SELECT table_name FROM information_schema.tables " + + "WHERE table_schema = 'public' AND table_type = 'BASE TABLE' " + + "ORDER BY table_name", + String.class + ); + } - String host = uri.getHost() != null ? uri.getHost() : "localhost"; - int port = uri.getPort() > 0 ? uri.getPort() : 5432; - String path = uri.getPath(); - String database = (path != null && path.length() > 1) ? path.substring(1) : "postgres"; + /** + * Export all rows of a table as INSERT statements. + */ + private void exportTableData(BufferedWriter writer, String tableName) throws IOException { + // Validate table name to prevent SQL injection (should only contain safe chars) + if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { + log.warn("Skipping suspicious table name: {}", tableName); + return; + } - return new DbConnectionInfo(host, port, database); + try { + jdbcTemplate.query("SELECT * FROM \"" + tableName + "\"", (ResultSet rs) -> { + try { + ResultSetMetaData meta = rs.getMetaData(); + int columnCount = meta.getColumnCount(); + + // Build column name list + List columns = new ArrayList<>(); + for (int i = 1; i <= columnCount; i++) { + columns.add("\"" + meta.getColumnName(i) + "\""); + } + String columnList = String.join(", ", columns); + + while (rs.next()) { + List values = new ArrayList<>(); + for (int i = 1; i <= columnCount; i++) { + Object val = rs.getObject(i); + if (val == null) { + values.add("NULL"); + } else if (val instanceof Number) { + values.add(val.toString()); + } else if (val instanceof Boolean) { + values.add(((Boolean) val) ? "TRUE" : "FALSE"); + } else { + // Escape single quotes for SQL strings + String escaped = val.toString().replace("'", "''"); + values.add("'" + escaped + "'"); + } + } + + writer.write("INSERT INTO \"" + tableName + "\" (" + columnList + ") VALUES (" + + String.join(", ", values) + ");\n"); + } + } catch (Exception e) { + log.error("Error exporting table {}: {}", tableName, e.getMessage()); + } + }); } catch (Exception e) { - log.warn("Could not parse JDBC URL '{}', using defaults. Error: {}", jdbcUrl, e.getMessage()); - return new DbConnectionInfo("localhost", 5432, "postgres"); + writer.write("-- ERROR exporting table " + tableName + ": " + e.getMessage() + "\n"); + log.error("Failed to export table {}: {}", tableName, e.getMessage()); } } - - private record DbConnectionInfo(String host, int port, String database) {} } From e311e537848642bdf21b081e561d2b28edb827a0 Mon Sep 17 00:00:00 2001 From: JinethBosilu <157296244+JinethBosilu@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:58:55 +0530 Subject: [PATCH 3/3] feat: enhance logging by sanitizing inputs in BackupService and SystemSettingsService --- .../analyzer/service/BackupService.java | 59 +++++++++++++------ .../service/SystemSettingsService.java | 12 +++- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/crimeLink/analyzer/service/BackupService.java b/src/main/java/com/crimeLink/analyzer/service/BackupService.java index a19ec2e..f39c1b6 100644 --- a/src/main/java/com/crimeLink/analyzer/service/BackupService.java +++ b/src/main/java/com/crimeLink/analyzer/service/BackupService.java @@ -46,8 +46,8 @@ public BackupMetadata createBackup(String userEmail) { .format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); String filename = "backup_" + timestamp + ".sql"; - Path backupDir = Paths.get(backupDirectory); - Path backupFile = backupDir.resolve(filename); + Path backupFile = resolveSafeBackupPath(filename); + Path backupDir = backupFile.getParent(); BackupMetadata metadata = new BackupMetadata(); metadata.setFilename(filename); @@ -86,12 +86,12 @@ public BackupMetadata createBackup(String userEmail) { backupRepo.save(metadata); log.info("Backup created successfully: {} ({} bytes) by {}", - filename, resultFile.length(), userEmail); + sanitizeForLog(filename), resultFile.length(), sanitizeForLog(userEmail)); return metadata; } catch (Exception e) { - log.error("Backup failed: {}", e.getMessage(), e); + log.error("Backup failed: {}", sanitizeForLog(e.getMessage()), e); metadata.setStatus("FAILED"); metadata.setSizeBytes(0L); backupRepo.save(metadata); @@ -103,13 +103,7 @@ public BackupMetadata createBackup(String userEmail) { * Restore database from a backup file by executing its SQL statements. */ public void restoreBackup(String filename, String userEmail) { - // Sanitize filename to prevent path traversal - if (!SAFE_FILENAME.matcher(filename).matches()) { - throw new IllegalArgumentException( - "Invalid filename. Only alphanumeric characters, underscores, hyphens, and .sql extension are allowed."); - } - - Path backupFile = Paths.get(backupDirectory).resolve(filename); + Path backupFile = resolveSafeBackupPath(filename); if (!Files.exists(backupFile)) { throw new IllegalArgumentException("Backup file not found: " + filename); @@ -134,15 +128,16 @@ public void restoreBackup(String filename, String userEmail) { } catch (Exception stmtEx) { // Log and continue — some statements may fail on duplicates etc. log.warn("Skipping failed statement: {}... Error: {}", - trimmed.substring(0, Math.min(80, trimmed.length())), - stmtEx.getMessage()); + sanitizeForLog(trimmed.substring(0, Math.min(80, trimmed.length()))), + sanitizeForLog(stmtEx.getMessage())); } } - log.info("Database restored from {} by {} ({} statements executed)", filename, userEmail, executed); + log.info("Database restored from {} by {} ({} statements executed)", + sanitizeForLog(filename), sanitizeForLog(userEmail), executed); } catch (IOException e) { - log.error("Restore failed: {}", e.getMessage(), e); + log.error("Restore failed: {}", sanitizeForLog(e.getMessage()), e); throw new RuntimeException("Restore failed: " + e.getMessage(), e); } } @@ -176,7 +171,7 @@ private List getTableNames() { private void exportTableData(BufferedWriter writer, String tableName) throws IOException { // Validate table name to prevent SQL injection (should only contain safe chars) if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { - log.warn("Skipping suspicious table name: {}", tableName); + log.warn("Skipping suspicious table name: {}", sanitizeForLog(tableName)); return; } @@ -214,12 +209,40 @@ private void exportTableData(BufferedWriter writer, String tableName) throws IOE + String.join(", ", values) + ");\n"); } } catch (Exception e) { - log.error("Error exporting table {}: {}", tableName, e.getMessage()); + log.error("Error exporting table {}: {}", sanitizeForLog(tableName), sanitizeForLog(e.getMessage())); } }); } catch (Exception e) { writer.write("-- ERROR exporting table " + tableName + ": " + e.getMessage() + "\n"); - log.error("Failed to export table {}: {}", tableName, e.getMessage()); + log.error("Failed to export table {}: {}", sanitizeForLog(tableName), sanitizeForLog(e.getMessage())); } } + + /** + * Confirms the requested filename is safe, resolves and normalizes the path, + * and strictly checks that it falls inside the base backup directory. + */ + private Path resolveSafeBackupPath(String filename) { + if (filename == null || !SAFE_FILENAME.matcher(filename).matches()) { + throw new IllegalArgumentException( + "Invalid filename. Only alphanumeric characters, underscores, hyphens, and .sql extension are allowed."); + } + + Path baseDir = Paths.get(backupDirectory).toAbsolutePath().normalize(); + Path resolvedPath = baseDir.resolve(filename).normalize(); + + if (!resolvedPath.startsWith(baseDir)) { + throw new IllegalArgumentException("Path traversal attempt detected."); + } + + return resolvedPath; + } + + /** + * Sanitizes strings for safe logging to prevent log injection. + */ + private String sanitizeForLog(String input) { + if (input == null) return "null"; + return input.replaceAll("[\\r\\n\\t]", "_"); + } } diff --git a/src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java b/src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java index 5ab93fa..361b93d 100644 --- a/src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java +++ b/src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java @@ -58,7 +58,7 @@ public Map updateSettings(Map incoming) { // Ignore unknown keys if (!DEFAULTS.containsKey(key)) { - log.warn("Ignoring unknown setting key: {}", key); + log.warn("Ignoring unknown setting key: {}", sanitizeForLog(key)); continue; } @@ -89,7 +89,7 @@ public Map updateSettings(Map incoming) { settingRepo.save(setting); } - log.info("System settings updated: {}", incoming.keySet()); + log.info("System settings updated: {}", sanitizeForLog(incoming.keySet().toString())); return getAllSettings(); } @@ -101,4 +101,12 @@ public String getSetting(String key) { .map(SystemSetting::getSettingValue) .orElse(DEFAULTS.get(key)); } + + /** + * Sanitize input for logging by removing CRLF and other control characters. + */ + private String sanitizeForLog(String input) { + if (input == null) return "null"; + return input.replaceAll("[\\r\\n\\t]", "_"); + } }