refactoring of backup/restore functionality
This commit is contained in:
@@ -51,24 +51,29 @@ public class DatabaseMigrationRunner implements CommandLineRunner {
|
|||||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
library_id VARCHAR(255) NOT NULL,
|
library_id VARCHAR(255) NOT NULL,
|
||||||
type VARCHAR(50) NOT NULL CHECK (type IN ('DATABASE_ONLY', 'COMPLETE')),
|
type VARCHAR(50) NOT NULL,
|
||||||
status VARCHAR(50) NOT NULL CHECK (status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED', 'EXPIRED')),
|
status VARCHAR(50) NOT NULL,
|
||||||
file_path VARCHAR(1000),
|
file_path VARCHAR(1000),
|
||||||
file_size_bytes BIGINT,
|
file_size_bytes BIGINT,
|
||||||
progress_percent INTEGER,
|
progress_percent INTEGER,
|
||||||
error_message VARCHAR(1000),
|
error_message VARCHAR(1000),
|
||||||
created_at TIMESTAMP NOT NULL,
|
created_at TIMESTAMP NOT NULL,
|
||||||
started_at TIMESTAMP,
|
started_at TIMESTAMP,
|
||||||
completed_at TIMESTAMP,
|
completed_at TIMESTAMP
|
||||||
expires_at TIMESTAMP
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_backup_jobs_library_id ON backup_jobs(library_id);
|
CREATE INDEX IF NOT EXISTS idx_backup_jobs_library_id ON backup_jobs(library_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_backup_jobs_status ON backup_jobs(status);
|
CREATE INDEX IF NOT EXISTS idx_backup_jobs_status ON backup_jobs(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_backup_jobs_expires_at ON backup_jobs(expires_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at ON backup_jobs(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at ON backup_jobs(created_at DESC);
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
// Remove the expires_at column and its index if they exist from the old schema
|
||||||
|
private static final String BACKUP_JOBS_CLEANUP_MIGRATION = """
|
||||||
|
ALTER TABLE backup_jobs DROP COLUMN IF EXISTS expires_at;
|
||||||
|
DROP INDEX IF EXISTS idx_backup_jobs_expires_at;
|
||||||
|
UPDATE backup_jobs SET status = 'COMPLETED' WHERE status = 'EXPIRED';
|
||||||
|
""";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(String... args) throws Exception {
|
public void run(String... args) throws Exception {
|
||||||
logger.info("🗄️ Starting database migrations...");
|
logger.info("🗄️ Starting database migrations...");
|
||||||
@@ -114,6 +119,11 @@ public class DatabaseMigrationRunner implements CommandLineRunner {
|
|||||||
stmt.execute(BACKUP_JOBS_MIGRATION);
|
stmt.execute(BACKUP_JOBS_MIGRATION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove expires_at column and update EXPIRED status from old schema
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute(BACKUP_JOBS_CLEANUP_MIGRATION);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("Applied migrations to {}", database);
|
logger.debug("Applied migrations to {}", database);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
import com.storycove.service.AsyncBackupService;
|
import com.storycove.service.AsyncBackupService;
|
||||||
|
import com.storycove.service.BackupManagementService;
|
||||||
import com.storycove.service.DatabaseManagementService;
|
import com.storycove.service.DatabaseManagementService;
|
||||||
import com.storycove.service.LibraryService;
|
import com.storycove.service.LibraryService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/database")
|
@RequestMapping("/api/database")
|
||||||
@@ -27,69 +24,15 @@ public class DatabaseController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AsyncBackupService asyncBackupService;
|
private AsyncBackupService asyncBackupService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BackupManagementService backupManagementService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private LibraryService libraryService;
|
private LibraryService libraryService;
|
||||||
|
|
||||||
@PostMapping("/backup")
|
// -------------------------------------------------------------------------
|
||||||
public ResponseEntity<Resource> backupDatabase() {
|
// Backup creation (async, with progress polling)
|
||||||
try {
|
// -------------------------------------------------------------------------
|
||||||
Resource backup = databaseManagementService.createBackup();
|
|
||||||
|
|
||||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
|
|
||||||
String filename = "storycove_backup_" + timestamp + ".sql";
|
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
|
||||||
.body(backup);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException("Failed to create database backup: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/restore")
|
|
||||||
public ResponseEntity<Map<String, Object>> restoreDatabase(@RequestParam("file") MultipartFile file) {
|
|
||||||
try {
|
|
||||||
if (file.isEmpty()) {
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("success", false, "message", "No file uploaded"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.getOriginalFilename().endsWith(".sql")) {
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("success", false, "message", "Invalid file type. Please upload a .sql file"));
|
|
||||||
}
|
|
||||||
|
|
||||||
databaseManagementService.restoreFromBackup(file.getInputStream());
|
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"message", "Database restored successfully from " + file.getOriginalFilename()
|
|
||||||
));
|
|
||||||
} catch (IOException e) {
|
|
||||||
return ResponseEntity.internalServerError()
|
|
||||||
.body(Map.of("success", false, "message", "Failed to read backup file: " + e.getMessage()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.internalServerError()
|
|
||||||
.body(Map.of("success", false, "message", "Failed to restore database: " + e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/clear")
|
|
||||||
public ResponseEntity<Map<String, Object>> clearDatabase() {
|
|
||||||
try {
|
|
||||||
int deletedRecords = databaseManagementService.clearAllData();
|
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"message", "Database cleared successfully",
|
|
||||||
"deletedRecords", deletedRecords
|
|
||||||
));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.internalServerError()
|
|
||||||
.body(Map.of("success", false, "message", "Failed to clear database: " + e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/backup-complete")
|
@PostMapping("/backup-complete")
|
||||||
public ResponseEntity<Map<String, Object>> backupCompleteAsync() {
|
public ResponseEntity<Map<String, Object>> backupCompleteAsync() {
|
||||||
@@ -100,7 +43,6 @@ public class DatabaseController {
|
|||||||
.body(Map.of("success", false, "message", "No library selected"));
|
.body(Map.of("success", false, "message", "No library selected"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start backup job asynchronously
|
|
||||||
com.storycove.entity.BackupJob job = asyncBackupService.startBackupJob(
|
com.storycove.entity.BackupJob job = asyncBackupService.startBackupJob(
|
||||||
libraryId,
|
libraryId,
|
||||||
com.storycove.entity.BackupJob.BackupType.COMPLETE
|
com.storycove.entity.BackupJob.BackupType.COMPLETE
|
||||||
@@ -146,132 +88,79 @@ public class DatabaseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/backup-download/{jobId}")
|
// -------------------------------------------------------------------------
|
||||||
public ResponseEntity<Resource> downloadBackup(@PathVariable String jobId) {
|
// Backup file management (filesystem-based)
|
||||||
try {
|
// -------------------------------------------------------------------------
|
||||||
java.util.UUID uuid = java.util.UUID.fromString(jobId);
|
|
||||||
Resource backup = asyncBackupService.getBackupFile(uuid);
|
|
||||||
|
|
||||||
java.util.Optional<com.storycove.entity.BackupJob> jobOpt = asyncBackupService.getJobStatus(uuid);
|
@GetMapping("/backups")
|
||||||
if (jobOpt.isEmpty()) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
com.storycove.entity.BackupJob job = jobOpt.get();
|
|
||||||
String timestamp = job.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
|
|
||||||
String extension = job.getType() == com.storycove.entity.BackupJob.BackupType.COMPLETE ? "zip" : "sql";
|
|
||||||
String filename = "storycove_backup_" + timestamp + "." + extension;
|
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
|
||||||
.header(HttpHeaders.CONTENT_TYPE,
|
|
||||||
job.getType() == com.storycove.entity.BackupJob.BackupType.COMPLETE
|
|
||||||
? "application/zip"
|
|
||||||
: "application/sql")
|
|
||||||
.body(backup);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return ResponseEntity.badRequest().build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException("Failed to download backup: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/backup-list")
|
|
||||||
public ResponseEntity<Map<String, Object>> listBackups() {
|
public ResponseEntity<Map<String, Object>> listBackups() {
|
||||||
try {
|
try {
|
||||||
String libraryId = libraryService.getCurrentLibraryId();
|
List<BackupManagementService.BackupFileInfo> backups = backupManagementService.listBackups();
|
||||||
if (libraryId == null) {
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("success", false, "message", "No library selected"));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<com.storycove.entity.BackupJob> jobs = asyncBackupService.listBackupJobs(libraryId);
|
List<Map<String, Object>> backupList = backups.stream()
|
||||||
|
.map(b -> {
|
||||||
List<Map<String, Object>> jobsList = jobs.stream()
|
Map<String, Object> m = new java.util.HashMap<>();
|
||||||
.map(job -> {
|
m.put("filename", b.getFilename());
|
||||||
Map<String, Object> jobMap = new java.util.HashMap<>();
|
m.put("sizeBytes", b.getSizeBytes());
|
||||||
jobMap.put("jobId", job.getId().toString());
|
m.put("createdAt", b.getCreatedAt().toString());
|
||||||
jobMap.put("type", job.getType().toString());
|
m.put("origin", b.getOrigin().toString());
|
||||||
jobMap.put("status", job.getStatus().toString());
|
return m;
|
||||||
jobMap.put("progress", job.getProgressPercent());
|
|
||||||
jobMap.put("fileSizeBytes", job.getFileSizeBytes() != null ? job.getFileSizeBytes() : 0L);
|
|
||||||
jobMap.put("createdAt", job.getCreatedAt().toString());
|
|
||||||
jobMap.put("completedAt", job.getCompletedAt() != null ? job.getCompletedAt().toString() : "");
|
|
||||||
return jobMap;
|
|
||||||
})
|
})
|
||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of("success", true, "backups", backupList));
|
||||||
"success", true,
|
|
||||||
"backups", jobsList
|
|
||||||
));
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.internalServerError()
|
return ResponseEntity.internalServerError()
|
||||||
.body(Map.of("success", false, "message", "Failed to list backups: " + e.getMessage()));
|
.body(Map.of("success", false, "message", "Failed to list backups: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/backup/{jobId}")
|
@PostMapping("/backups/{filename}/restore")
|
||||||
public ResponseEntity<Map<String, Object>> deleteBackup(@PathVariable String jobId) {
|
public ResponseEntity<Map<String, Object>> restoreFromFile(@PathVariable String filename) {
|
||||||
try {
|
try {
|
||||||
java.util.UUID uuid = java.util.UUID.fromString(jobId);
|
backupManagementService.restoreFromFile(filename);
|
||||||
asyncBackupService.deleteBackupJob(uuid);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"success", true,
|
"success", true,
|
||||||
"message", "Backup deleted successfully"
|
"message", "Restored successfully from " + filename
|
||||||
));
|
));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.internalServerError()
|
||||||
.body(Map.of("success", false, "message", "Invalid job ID"));
|
.body(Map.of("success", false, "message", "Restore failed: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/backups/{filename}/download")
|
||||||
|
public ResponseEntity<Resource> downloadBackupFile(@PathVariable String filename) {
|
||||||
|
try {
|
||||||
|
Resource resource = backupManagementService.getBackupResource(filename);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.header(HttpHeaders.CONTENT_TYPE, "application/zip")
|
||||||
|
.body(resource);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/backups/{filename}")
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteBackupFile(@PathVariable String filename) {
|
||||||
|
try {
|
||||||
|
backupManagementService.deleteBackup(filename);
|
||||||
|
return ResponseEntity.ok(Map.of("success", true, "message", "Backup deleted"));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.internalServerError()
|
return ResponseEntity.internalServerError()
|
||||||
.body(Map.of("success", false, "message", "Failed to delete backup: " + e.getMessage()));
|
.body(Map.of("success", false, "message", "Failed to delete backup: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/restore-complete")
|
// -------------------------------------------------------------------------
|
||||||
public ResponseEntity<Map<String, Object>> restoreComplete(@RequestParam("file") MultipartFile file) {
|
// Clear operations
|
||||||
System.err.println("Complete restore endpoint called with file: " + (file != null ? file.getOriginalFilename() : "null"));
|
// -------------------------------------------------------------------------
|
||||||
try {
|
|
||||||
if (file.isEmpty()) {
|
|
||||||
System.err.println("File is empty - returning bad request");
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("success", false, "message", "No file uploaded"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.getOriginalFilename().endsWith(".zip")) {
|
|
||||||
System.err.println("Invalid file type: " + file.getOriginalFilename());
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(Map.of("success", false, "message", "Invalid file type. Please upload a .zip file"));
|
|
||||||
}
|
|
||||||
|
|
||||||
System.err.println("File validation passed, calling restore service...");
|
|
||||||
databaseManagementService.restoreFromCompleteBackup(file.getInputStream());
|
|
||||||
System.err.println("Restore service completed successfully");
|
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"message", "Complete backup restored successfully from " + file.getOriginalFilename()
|
|
||||||
));
|
|
||||||
} catch (IOException e) {
|
|
||||||
System.err.println("IOException during restore: " + e.getMessage());
|
|
||||||
e.printStackTrace();
|
|
||||||
return ResponseEntity.internalServerError()
|
|
||||||
.body(Map.of("success", false, "message", "Failed to read backup file: " + e.getMessage()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Exception during restore: " + e.getMessage());
|
|
||||||
e.printStackTrace();
|
|
||||||
return ResponseEntity.internalServerError()
|
|
||||||
.body(Map.of("success", false, "message", "Failed to restore complete backup: " + e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/clear-complete")
|
@PostMapping("/clear-complete")
|
||||||
public ResponseEntity<Map<String, Object>> clearComplete() {
|
public ResponseEntity<Map<String, Object>> clearComplete() {
|
||||||
try {
|
try {
|
||||||
int deletedRecords = databaseManagementService.clearAllDataAndFiles();
|
int deletedRecords = databaseManagementService.clearAllDataAndFiles();
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"success", true,
|
"success", true,
|
||||||
"message", "Database and files cleared successfully",
|
"message", "Database and files cleared successfully",
|
||||||
@@ -282,4 +171,19 @@ public class DatabaseController {
|
|||||||
.body(Map.of("success", false, "message", "Failed to clear database and files: " + e.getMessage()));
|
.body(Map.of("success", false, "message", "Failed to clear database and files: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/clear")
|
||||||
|
public ResponseEntity<Map<String, Object>> clearDatabase() {
|
||||||
|
try {
|
||||||
|
int deletedRecords = databaseManagementService.clearAllData();
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Database cleared successfully",
|
||||||
|
"deletedRecords", deletedRecords
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(Map.of("success", false, "message", "Failed to clear database: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -44,14 +44,9 @@ public class BackupJob {
|
|||||||
@Column
|
@Column
|
||||||
private LocalDateTime completedAt;
|
private LocalDateTime completedAt;
|
||||||
|
|
||||||
@Column
|
|
||||||
private LocalDateTime expiresAt;
|
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
// Backups expire after 24 hours
|
|
||||||
expiresAt = LocalDateTime.now().plusDays(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enums
|
// Enums
|
||||||
@@ -64,8 +59,7 @@ public class BackupJob {
|
|||||||
PENDING,
|
PENDING,
|
||||||
IN_PROGRESS,
|
IN_PROGRESS,
|
||||||
COMPLETED,
|
COMPLETED,
|
||||||
FAILED,
|
FAILED
|
||||||
EXPIRED
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constructors
|
// Constructors
|
||||||
@@ -168,19 +162,7 @@ public class BackupJob {
|
|||||||
this.completedAt = completedAt;
|
this.completedAt = completedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getExpiresAt() {
|
|
||||||
return expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
|
||||||
this.expiresAt = expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
public boolean isExpired() {
|
|
||||||
return LocalDateTime.now().isAfter(expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isCompleted() {
|
public boolean isCompleted() {
|
||||||
return status == BackupStatus.COMPLETED;
|
return status == BackupStatus.COMPLETED;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ package com.storycove.repository;
|
|||||||
|
|
||||||
import com.storycove.entity.BackupJob;
|
import com.storycove.entity.BackupJob;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -15,11 +11,4 @@ import java.util.UUID;
|
|||||||
public interface BackupJobRepository extends JpaRepository<BackupJob, UUID> {
|
public interface BackupJobRepository extends JpaRepository<BackupJob, UUID> {
|
||||||
|
|
||||||
List<BackupJob> findByLibraryIdOrderByCreatedAtDesc(String libraryId);
|
List<BackupJob> findByLibraryIdOrderByCreatedAtDesc(String libraryId);
|
||||||
|
|
||||||
@Query("SELECT bj FROM BackupJob bj WHERE bj.expiresAt < :now AND bj.status = 'COMPLETED'")
|
|
||||||
List<BackupJob> findExpiredJobs(@Param("now") LocalDateTime now);
|
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query("UPDATE BackupJob bj SET bj.status = 'EXPIRED' WHERE bj.expiresAt < :now AND bj.status = 'COMPLETED'")
|
|
||||||
int markExpiredJobs(@Param("now") LocalDateTime now);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -27,9 +28,10 @@ import java.util.UUID;
|
|||||||
public class AsyncBackupExecutor {
|
public class AsyncBackupExecutor {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(AsyncBackupExecutor.class);
|
private static final Logger logger = LoggerFactory.getLogger(AsyncBackupExecutor.class);
|
||||||
|
private static final DateTimeFormatter FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
|
||||||
|
|
||||||
@Value("${storycove.upload.dir:/app/images}")
|
@Value("${storycove.backup.dir:/app/backups}")
|
||||||
private String uploadDir;
|
private String backupDir;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private BackupJobRepository backupJobRepository;
|
private BackupJobRepository backupJobRepository;
|
||||||
@@ -69,16 +71,13 @@ public class AsyncBackupExecutor {
|
|||||||
libraryService.switchToLibraryAfterAuthentication(job.getLibraryId());
|
libraryService.switchToLibraryAfterAuthentication(job.getLibraryId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create backup file
|
// Create backup file in the unified backup directory
|
||||||
Path backupDir = Paths.get(uploadDir, "backups", job.getLibraryId());
|
Path libraryBackupDir = Paths.get(backupDir, job.getLibraryId());
|
||||||
Files.createDirectories(backupDir);
|
Files.createDirectories(libraryBackupDir);
|
||||||
|
|
||||||
String filename = String.format("backup_%s_%s.%s",
|
String timestamp = LocalDateTime.now().format(FILENAME_FORMATTER);
|
||||||
job.getId().toString(),
|
String filename = String.format("manual_backup_%s.zip", timestamp);
|
||||||
LocalDateTime.now().toString().replaceAll(":", "-"),
|
Path backupFile = libraryBackupDir.resolve(filename);
|
||||||
job.getType() == BackupJob.BackupType.COMPLETE ? "zip" : "sql");
|
|
||||||
|
|
||||||
Path backupFile = backupDir.resolve(filename);
|
|
||||||
|
|
||||||
job.setProgressPercent(10);
|
job.setProgressPercent(10);
|
||||||
backupJobRepository.save(job);
|
backupJobRepository.save(job);
|
||||||
@@ -94,7 +93,7 @@ public class AsyncBackupExecutor {
|
|||||||
job.setProgressPercent(80);
|
job.setProgressPercent(80);
|
||||||
backupJobRepository.save(job);
|
backupJobRepository.save(job);
|
||||||
|
|
||||||
// Copy resource to permanent file
|
// Write backup to file
|
||||||
try (var inputStream = backupResource.getInputStream();
|
try (var inputStream = backupResource.getInputStream();
|
||||||
var outputStream = Files.newOutputStream(backupFile)) {
|
var outputStream = Files.newOutputStream(backupFile)) {
|
||||||
inputStream.transferTo(outputStream);
|
inputStream.transferTo(outputStream);
|
||||||
@@ -110,8 +109,8 @@ public class AsyncBackupExecutor {
|
|||||||
job.setCompletedAt(LocalDateTime.now());
|
job.setCompletedAt(LocalDateTime.now());
|
||||||
job.setProgressPercent(100);
|
job.setProgressPercent(100);
|
||||||
|
|
||||||
logger.info("Backup job {} completed successfully. File size: {} bytes",
|
logger.info("Backup job {} completed successfully. File: {}, Size: {} bytes",
|
||||||
job.getId(), job.getFileSizeBytes());
|
job.getId(), filename, job.getFileSizeBytes());
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Backup job {} failed", job.getId(), e);
|
logger.error("Backup job {} failed", job.getId(), e);
|
||||||
|
|||||||
@@ -5,19 +5,10 @@ import com.storycove.repository.BackupJobRepository;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.core.io.FileSystemResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -26,9 +17,6 @@ public class AsyncBackupService {
|
|||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(AsyncBackupService.class);
|
private static final Logger logger = LoggerFactory.getLogger(AsyncBackupService.class);
|
||||||
|
|
||||||
@Value("${storycove.upload.dir:/app/images}")
|
|
||||||
private String uploadDir;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private BackupJobRepository backupJobRepository;
|
private BackupJobRepository backupJobRepository;
|
||||||
|
|
||||||
@@ -37,7 +25,7 @@ public class AsyncBackupService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a backup job asynchronously.
|
* Start a backup job asynchronously.
|
||||||
* This method returns immediately after creating the job record.
|
* Returns immediately after creating the job record.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public BackupJob startBackupJob(String libraryId, BackupJob.BackupType type) {
|
public BackupJob startBackupJob(String libraryId, BackupJob.BackupType type) {
|
||||||
@@ -48,7 +36,6 @@ public class AsyncBackupService {
|
|||||||
|
|
||||||
logger.info("Backup job created with ID: {}. Starting async execution...", job.getId());
|
logger.info("Backup job created with ID: {}. Starting async execution...", job.getId());
|
||||||
|
|
||||||
// Start backup in background using separate service (ensures @Async works properly)
|
|
||||||
asyncBackupExecutor.executeBackupAsync(job.getId());
|
asyncBackupExecutor.executeBackupAsync(job.getId());
|
||||||
|
|
||||||
logger.info("Async backup execution triggered for job: {}", job.getId());
|
logger.info("Async backup execution triggered for job: {}", job.getId());
|
||||||
@@ -57,90 +44,15 @@ public class AsyncBackupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get backup job status
|
* Get backup job status (used for polling during backup creation).
|
||||||
*/
|
*/
|
||||||
public Optional<BackupJob> getJobStatus(UUID jobId) {
|
public Optional<BackupJob> getJobStatus(UUID jobId) {
|
||||||
return backupJobRepository.findById(jobId);
|
return backupJobRepository.findById(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get backup file for download
|
* Delete a stale or failed backup job record.
|
||||||
*/
|
* The actual file (if any) is managed by BackupManagementService.
|
||||||
public Resource getBackupFile(UUID jobId) throws IOException {
|
|
||||||
Optional<BackupJob> jobOpt = backupJobRepository.findById(jobId);
|
|
||||||
if (jobOpt.isEmpty()) {
|
|
||||||
throw new IOException("Backup job not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupJob job = jobOpt.get();
|
|
||||||
|
|
||||||
if (!job.isCompleted()) {
|
|
||||||
throw new IOException("Backup is not completed yet");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (job.isExpired()) {
|
|
||||||
throw new IOException("Backup has expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (job.getFilePath() == null) {
|
|
||||||
throw new IOException("Backup file path not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
Path backupPath = Paths.get(job.getFilePath());
|
|
||||||
if (!Files.exists(backupPath)) {
|
|
||||||
throw new IOException("Backup file not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileSystemResource(backupPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List backup jobs for a library
|
|
||||||
*/
|
|
||||||
public List<BackupJob> listBackupJobs(String libraryId) {
|
|
||||||
return backupJobRepository.findByLibraryIdOrderByCreatedAtDesc(libraryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up expired backup jobs and their files
|
|
||||||
* Runs daily at 2 AM
|
|
||||||
*/
|
|
||||||
@Scheduled(cron = "0 0 2 * * ?")
|
|
||||||
@Transactional
|
|
||||||
public void cleanupExpiredBackups() {
|
|
||||||
logger.info("Starting cleanup of expired backups");
|
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
|
|
||||||
// Mark expired jobs
|
|
||||||
int markedCount = backupJobRepository.markExpiredJobs(now);
|
|
||||||
logger.info("Marked {} jobs as expired", markedCount);
|
|
||||||
|
|
||||||
// Find all expired jobs to delete their files
|
|
||||||
List<BackupJob> expiredJobs = backupJobRepository.findExpiredJobs(now);
|
|
||||||
|
|
||||||
for (BackupJob job : expiredJobs) {
|
|
||||||
if (job.getFilePath() != null) {
|
|
||||||
try {
|
|
||||||
Path filePath = Paths.get(job.getFilePath());
|
|
||||||
if (Files.exists(filePath)) {
|
|
||||||
Files.delete(filePath);
|
|
||||||
logger.info("Deleted expired backup file: {}", filePath);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Failed to delete expired backup file: {}", job.getFilePath(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the job record
|
|
||||||
backupJobRepository.delete(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Cleanup completed. Deleted {} expired backups", expiredJobs.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a specific backup job and its file
|
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteBackupJob(UUID jobId) throws IOException {
|
public void deleteBackupJob(UUID jobId) throws IOException {
|
||||||
@@ -149,19 +61,7 @@ public class AsyncBackupService {
|
|||||||
throw new IOException("Backup job not found");
|
throw new IOException("Backup job not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
BackupJob job = jobOpt.get();
|
backupJobRepository.delete(jobOpt.get());
|
||||||
|
logger.info("Deleted backup job record: {}", jobId);
|
||||||
// Delete file if it exists
|
|
||||||
if (job.getFilePath() != null) {
|
|
||||||
Path filePath = Paths.get(job.getFilePath());
|
|
||||||
if (Files.exists(filePath)) {
|
|
||||||
Files.delete(filePath);
|
|
||||||
logger.info("Deleted backup file: {}", filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete job record
|
|
||||||
backupJobRepository.delete(job);
|
|
||||||
logger.info("Deleted backup job: {}", jobId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import java.util.stream.Stream;
|
|||||||
/**
|
/**
|
||||||
* Service for automatic daily backups.
|
* Service for automatic daily backups.
|
||||||
* Runs at 4 AM daily and creates a backup if content has changed since last backup.
|
* Runs at 4 AM daily and creates a backup if content has changed since last backup.
|
||||||
* Keeps maximum of 5 backups, rotating old ones out.
|
* Keeps maximum of MAX_BACKUPS auto backups, rotating old ones out.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class AutomaticBackupService {
|
public class AutomaticBackupService {
|
||||||
@@ -32,8 +32,8 @@ public class AutomaticBackupService {
|
|||||||
private static final int MAX_BACKUPS = 10;
|
private static final int MAX_BACKUPS = 10;
|
||||||
private static final DateTimeFormatter FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
|
private static final DateTimeFormatter FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
|
||||||
|
|
||||||
@Value("${storycove.automatic-backup.dir:/app/automatic-backups}")
|
@Value("${storycove.backup.dir:/app/backups}")
|
||||||
private String automaticBackupDir;
|
private String backupDir;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private StoryRepository storyRepository;
|
private StoryRepository storyRepository;
|
||||||
@@ -57,7 +57,6 @@ public class AutomaticBackupService {
|
|||||||
logger.info("========================================");
|
logger.info("========================================");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current library ID (or default)
|
|
||||||
String libraryId = libraryService.getCurrentLibraryId();
|
String libraryId = libraryService.getCurrentLibraryId();
|
||||||
if (libraryId == null) {
|
if (libraryId == null) {
|
||||||
libraryId = "default";
|
libraryId = "default";
|
||||||
@@ -65,7 +64,6 @@ public class AutomaticBackupService {
|
|||||||
|
|
||||||
logger.info("Checking for content changes in library: {}", libraryId);
|
logger.info("Checking for content changes in library: {}", libraryId);
|
||||||
|
|
||||||
// Check if content has changed since last backup
|
|
||||||
if (!hasContentChanged()) {
|
if (!hasContentChanged()) {
|
||||||
logger.info("No content changes detected since last backup. Skipping backup.");
|
logger.info("No content changes detected since last backup. Skipping backup.");
|
||||||
logger.info("========================================");
|
logger.info("========================================");
|
||||||
@@ -74,34 +72,29 @@ public class AutomaticBackupService {
|
|||||||
|
|
||||||
logger.info("Content changes detected! Creating automatic backup...");
|
logger.info("Content changes detected! Creating automatic backup...");
|
||||||
|
|
||||||
// Create backup directory for this library
|
Path libraryBackupDir = Paths.get(backupDir, libraryId);
|
||||||
Path backupPath = Paths.get(automaticBackupDir, libraryId);
|
Files.createDirectories(libraryBackupDir);
|
||||||
Files.createDirectories(backupPath);
|
|
||||||
|
|
||||||
// Create the backup
|
|
||||||
String timestamp = LocalDateTime.now().format(FILENAME_FORMATTER);
|
String timestamp = LocalDateTime.now().format(FILENAME_FORMATTER);
|
||||||
String filename = String.format("auto_backup_%s.zip", timestamp);
|
String filename = String.format("auto_backup_%s.zip", timestamp);
|
||||||
Path backupFile = backupPath.resolve(filename);
|
Path backupFile = libraryBackupDir.resolve(filename);
|
||||||
|
|
||||||
logger.info("Creating complete backup to: {}", backupFile);
|
logger.info("Creating complete backup to: {}", backupFile);
|
||||||
|
|
||||||
Resource backup = databaseManagementService.createCompleteBackup();
|
Resource backup = databaseManagementService.createCompleteBackup();
|
||||||
|
|
||||||
// Write backup to file
|
|
||||||
try (var inputStream = backup.getInputStream();
|
try (var inputStream = backup.getInputStream();
|
||||||
var outputStream = Files.newOutputStream(backupFile)) {
|
var outputStream = Files.newOutputStream(backupFile)) {
|
||||||
inputStream.transferTo(outputStream);
|
inputStream.transferTo(outputStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
long fileSize = Files.size(backupFile);
|
long fileSize = Files.size(backupFile);
|
||||||
logger.info("✅ Automatic backup created successfully");
|
logger.info("Automatic backup created successfully");
|
||||||
logger.info(" File: {}", backupFile.getFileName());
|
logger.info(" File: {}", backupFile.getFileName());
|
||||||
logger.info(" Size: {} MB", fileSize / 1024 / 1024);
|
logger.info(" Size: {} MB", fileSize / 1024 / 1024);
|
||||||
|
|
||||||
// Rotate old backups (keep only MAX_BACKUPS)
|
rotateAutoBackups(libraryBackupDir);
|
||||||
rotateBackups(backupPath);
|
|
||||||
|
|
||||||
// Update last backup check time
|
|
||||||
lastBackupCheck = LocalDateTime.now();
|
lastBackupCheck = LocalDateTime.now();
|
||||||
|
|
||||||
logger.info("========================================");
|
logger.info("========================================");
|
||||||
@@ -109,104 +102,39 @@ public class AutomaticBackupService {
|
|||||||
logger.info("========================================");
|
logger.info("========================================");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("❌ Automatic backup failed", e);
|
logger.error("Automatic backup failed", e);
|
||||||
logger.info("========================================");
|
logger.info("========================================");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if content has changed since last backup.
|
|
||||||
* Looks for stories created or updated after the last backup time.
|
|
||||||
*/
|
|
||||||
private boolean hasContentChanged() {
|
private boolean hasContentChanged() {
|
||||||
try {
|
try {
|
||||||
if (lastBackupCheck == null) {
|
if (lastBackupCheck == null) {
|
||||||
// First run - check if there are any stories at all
|
|
||||||
long storyCount = storyRepository.count();
|
long storyCount = storyRepository.count();
|
||||||
logger.info("First backup check - found {} stories", storyCount);
|
logger.info("First backup check - found {} stories", storyCount);
|
||||||
return storyCount > 0;
|
return storyCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for stories created or updated since last backup
|
|
||||||
long changedCount = storyRepository.countStoriesModifiedAfter(lastBackupCheck);
|
long changedCount = storyRepository.countStoriesModifiedAfter(lastBackupCheck);
|
||||||
logger.info("Found {} stories modified since last backup ({})", changedCount, lastBackupCheck);
|
logger.info("Found {} stories modified since last backup ({})", changedCount, lastBackupCheck);
|
||||||
return changedCount > 0;
|
return changedCount > 0;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Error checking for content changes", e);
|
logger.error("Error checking for content changes", e);
|
||||||
// On error, create backup to be safe
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotate backups - keep only MAX_BACKUPS most recent backups.
|
* Rotate auto backups — keep only MAX_BACKUPS most recent auto_backup_*.zip files.
|
||||||
* Deletes older backups.
|
* Manual backups are never touched by rotation.
|
||||||
*/
|
*/
|
||||||
private void rotateBackups(Path backupPath) throws IOException {
|
private void rotateAutoBackups(Path libraryBackupDir) throws IOException {
|
||||||
logger.info("Checking for old backups to rotate...");
|
logger.info("Checking for old automatic backups to rotate...");
|
||||||
|
|
||||||
// Find all backup files in the directory
|
List<Path> autoBackupFiles;
|
||||||
List<Path> backupFiles;
|
try (Stream<Path> stream = Files.list(libraryBackupDir)) {
|
||||||
try (Stream<Path> stream = Files.list(backupPath)) {
|
autoBackupFiles = stream
|
||||||
backupFiles = stream
|
|
||||||
.filter(Files::isRegularFile)
|
|
||||||
.filter(p -> p.getFileName().toString().startsWith("auto_backup_"))
|
|
||||||
.filter(p -> p.getFileName().toString().endsWith(".zip"))
|
|
||||||
.sorted(Comparator.comparing((Path p) -> {
|
|
||||||
try {
|
|
||||||
return Files.getLastModifiedTime(p);
|
|
||||||
} catch (IOException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}).reversed()) // Most recent first
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Found {} automatic backups", backupFiles.size());
|
|
||||||
|
|
||||||
// Delete old backups if we exceed MAX_BACKUPS
|
|
||||||
if (backupFiles.size() > MAX_BACKUPS) {
|
|
||||||
List<Path> toDelete = backupFiles.subList(MAX_BACKUPS, backupFiles.size());
|
|
||||||
logger.info("Deleting {} old backups to maintain maximum of {}", toDelete.size(), MAX_BACKUPS);
|
|
||||||
|
|
||||||
for (Path oldBackup : toDelete) {
|
|
||||||
try {
|
|
||||||
Files.delete(oldBackup);
|
|
||||||
logger.info(" Deleted old backup: {}", oldBackup.getFileName());
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Failed to delete old backup: {}", oldBackup, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info("Backup count within limit ({}), no rotation needed", MAX_BACKUPS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manual trigger for testing - creates backup immediately if content changed.
|
|
||||||
*/
|
|
||||||
public void triggerManualBackup() {
|
|
||||||
logger.info("Manual automatic backup triggered");
|
|
||||||
performAutomaticBackup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of automatic backups for the current library.
|
|
||||||
*/
|
|
||||||
public List<BackupInfo> listAutomaticBackups() throws IOException {
|
|
||||||
String libraryId = libraryService.getCurrentLibraryId();
|
|
||||||
if (libraryId == null) {
|
|
||||||
libraryId = "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
Path backupPath = Paths.get(automaticBackupDir, libraryId);
|
|
||||||
if (!Files.exists(backupPath)) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Stream<Path> stream = Files.list(backupPath)) {
|
|
||||||
return stream
|
|
||||||
.filter(Files::isRegularFile)
|
.filter(Files::isRegularFile)
|
||||||
.filter(p -> p.getFileName().toString().startsWith("auto_backup_"))
|
.filter(p -> p.getFileName().toString().startsWith("auto_backup_"))
|
||||||
.filter(p -> p.getFileName().toString().endsWith(".zip"))
|
.filter(p -> p.getFileName().toString().endsWith(".zip"))
|
||||||
@@ -217,46 +145,30 @@ public class AutomaticBackupService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}).reversed())
|
}).reversed())
|
||||||
.map(p -> {
|
|
||||||
try {
|
|
||||||
return new BackupInfo(
|
|
||||||
p.getFileName().toString(),
|
|
||||||
Files.size(p),
|
|
||||||
Files.getLastModifiedTime(p).toInstant().toString()
|
|
||||||
);
|
|
||||||
} catch (IOException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(info -> info != null)
|
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Found {} automatic backups", autoBackupFiles.size());
|
||||||
|
|
||||||
|
if (autoBackupFiles.size() > MAX_BACKUPS) {
|
||||||
|
List<Path> toDelete = autoBackupFiles.subList(MAX_BACKUPS, autoBackupFiles.size());
|
||||||
|
logger.info("Deleting {} old automatic backups to maintain maximum of {}", toDelete.size(), MAX_BACKUPS);
|
||||||
|
|
||||||
|
for (Path oldBackup : toDelete) {
|
||||||
|
try {
|
||||||
|
Files.delete(oldBackup);
|
||||||
|
logger.info(" Deleted old backup: {}", oldBackup.getFileName());
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warn("Failed to delete old backup: {}", oldBackup, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info("Auto backup count within limit ({}), no rotation needed", MAX_BACKUPS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public void triggerManualBackup() {
|
||||||
* Simple backup info class.
|
logger.info("Manual automatic backup triggered");
|
||||||
*/
|
performAutomaticBackup();
|
||||||
public static class BackupInfo {
|
|
||||||
private final String filename;
|
|
||||||
private final long sizeBytes;
|
|
||||||
private final String createdAt;
|
|
||||||
|
|
||||||
public BackupInfo(String filename, long sizeBytes, String createdAt) {
|
|
||||||
this.filename = filename;
|
|
||||||
this.sizeBytes = sizeBytes;
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getFilename() {
|
|
||||||
return filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getSizeBytes() {
|
|
||||||
return sizeBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages backup files on the filesystem.
|
||||||
|
* The backup directory is the single source of truth for listing and restoring backups.
|
||||||
|
* Files placed in the directory manually (e.g. copied from another server) are
|
||||||
|
* automatically discovered and available for restore.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class BackupManagementService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(BackupManagementService.class);
|
||||||
|
|
||||||
|
@Value("${storycove.backup.dir:/app/backups}")
|
||||||
|
private String backupDir;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DatabaseManagementService databaseManagementService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LibraryService libraryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all backup files for the current library, sorted newest first.
|
||||||
|
* Discovers any .zip file in the library backup directory, including ones copied in from another server.
|
||||||
|
*/
|
||||||
|
public List<BackupFileInfo> listBackups() throws IOException {
|
||||||
|
String libraryId = currentLibraryId();
|
||||||
|
Path dir = Paths.get(backupDir, libraryId);
|
||||||
|
|
||||||
|
if (!Files.exists(dir)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Stream<Path> stream = Files.list(dir)) {
|
||||||
|
return stream
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.filter(p -> p.getFileName().toString().endsWith(".zip"))
|
||||||
|
.sorted(Comparator.comparing((Path p) -> {
|
||||||
|
try {
|
||||||
|
return Files.getLastModifiedTime(p);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).reversed())
|
||||||
|
.map(p -> {
|
||||||
|
try {
|
||||||
|
String filename = p.getFileName().toString();
|
||||||
|
long sizeBytes = Files.size(p);
|
||||||
|
Instant createdAt = Files.getLastModifiedTime(p).toInstant();
|
||||||
|
BackupOrigin origin = detectOrigin(filename);
|
||||||
|
return new BackupFileInfo(filename, sizeBytes, createdAt, origin);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(info -> info != null)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore from a named backup file in the current library's backup directory.
|
||||||
|
*/
|
||||||
|
public void restoreFromFile(String filename) throws IOException {
|
||||||
|
validateFilename(filename);
|
||||||
|
Path backupFile = resolveBackupFile(filename);
|
||||||
|
|
||||||
|
if (!Files.exists(backupFile)) {
|
||||||
|
throw new IOException("Backup file not found: " + filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Restoring from backup file: {}", filename);
|
||||||
|
try (var inputStream = Files.newInputStream(backupFile)) {
|
||||||
|
databaseManagementService.restoreFromCompleteBackup(inputStream);
|
||||||
|
} catch (java.sql.SQLException e) {
|
||||||
|
throw new IOException("Restore failed: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
logger.info("Restore from {} completed successfully", filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a named backup file from the current library's backup directory.
|
||||||
|
*/
|
||||||
|
public void deleteBackup(String filename) throws IOException {
|
||||||
|
validateFilename(filename);
|
||||||
|
Path backupFile = resolveBackupFile(filename);
|
||||||
|
|
||||||
|
if (!Files.exists(backupFile)) {
|
||||||
|
throw new IOException("Backup file not found: " + filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.delete(backupFile);
|
||||||
|
logger.info("Deleted backup file: {}", filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a Resource for downloading a named backup file.
|
||||||
|
*/
|
||||||
|
public Resource getBackupResource(String filename) throws IOException {
|
||||||
|
validateFilename(filename);
|
||||||
|
Path backupFile = resolveBackupFile(filename);
|
||||||
|
|
||||||
|
if (!Files.exists(backupFile)) {
|
||||||
|
throw new IOException("Backup file not found: " + filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileSystemResource(backupFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path resolveBackupFile(String filename) {
|
||||||
|
String libraryId = currentLibraryId();
|
||||||
|
return Paths.get(backupDir, libraryId, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String currentLibraryId() {
|
||||||
|
String libraryId = libraryService.getCurrentLibraryId();
|
||||||
|
return libraryId != null ? libraryId : "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
private BackupOrigin detectOrigin(String filename) {
|
||||||
|
if (filename.startsWith("manual_backup_")) {
|
||||||
|
return BackupOrigin.MANUAL;
|
||||||
|
} else if (filename.startsWith("auto_backup_")) {
|
||||||
|
return BackupOrigin.AUTO;
|
||||||
|
}
|
||||||
|
return BackupOrigin.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prevent path traversal by rejecting filenames with directory separators. */
|
||||||
|
private void validateFilename(String filename) throws IOException {
|
||||||
|
if (filename == null || filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) {
|
||||||
|
throw new IOException("Invalid backup filename: " + filename);
|
||||||
|
}
|
||||||
|
if (!filename.endsWith(".zip")) {
|
||||||
|
throw new IOException("Only .zip backup files are supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BackupOrigin {
|
||||||
|
MANUAL, AUTO, UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class BackupFileInfo {
|
||||||
|
private final String filename;
|
||||||
|
private final long sizeBytes;
|
||||||
|
private final Instant createdAt;
|
||||||
|
private final BackupOrigin origin;
|
||||||
|
|
||||||
|
public BackupFileInfo(String filename, long sizeBytes, Instant createdAt, BackupOrigin origin) {
|
||||||
|
this.filename = filename;
|
||||||
|
this.sizeBytes = sizeBytes;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.origin = origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilename() {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getSizeBytes() {
|
||||||
|
return sizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BackupOrigin getOrigin() {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,8 +94,8 @@ storycove:
|
|||||||
enable-metrics: ${SOLR_ENABLE_METRICS:true}
|
enable-metrics: ${SOLR_ENABLE_METRICS:true}
|
||||||
images:
|
images:
|
||||||
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
|
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
|
||||||
automatic-backup:
|
backup:
|
||||||
dir: ${AUTOMATIC_BACKUP_DIR:/app/automatic-backups}
|
dir: ${BACKUP_DIR:/app/backups}
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker/storycove/images:/app/images
|
- /volume1/docker/storycove/images:/app/images
|
||||||
- /volume1/docker/storycove/config:/app/config
|
- /volume1/docker/storycove/config:/app/config
|
||||||
- /volume1/docker/storycove/backups:/app/automatic-backups
|
- /volume1/docker/storycove/backups:/app/backups
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -39,15 +39,25 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
success?: boolean;
|
success?: boolean;
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
downloadReady?: boolean;
|
|
||||||
};
|
};
|
||||||
completeRestore: { loading: boolean; message: string; success?: boolean };
|
|
||||||
completeClear: { loading: boolean; message: string; success?: boolean };
|
completeClear: { loading: boolean; message: string; success?: boolean };
|
||||||
}>({
|
}>({
|
||||||
completeBackup: { loading: false, message: '', progress: 0 },
|
completeBackup: { loading: false, message: '', progress: 0 },
|
||||||
completeRestore: { loading: false, message: '' },
|
|
||||||
completeClear: { loading: false, message: '' }
|
completeClear: { loading: false, message: '' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [backupList, setBackupList] = useState<{
|
||||||
|
loading: boolean;
|
||||||
|
backups: Array<{ filename: string; sizeBytes: number; createdAt: string; origin: 'MANUAL' | 'AUTO' | 'UNKNOWN' }>;
|
||||||
|
error?: string;
|
||||||
|
}>({ loading: false, backups: [] });
|
||||||
|
|
||||||
|
const [restoreStatus, setRestoreStatus] = useState<{
|
||||||
|
loading: boolean;
|
||||||
|
filename?: string;
|
||||||
|
message: string;
|
||||||
|
success?: boolean;
|
||||||
|
}>({ loading: false, message: '' });
|
||||||
const [cleanupStatus, setCleanupStatus] = useState<{
|
const [cleanupStatus, setCleanupStatus] = useState<{
|
||||||
preview: { loading: boolean; message: string; success?: boolean; data?: any };
|
preview: { loading: boolean; message: string; success?: boolean; data?: any };
|
||||||
execute: { loading: boolean; message: string; success?: boolean };
|
execute: { loading: boolean; message: string; success?: boolean };
|
||||||
@@ -67,6 +77,20 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
const [hoveredImage, setHoveredImage] = useState<{ src: string; alt: string } | null>(null);
|
const [hoveredImage, setHoveredImage] = useState<{ src: string; alt: string } | null>(null);
|
||||||
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const loadBackups = async () => {
|
||||||
|
setBackupList(prev => ({ ...prev, loading: true, error: undefined }));
|
||||||
|
try {
|
||||||
|
const result = await databaseApi.listBackupFiles();
|
||||||
|
setBackupList({ loading: false, backups: result.backups });
|
||||||
|
} catch (error: any) {
|
||||||
|
setBackupList({ loading: false, backups: [], error: error.message || 'Failed to load backups' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBackups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleImageHover = (filePath: string, fileName: string, event: React.MouseEvent) => {
|
const handleImageHover = (filePath: string, fileName: string, event: React.MouseEvent) => {
|
||||||
// Convert backend file path to frontend image URL
|
// Convert backend file path to frontend image URL
|
||||||
const imageUrl = filePath.replace(/^.*\/images\//, '/images/');
|
const imageUrl = filePath.replace(/^.*\/images\//, '/images/');
|
||||||
@@ -88,11 +112,10 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
const handleCompleteBackup = async () => {
|
const handleCompleteBackup = async () => {
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: { loading: true, message: 'Starting backup...', success: undefined, progress: 0, downloadReady: false }
|
completeBackup: { loading: true, message: 'Starting backup...', success: undefined, progress: 0 }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start the async backup job
|
|
||||||
const startResponse = await databaseApi.backupComplete();
|
const startResponse = await databaseApi.backupComplete();
|
||||||
const jobId = startResponse.jobId;
|
const jobId = startResponse.jobId;
|
||||||
|
|
||||||
@@ -101,7 +124,6 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
completeBackup: { ...prev.completeBackup, jobId, message: 'Backup in progress...' }
|
completeBackup: { ...prev.completeBackup, jobId, message: 'Backup in progress...' }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Poll for progress
|
|
||||||
const pollInterval = setInterval(async () => {
|
const pollInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const status = await databaseApi.getBackupStatus(jobId);
|
const status = await databaseApi.getBackupStatus(jobId);
|
||||||
@@ -110,147 +132,79 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: {
|
completeBackup: { loading: false, message: 'Backup created successfully.', success: true, jobId, progress: 100 }
|
||||||
loading: false,
|
|
||||||
message: 'Backup completed! Ready to download.',
|
|
||||||
success: true,
|
|
||||||
jobId,
|
|
||||||
progress: 100,
|
|
||||||
downloadReady: true
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
loadBackups();
|
||||||
// Clear message after 30 seconds (keep download button visible)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({ ...prev, completeBackup: { ...prev.completeBackup, message: '' } }));
|
||||||
...prev,
|
}, 8000);
|
||||||
completeBackup: { ...prev.completeBackup, message: '' }
|
|
||||||
}));
|
|
||||||
}, 30000);
|
|
||||||
} else if (status.status === 'FAILED') {
|
} else if (status.status === 'FAILED') {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: {
|
completeBackup: { loading: false, message: `Backup failed: ${status.errorMessage}`, success: false, progress: 0 }
|
||||||
loading: false,
|
|
||||||
message: `Backup failed: ${status.errorMessage}`,
|
|
||||||
success: false,
|
|
||||||
progress: 0,
|
|
||||||
downloadReady: false
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// Update progress
|
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: {
|
completeBackup: { ...prev.completeBackup, progress: status.progress, message: `Creating backup... ${status.progress}%` }
|
||||||
...prev.completeBackup,
|
|
||||||
progress: status.progress,
|
|
||||||
message: `Creating backup... ${status.progress}%`
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (pollError: any) {
|
} catch (pollError: any) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: {
|
completeBackup: { loading: false, message: `Failed to check backup status: ${pollError.message}`, success: false, progress: 0 }
|
||||||
loading: false,
|
|
||||||
message: `Failed to check backup status: ${pollError.message}`,
|
|
||||||
success: false,
|
|
||||||
progress: 0,
|
|
||||||
downloadReady: false
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, 2000); // Poll every 2 seconds
|
}, 2000);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: {
|
completeBackup: { loading: false, message: error.message || 'Failed to start backup', success: false, progress: 0 }
|
||||||
loading: false,
|
|
||||||
message: error.message || 'Failed to start backup',
|
|
||||||
success: false,
|
|
||||||
progress: 0,
|
|
||||||
downloadReady: false
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadBackup = (jobId: string) => {
|
const handleRestoreFromFile = async (filename: string) => {
|
||||||
const downloadUrl = databaseApi.downloadBackup(jobId);
|
const confirmed = window.confirm(
|
||||||
|
`Are you sure you want to restore "${filename}"? This will PERMANENTLY DELETE all current data AND files (cover images, avatars) and replace them with the backup data. This action cannot be undone!`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setRestoreStatus({ loading: true, filename, message: 'Restoring backup...' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await databaseApi.restoreFromFile(filename);
|
||||||
|
setRestoreStatus({ loading: false, filename, message: result.message, success: result.success });
|
||||||
|
} catch (error: any) {
|
||||||
|
setRestoreStatus({ loading: false, filename, message: error.message || 'Restore failed', success: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setRestoreStatus({ loading: false, message: '' }), 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadBackupFile = (filename: string) => {
|
||||||
|
const url = databaseApi.downloadBackupFile(filename);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = downloadUrl;
|
link.href = url;
|
||||||
link.download = ''; // Filename will be set by server
|
link.download = filename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
// Clear the download ready state after download
|
|
||||||
setDatabaseStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
completeBackup: {
|
|
||||||
loading: false,
|
|
||||||
message: 'Backup downloaded successfully',
|
|
||||||
success: true,
|
|
||||||
progress: 100,
|
|
||||||
downloadReady: false
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDeleteBackupFile = async (filename: string) => {
|
||||||
const file = event.target.files?.[0];
|
const confirmed = window.confirm(`Delete backup "${filename}"? This cannot be undone.`);
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Reset the input so the same file can be selected again
|
|
||||||
event.target.value = '';
|
|
||||||
|
|
||||||
if (!file.name.endsWith('.zip')) {
|
|
||||||
setDatabaseStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
completeRestore: { loading: false, message: 'Please select a .zip file', success: false }
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
'Are you sure you want to restore the complete backup? This will PERMANENTLY DELETE all current data AND files (cover images, avatars) and replace them with the backup data. This action cannot be undone!'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setDatabaseStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
completeRestore: { loading: true, message: 'Restoring complete backup...', success: undefined }
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await databaseApi.restoreComplete(file);
|
await databaseApi.deleteBackupFile(filename);
|
||||||
setDatabaseStatus(prev => ({
|
loadBackups();
|
||||||
...prev,
|
|
||||||
completeRestore: {
|
|
||||||
loading: false,
|
|
||||||
message: result.success ? result.message : result.message,
|
|
||||||
success: result.success
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setDatabaseStatus(prev => ({
|
alert(`Failed to delete backup: ${error.message}`);
|
||||||
...prev,
|
|
||||||
completeRestore: { loading: false, message: error.message || 'Complete restore failed', success: false }
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear message after 10 seconds for restore (longer because it's important)
|
|
||||||
setTimeout(() => {
|
|
||||||
setDatabaseStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
completeRestore: { loading: false, message: '', success: undefined }
|
|
||||||
}));
|
|
||||||
}, 10000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteClear = async () => {
|
const handleCompleteClear = async () => {
|
||||||
@@ -1129,29 +1083,17 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
<div className="border theme-border rounded-lg p-4 border-blue-200 dark:border-blue-800">
|
<div className="border theme-border rounded-lg p-4 border-blue-200 dark:border-blue-800">
|
||||||
<h3 className="text-lg font-semibold theme-header mb-3">📦 Create Backup</h3>
|
<h3 className="text-lg font-semibold theme-header mb-3">📦 Create Backup</h3>
|
||||||
<p className="text-sm theme-text mb-3">
|
<p className="text-sm theme-text mb-3">
|
||||||
Download a complete backup as a ZIP file. This includes your database AND all uploaded files (cover images, avatars). This is a comprehensive backup of your entire StoryCove installation.
|
Creates a complete backup saved on the server. The backup includes your database AND all uploaded files (cover images, avatars).
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<Button
|
||||||
<Button
|
onClick={handleCompleteBackup}
|
||||||
onClick={handleCompleteBackup}
|
disabled={databaseStatus.completeBackup.loading}
|
||||||
disabled={databaseStatus.completeBackup.loading || databaseStatus.completeBackup.downloadReady}
|
loading={databaseStatus.completeBackup.loading}
|
||||||
loading={databaseStatus.completeBackup.loading}
|
variant="primary"
|
||||||
variant="primary"
|
className="w-full sm:w-auto"
|
||||||
className="w-full sm:w-auto"
|
>
|
||||||
>
|
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Create Backup'}
|
||||||
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Create Backup'}
|
</Button>
|
||||||
</Button>
|
|
||||||
|
|
||||||
{databaseStatus.completeBackup.downloadReady && databaseStatus.completeBackup.jobId && (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDownloadBackup(databaseStatus.completeBackup.jobId!)}
|
|
||||||
variant="primary"
|
|
||||||
className="w-full sm:w-auto ml-0 sm:ml-3 bg-green-600 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
⬇️ Download Backup
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{databaseStatus.completeBackup.loading && databaseStatus.completeBackup.progress !== undefined && (
|
{databaseStatus.completeBackup.loading && databaseStatus.completeBackup.progress !== undefined && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
@@ -1181,34 +1123,117 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Restore Section */}
|
{/* Backup List / Restore Section */}
|
||||||
<div className="border theme-border rounded-lg p-4 border-orange-200 dark:border-orange-800">
|
<div className="border theme-border rounded-lg p-4 border-blue-200 dark:border-blue-800">
|
||||||
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Backup</h3>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-sm theme-text mb-3">
|
<h3 className="text-lg font-semibold theme-header">📋 Backups</h3>
|
||||||
<strong className="text-orange-600 dark:text-orange-400">⚠️ Warning:</strong> This will completely replace your current database AND all files with the backup. All existing data and uploaded files will be permanently deleted.
|
<button
|
||||||
</p>
|
onClick={loadBackups}
|
||||||
<div className="flex items-center gap-3">
|
disabled={backupList.loading}
|
||||||
<input
|
className="text-sm theme-text opacity-70 hover:opacity-100 flex items-center gap-1"
|
||||||
type="file"
|
title="Refresh backup list"
|
||||||
accept=".zip"
|
>
|
||||||
onChange={handleCompleteRestore}
|
<span className={backupList.loading ? 'animate-spin' : ''}>↻</span>
|
||||||
disabled={databaseStatus.completeRestore.loading}
|
Refresh
|
||||||
className="flex-1 text-sm theme-text file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:theme-accent-bg file:text-white hover:file:bg-opacity-90 file:cursor-pointer"
|
</button>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{databaseStatus.completeRestore.message && (
|
<p className="text-sm theme-text mb-4">
|
||||||
<div className={`text-sm p-2 rounded mt-3 ${
|
Backups are stored on the server. To transfer a backup to another server, download it and place it in the server's backup directory — it will appear here automatically.
|
||||||
databaseStatus.completeRestore.success
|
</p>
|
||||||
|
|
||||||
|
{restoreStatus.message && (
|
||||||
|
<div className={`text-sm p-2 rounded mb-3 ${
|
||||||
|
restoreStatus.success
|
||||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
: restoreStatus.success === false
|
||||||
|
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
|
||||||
}`}>
|
}`}>
|
||||||
{databaseStatus.completeRestore.message}
|
{restoreStatus.loading && (
|
||||||
|
<span className="inline-block animate-spin mr-2">↻</span>
|
||||||
|
)}
|
||||||
|
{restoreStatus.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{databaseStatus.completeRestore.loading && (
|
|
||||||
<div className="text-sm theme-text mt-3 flex items-center gap-2">
|
{backupList.error && (
|
||||||
|
<div className="text-sm p-2 rounded mb-3 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200">
|
||||||
|
{backupList.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backupList.loading && backupList.backups.length === 0 ? (
|
||||||
|
<div className="text-sm theme-text flex items-center gap-2 py-4">
|
||||||
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||||
Restoring backup...
|
Loading backups...
|
||||||
|
</div>
|
||||||
|
) : backupList.backups.length === 0 ? (
|
||||||
|
<p className="text-sm theme-text opacity-60 py-4 text-center">No backups found. Create your first backup above.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b theme-border text-left">
|
||||||
|
<th className="pb-2 pr-4 font-medium theme-text">Date</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium theme-text">Origin</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium theme-text">Size</th>
|
||||||
|
<th className="pb-2 font-medium theme-text text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{backupList.backups.map(backup => (
|
||||||
|
<tr key={backup.filename} className="border-b theme-border last:border-0">
|
||||||
|
<td className="py-2 pr-4 theme-text">
|
||||||
|
<div>{new Date(backup.createdAt).toLocaleDateString()}</div>
|
||||||
|
<div className="text-xs opacity-60">{new Date(backup.createdAt).toLocaleTimeString()}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
backup.origin === 'MANUAL'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
|
||||||
|
: backup.origin === 'AUTO'
|
||||||
|
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{backup.origin === 'MANUAL' ? 'Manual' : backup.origin === 'AUTO' ? 'Automatic' : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 theme-text opacity-70 text-xs">
|
||||||
|
{backup.sizeBytes >= 1024 * 1024
|
||||||
|
? `${(backup.sizeBytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: `${(backup.sizeBytes / 1024).toFixed(0)} KB`}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestoreFromFile(backup.filename)}
|
||||||
|
disabled={restoreStatus.loading}
|
||||||
|
className="text-xs px-2 py-1 rounded border theme-border theme-text hover:bg-orange-50 dark:hover:bg-orange-900/20 hover:border-orange-300 dark:hover:border-orange-700 disabled:opacity-40"
|
||||||
|
title="Restore this backup"
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadBackupFile(backup.filename)}
|
||||||
|
className="text-xs px-2 py-1 rounded border theme-border theme-text hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-700"
|
||||||
|
title="Download this backup"
|
||||||
|
>
|
||||||
|
⬇
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteBackupFile(backup.filename)}
|
||||||
|
disabled={restoreStatus.loading}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-40"
|
||||||
|
title="Delete this backup"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1243,10 +1268,9 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
<p className="font-medium mb-1">💡 Best Practices:</p>
|
<p className="font-medium mb-1">💡 Best Practices:</p>
|
||||||
<ul className="text-xs space-y-1 ml-4">
|
<ul className="text-xs space-y-1 ml-4">
|
||||||
<li>• <strong>Always backup</strong> before performing restore or clear operations</li>
|
<li>• <strong>Always backup</strong> before performing restore or clear operations</li>
|
||||||
<li>• <strong>Store backups safely</strong> in multiple locations for important data</li>
|
<li>• <strong>Cross-server transfer:</strong> download a backup and place it in the backup directory of the other server — it appears in this list automatically</li>
|
||||||
<li>• <strong>Test restores</strong> in a development environment when possible</li>
|
|
||||||
<li>• <strong>Backup files (.zip)</strong> contain both database and all uploaded files</li>
|
<li>• <strong>Backup files (.zip)</strong> contain both database and all uploaded files</li>
|
||||||
<li>• <strong>Verify backup files</strong> are complete before relying on them</li>
|
<li>• <strong>Automatic backups</strong> are created daily at 4 AM when content has changed (up to 10 kept)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1043,27 +1043,12 @@ export const collectionApi = {
|
|||||||
|
|
||||||
// Database management endpoints
|
// Database management endpoints
|
||||||
export const databaseApi = {
|
export const databaseApi = {
|
||||||
backup: async (): Promise<Blob> => {
|
|
||||||
const response = await api.post('/database/backup', {}, {
|
|
||||||
responseType: 'blob'
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
restore: async (file: File): Promise<{ success: boolean; message: string }> => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const response = await api.post('/database/restore', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
clear: async (): Promise<{ success: boolean; message: string; deletedRecords?: number }> => {
|
clear: async (): Promise<{ success: boolean; message: string; deletedRecords?: number }> => {
|
||||||
const response = await api.post('/database/clear');
|
const response = await api.post('/database/clear');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Async backup creation
|
||||||
backupComplete: async (): Promise<{ success: boolean; jobId: string; status: string; message: string }> => {
|
backupComplete: async (): Promise<{ success: boolean; jobId: string; status: string; message: string }> => {
|
||||||
const response = await api.post('/database/backup-complete');
|
const response = await api.post('/database/backup-complete');
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -1083,37 +1068,31 @@ export const databaseApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadBackup: (jobId: string): string => {
|
// Filesystem-based backup management
|
||||||
return `/api/database/backup-download/${jobId}`;
|
listBackupFiles: async (): Promise<{
|
||||||
},
|
|
||||||
|
|
||||||
listBackups: async (): Promise<{
|
|
||||||
success: boolean;
|
success: boolean;
|
||||||
backups: Array<{
|
backups: Array<{
|
||||||
jobId: string;
|
filename: string;
|
||||||
type: string;
|
sizeBytes: number;
|
||||||
status: string;
|
|
||||||
progress: number;
|
|
||||||
fileSizeBytes: number;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
completedAt: string;
|
origin: 'MANUAL' | 'AUTO' | 'UNKNOWN';
|
||||||
}>;
|
}>;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await api.get('/database/backup-list');
|
const response = await api.get('/database/backups');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteBackup: async (jobId: string): Promise<{ success: boolean; message: string }> => {
|
restoreFromFile: async (filename: string): Promise<{ success: boolean; message: string }> => {
|
||||||
const response = await api.delete(`/database/backup/${jobId}`);
|
const response = await api.post(`/database/backups/${encodeURIComponent(filename)}/restore`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
restoreComplete: async (file: File): Promise<{ success: boolean; message: string }> => {
|
downloadBackupFile: (filename: string): string => {
|
||||||
const formData = new FormData();
|
return `/api/database/backups/${encodeURIComponent(filename)}/download`;
|
||||||
formData.append('file', file);
|
},
|
||||||
const response = await api.post('/database/restore-complete', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
deleteBackupFile: async (filename: string): Promise<{ success: boolean; message: string }> => {
|
||||||
});
|
const response = await api.delete(`/database/backups/${encodeURIComponent(filename)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user