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/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..284a781 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/BackupService.java @@ -0,0 +1,248 @@ +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.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +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; + +@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("${backup.directory:./backups}") + private String backupDirectory; + + public BackupService(BackupMetadataRepository backupRepo, JdbcTemplate jdbcTemplate) { + this.backupRepo = backupRepo; + this.jdbcTemplate = jdbcTemplate; + } + + /** + * 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() + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); + String filename = "backup_" + timestamp + ".sql"; + + Path backupFile = resolveSafeBackupPath(filename); + Path backupDir = backupFile.getParent(); + + BackupMetadata metadata = new BackupMetadata(); + metadata.setFilename(filename); + metadata.setCreatedBy(userEmail); + metadata.setCreatedAt(LocalDateTime.now()); + + try { + // Ensure backup directory exists + Files.createDirectories(backupDir); + + // 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"); + + for (String table : tables) { + writer.write("\n-- ========================================\n"); + writer.write("-- Table: " + table + "\n"); + writer.write("-- ========================================\n\n"); + + // Export table data as INSERT statements + exportTableData(writer, table); + } + + writer.write("\n-- Backup complete\n"); + } + + // Record file size + File resultFile = backupFile.toFile(); + metadata.setSizeBytes(resultFile.length()); + metadata.setStatus("SUCCESS"); + backupRepo.save(metadata); + + log.info("Backup created successfully: {} ({} bytes) by {}", + sanitizeForLog(filename), resultFile.length(), sanitizeForLog(userEmail)); + + return metadata; + + } catch (Exception e) { + log.error("Backup failed: {}", sanitizeForLog(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 by executing its SQL statements. + */ + public void restoreBackup(String filename, String userEmail) { + Path backupFile = resolveSafeBackupPath(filename); + + if (!Files.exists(backupFile)) { + throw new IllegalArgumentException("Backup file not found: " + filename); + } + + try { + 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: {}", + sanitizeForLog(trimmed.substring(0, Math.min(80, trimmed.length()))), + sanitizeForLog(stmtEx.getMessage())); + } + } + + log.info("Database restored from {} by {} ({} statements executed)", + sanitizeForLog(filename), sanitizeForLog(userEmail), executed); + + } catch (IOException e) { + log.error("Restore failed: {}", sanitizeForLog(e.getMessage()), e); + throw new RuntimeException("Restore failed: " + e.getMessage(), e); + } + } + + /** + * List all backup metadata, most recent first. + */ + public List listBackups() { + return backupRepo.findAllByOrderByCreatedAtDesc(); + } + + // ════════════════════════════════════════════════════════════════ + // Private helpers + // ════════════════════════════════════════════════════════════════ + + /** + * Get all user table names from the public schema. + */ + 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 + ); + } + + /** + * 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: {}", sanitizeForLog(tableName)); + return; + } + + 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 {}: {}", sanitizeForLog(tableName), sanitizeForLog(e.getMessage())); + } + }); + } catch (Exception e) { + writer.write("-- ERROR exporting table " + tableName + ": " + e.getMessage() + "\n"); + 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.replace('\n', '_').replace('\r', '_').replace('\t', '_'); + } +} 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..6ab3922 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/SystemSettingsService.java @@ -0,0 +1,112 @@ +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: {}", sanitizeForLog(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: {}", sanitizeForLog(incoming.keySet().toString())); + 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)); + } + + /** + * Sanitize input for logging by removing CRLF and other control characters. + */ + private String sanitizeForLog(String input) { + if (input == null) return "null"; + return input.replace('\n', '_').replace('\r', '_').replace('\t', '_'); + } +} 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}