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 (
|
||||
id UUID PRIMARY KEY,
|
||||
library_id VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('DATABASE_ONLY', 'COMPLETE')),
|
||||
status VARCHAR(50) NOT NULL CHECK (status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED', 'EXPIRED')),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
file_path VARCHAR(1000),
|
||||
file_size_bytes BIGINT,
|
||||
progress_percent INTEGER,
|
||||
error_message VARCHAR(1000),
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
completed_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_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);
|
||||
""";
|
||||
|
||||
// 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
|
||||
public void run(String... args) throws Exception {
|
||||
logger.info("🗄️ Starting database migrations...");
|
||||
@@ -114,6 +119,11 @@ public class DatabaseMigrationRunner implements CommandLineRunner {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.service.AsyncBackupService;
|
||||
import com.storycove.service.BackupManagementService;
|
||||
import com.storycove.service.DatabaseManagementService;
|
||||
import com.storycove.service.LibraryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/database")
|
||||
@@ -27,69 +24,15 @@ public class DatabaseController {
|
||||
@Autowired
|
||||
private AsyncBackupService asyncBackupService;
|
||||
|
||||
@Autowired
|
||||
private BackupManagementService backupManagementService;
|
||||
|
||||
@Autowired
|
||||
private LibraryService libraryService;
|
||||
|
||||
@PostMapping("/backup")
|
||||
public ResponseEntity<Resource> backupDatabase() {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// Backup creation (async, with progress polling)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/backup-complete")
|
||||
public ResponseEntity<Map<String, Object>> backupCompleteAsync() {
|
||||
@@ -100,7 +43,6 @@ public class DatabaseController {
|
||||
.body(Map.of("success", false, "message", "No library selected"));
|
||||
}
|
||||
|
||||
// Start backup job asynchronously
|
||||
com.storycove.entity.BackupJob job = asyncBackupService.startBackupJob(
|
||||
libraryId,
|
||||
com.storycove.entity.BackupJob.BackupType.COMPLETE
|
||||
@@ -146,132 +88,79 @@ public class DatabaseController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/backup-download/{jobId}")
|
||||
public ResponseEntity<Resource> downloadBackup(@PathVariable String jobId) {
|
||||
try {
|
||||
java.util.UUID uuid = java.util.UUID.fromString(jobId);
|
||||
Resource backup = asyncBackupService.getBackupFile(uuid);
|
||||
// -------------------------------------------------------------------------
|
||||
// Backup file management (filesystem-based)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
java.util.Optional<com.storycove.entity.BackupJob> jobOpt = asyncBackupService.getJobStatus(uuid);
|
||||
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")
|
||||
@GetMapping("/backups")
|
||||
public ResponseEntity<Map<String, Object>> listBackups() {
|
||||
try {
|
||||
String libraryId = libraryService.getCurrentLibraryId();
|
||||
if (libraryId == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "No library selected"));
|
||||
}
|
||||
List<BackupManagementService.BackupFileInfo> backups = backupManagementService.listBackups();
|
||||
|
||||
List<com.storycove.entity.BackupJob> jobs = asyncBackupService.listBackupJobs(libraryId);
|
||||
|
||||
List<Map<String, Object>> jobsList = jobs.stream()
|
||||
.map(job -> {
|
||||
Map<String, Object> jobMap = new java.util.HashMap<>();
|
||||
jobMap.put("jobId", job.getId().toString());
|
||||
jobMap.put("type", job.getType().toString());
|
||||
jobMap.put("status", job.getStatus().toString());
|
||||
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;
|
||||
List<Map<String, Object>> backupList = backups.stream()
|
||||
.map(b -> {
|
||||
Map<String, Object> m = new java.util.HashMap<>();
|
||||
m.put("filename", b.getFilename());
|
||||
m.put("sizeBytes", b.getSizeBytes());
|
||||
m.put("createdAt", b.getCreatedAt().toString());
|
||||
m.put("origin", b.getOrigin().toString());
|
||||
return m;
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"backups", jobsList
|
||||
));
|
||||
return ResponseEntity.ok(Map.of("success", true, "backups", backupList));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "Failed to list backups: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/backup/{jobId}")
|
||||
public ResponseEntity<Map<String, Object>> deleteBackup(@PathVariable String jobId) {
|
||||
@PostMapping("/backups/{filename}/restore")
|
||||
public ResponseEntity<Map<String, Object>> restoreFromFile(@PathVariable String filename) {
|
||||
try {
|
||||
java.util.UUID uuid = java.util.UUID.fromString(jobId);
|
||||
asyncBackupService.deleteBackupJob(uuid);
|
||||
|
||||
backupManagementService.restoreFromFile(filename);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Backup deleted successfully"
|
||||
"message", "Restored successfully from " + filename
|
||||
));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "Invalid job ID"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.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) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.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) {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// Clear operations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/clear-complete")
|
||||
public ResponseEntity<Map<String, Object>> clearComplete() {
|
||||
try {
|
||||
int deletedRecords = databaseManagementService.clearAllDataAndFiles();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"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()));
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Column
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
// Backups expire after 24 hours
|
||||
expiresAt = LocalDateTime.now().plusDays(1);
|
||||
}
|
||||
|
||||
// Enums
|
||||
@@ -64,8 +59,7 @@ public class BackupJob {
|
||||
PENDING,
|
||||
IN_PROGRESS,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
EXPIRED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// Constructors
|
||||
@@ -168,19 +162,7 @@ public class BackupJob {
|
||||
this.completedAt = completedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
public boolean isExpired() {
|
||||
return LocalDateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
public boolean isCompleted() {
|
||||
return status == BackupStatus.COMPLETED;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,8 @@ package com.storycove.repository;
|
||||
|
||||
import com.storycove.entity.BackupJob;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -15,11 +11,4 @@ import java.util.UUID;
|
||||
public interface BackupJobRepository extends JpaRepository<BackupJob, UUID> {
|
||||
|
||||
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.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -27,9 +28,10 @@ import java.util.UUID;
|
||||
public class AsyncBackupExecutor {
|
||||
|
||||
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}")
|
||||
private String uploadDir;
|
||||
@Value("${storycove.backup.dir:/app/backups}")
|
||||
private String backupDir;
|
||||
|
||||
@Autowired
|
||||
private BackupJobRepository backupJobRepository;
|
||||
@@ -69,16 +71,13 @@ public class AsyncBackupExecutor {
|
||||
libraryService.switchToLibraryAfterAuthentication(job.getLibraryId());
|
||||
}
|
||||
|
||||
// Create backup file
|
||||
Path backupDir = Paths.get(uploadDir, "backups", job.getLibraryId());
|
||||
Files.createDirectories(backupDir);
|
||||
// Create backup file in the unified backup directory
|
||||
Path libraryBackupDir = Paths.get(backupDir, job.getLibraryId());
|
||||
Files.createDirectories(libraryBackupDir);
|
||||
|
||||
String filename = String.format("backup_%s_%s.%s",
|
||||
job.getId().toString(),
|
||||
LocalDateTime.now().toString().replaceAll(":", "-"),
|
||||
job.getType() == BackupJob.BackupType.COMPLETE ? "zip" : "sql");
|
||||
|
||||
Path backupFile = backupDir.resolve(filename);
|
||||
String timestamp = LocalDateTime.now().format(FILENAME_FORMATTER);
|
||||
String filename = String.format("manual_backup_%s.zip", timestamp);
|
||||
Path backupFile = libraryBackupDir.resolve(filename);
|
||||
|
||||
job.setProgressPercent(10);
|
||||
backupJobRepository.save(job);
|
||||
@@ -94,7 +93,7 @@ public class AsyncBackupExecutor {
|
||||
job.setProgressPercent(80);
|
||||
backupJobRepository.save(job);
|
||||
|
||||
// Copy resource to permanent file
|
||||
// Write backup to file
|
||||
try (var inputStream = backupResource.getInputStream();
|
||||
var outputStream = Files.newOutputStream(backupFile)) {
|
||||
inputStream.transferTo(outputStream);
|
||||
@@ -110,8 +109,8 @@ public class AsyncBackupExecutor {
|
||||
job.setCompletedAt(LocalDateTime.now());
|
||||
job.setProgressPercent(100);
|
||||
|
||||
logger.info("Backup job {} completed successfully. File size: {} bytes",
|
||||
job.getId(), job.getFileSizeBytes());
|
||||
logger.info("Backup job {} completed successfully. File: {}, Size: {} bytes",
|
||||
job.getId(), filename, job.getFileSizeBytes());
|
||||
|
||||
} catch (Exception 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.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.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
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.UUID;
|
||||
|
||||
@@ -26,9 +17,6 @@ public class AsyncBackupService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AsyncBackupService.class);
|
||||
|
||||
@Value("${storycove.upload.dir:/app/images}")
|
||||
private String uploadDir;
|
||||
|
||||
@Autowired
|
||||
private BackupJobRepository backupJobRepository;
|
||||
|
||||
@@ -37,7 +25,7 @@ public class AsyncBackupService {
|
||||
|
||||
/**
|
||||
* Start a backup job asynchronously.
|
||||
* This method returns immediately after creating the job record.
|
||||
* Returns immediately after creating the job record.
|
||||
*/
|
||||
@Transactional
|
||||
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());
|
||||
|
||||
// Start backup in background using separate service (ensures @Async works properly)
|
||||
asyncBackupExecutor.executeBackupAsync(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) {
|
||||
return backupJobRepository.findById(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup file for download
|
||||
*/
|
||||
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
|
||||
* Delete a stale or failed backup job record.
|
||||
* The actual file (if any) is managed by BackupManagementService.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteBackupJob(UUID jobId) throws IOException {
|
||||
@@ -149,19 +61,7 @@ public class AsyncBackupService {
|
||||
throw new IOException("Backup job not found");
|
||||
}
|
||||
|
||||
BackupJob job = jobOpt.get();
|
||||
|
||||
// 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);
|
||||
backupJobRepository.delete(jobOpt.get());
|
||||
logger.info("Deleted backup job record: {}", jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.stream.Stream;
|
||||
/**
|
||||
* Service for automatic daily backups.
|
||||
* 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
|
||||
public class AutomaticBackupService {
|
||||
@@ -32,8 +32,8 @@ public class AutomaticBackupService {
|
||||
private static final int MAX_BACKUPS = 10;
|
||||
private static final DateTimeFormatter FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
|
||||
|
||||
@Value("${storycove.automatic-backup.dir:/app/automatic-backups}")
|
||||
private String automaticBackupDir;
|
||||
@Value("${storycove.backup.dir:/app/backups}")
|
||||
private String backupDir;
|
||||
|
||||
@Autowired
|
||||
private StoryRepository storyRepository;
|
||||
@@ -57,7 +57,6 @@ public class AutomaticBackupService {
|
||||
logger.info("========================================");
|
||||
|
||||
try {
|
||||
// Get current library ID (or default)
|
||||
String libraryId = libraryService.getCurrentLibraryId();
|
||||
if (libraryId == null) {
|
||||
libraryId = "default";
|
||||
@@ -65,7 +64,6 @@ public class AutomaticBackupService {
|
||||
|
||||
logger.info("Checking for content changes in library: {}", libraryId);
|
||||
|
||||
// Check if content has changed since last backup
|
||||
if (!hasContentChanged()) {
|
||||
logger.info("No content changes detected since last backup. Skipping backup.");
|
||||
logger.info("========================================");
|
||||
@@ -74,34 +72,29 @@ public class AutomaticBackupService {
|
||||
|
||||
logger.info("Content changes detected! Creating automatic backup...");
|
||||
|
||||
// Create backup directory for this library
|
||||
Path backupPath = Paths.get(automaticBackupDir, libraryId);
|
||||
Files.createDirectories(backupPath);
|
||||
Path libraryBackupDir = Paths.get(backupDir, libraryId);
|
||||
Files.createDirectories(libraryBackupDir);
|
||||
|
||||
// Create the backup
|
||||
String timestamp = LocalDateTime.now().format(FILENAME_FORMATTER);
|
||||
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);
|
||||
|
||||
Resource backup = databaseManagementService.createCompleteBackup();
|
||||
|
||||
// Write backup to file
|
||||
try (var inputStream = backup.getInputStream();
|
||||
var outputStream = Files.newOutputStream(backupFile)) {
|
||||
inputStream.transferTo(outputStream);
|
||||
}
|
||||
|
||||
long fileSize = Files.size(backupFile);
|
||||
logger.info("✅ Automatic backup created successfully");
|
||||
logger.info("Automatic backup created successfully");
|
||||
logger.info(" File: {}", backupFile.getFileName());
|
||||
logger.info(" Size: {} MB", fileSize / 1024 / 1024);
|
||||
|
||||
// Rotate old backups (keep only MAX_BACKUPS)
|
||||
rotateBackups(backupPath);
|
||||
rotateAutoBackups(libraryBackupDir);
|
||||
|
||||
// Update last backup check time
|
||||
lastBackupCheck = LocalDateTime.now();
|
||||
|
||||
logger.info("========================================");
|
||||
@@ -109,104 +102,39 @@ public class AutomaticBackupService {
|
||||
logger.info("========================================");
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ Automatic backup failed", e);
|
||||
logger.error("Automatic backup failed", e);
|
||||
logger.info("========================================");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content has changed since last backup.
|
||||
* Looks for stories created or updated after the last backup time.
|
||||
*/
|
||||
private boolean hasContentChanged() {
|
||||
try {
|
||||
if (lastBackupCheck == null) {
|
||||
// First run - check if there are any stories at all
|
||||
long storyCount = storyRepository.count();
|
||||
logger.info("First backup check - found {} stories", storyCount);
|
||||
return storyCount > 0;
|
||||
}
|
||||
|
||||
// Check for stories created or updated since last backup
|
||||
long changedCount = storyRepository.countStoriesModifiedAfter(lastBackupCheck);
|
||||
logger.info("Found {} stories modified since last backup ({})", changedCount, lastBackupCheck);
|
||||
return changedCount > 0;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error checking for content changes", e);
|
||||
// On error, create backup to be safe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate backups - keep only MAX_BACKUPS most recent backups.
|
||||
* Deletes older backups.
|
||||
* Rotate auto backups — keep only MAX_BACKUPS most recent auto_backup_*.zip files.
|
||||
* Manual backups are never touched by rotation.
|
||||
*/
|
||||
private void rotateBackups(Path backupPath) throws IOException {
|
||||
logger.info("Checking for old backups to rotate...");
|
||||
private void rotateAutoBackups(Path libraryBackupDir) throws IOException {
|
||||
logger.info("Checking for old automatic backups to rotate...");
|
||||
|
||||
// Find all backup files in the directory
|
||||
List<Path> backupFiles;
|
||||
try (Stream<Path> stream = Files.list(backupPath)) {
|
||||
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
|
||||
List<Path> autoBackupFiles;
|
||||
try (Stream<Path> stream = Files.list(libraryBackupDir)) {
|
||||
autoBackupFiles = stream
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().startsWith("auto_backup_"))
|
||||
.filter(p -> p.getFileName().toString().endsWith(".zip"))
|
||||
@@ -217,46 +145,30 @@ public class AutomaticBackupService {
|
||||
return null;
|
||||
}
|
||||
}).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());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple backup info class.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
public void triggerManualBackup() {
|
||||
logger.info("Manual automatic backup triggered");
|
||||
performAutomaticBackup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
images:
|
||||
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
|
||||
automatic-backup:
|
||||
dir: ${AUTOMATIC_BACKUP_DIR:/app/automatic-backups}
|
||||
backup:
|
||||
dir: ${BACKUP_DIR:/app/backups}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
volumes:
|
||||
- /volume1/docker/storycove/images:/app/images
|
||||
- /volume1/docker/storycove/config:/app/config
|
||||
- /volume1/docker/storycove/backups:/app/automatic-backups
|
||||
- /volume1/docker/storycove/backups:/app/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -39,15 +39,25 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
success?: boolean;
|
||||
jobId?: string;
|
||||
progress?: number;
|
||||
downloadReady?: boolean;
|
||||
};
|
||||
completeRestore: { loading: boolean; message: string; success?: boolean };
|
||||
completeClear: { loading: boolean; message: string; success?: boolean };
|
||||
}>({
|
||||
completeBackup: { loading: false, message: '', progress: 0 },
|
||||
completeRestore: { 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<{
|
||||
preview: { loading: boolean; message: string; success?: boolean; data?: any };
|
||||
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 [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) => {
|
||||
// Convert backend file path to frontend image URL
|
||||
const imageUrl = filePath.replace(/^.*\/images\//, '/images/');
|
||||
@@ -88,11 +112,10 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
const handleCompleteBackup = async () => {
|
||||
setDatabaseStatus(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 {
|
||||
// Start the async backup job
|
||||
const startResponse = await databaseApi.backupComplete();
|
||||
const jobId = startResponse.jobId;
|
||||
|
||||
@@ -101,7 +124,6 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
completeBackup: { ...prev.completeBackup, jobId, message: 'Backup in progress...' }
|
||||
}));
|
||||
|
||||
// Poll for progress
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const status = await databaseApi.getBackupStatus(jobId);
|
||||
@@ -110,147 +132,79 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
clearInterval(pollInterval);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: 'Backup completed! Ready to download.',
|
||||
success: true,
|
||||
jobId,
|
||||
progress: 100,
|
||||
downloadReady: true
|
||||
}
|
||||
completeBackup: { loading: false, message: 'Backup created successfully.', success: true, jobId, progress: 100 }
|
||||
}));
|
||||
|
||||
// Clear message after 30 seconds (keep download button visible)
|
||||
loadBackups();
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { ...prev.completeBackup, message: '' }
|
||||
}));
|
||||
}, 30000);
|
||||
setDatabaseStatus(prev => ({ ...prev, completeBackup: { ...prev.completeBackup, message: '' } }));
|
||||
}, 8000);
|
||||
} else if (status.status === 'FAILED') {
|
||||
clearInterval(pollInterval);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: `Backup failed: ${status.errorMessage}`,
|
||||
success: false,
|
||||
progress: 0,
|
||||
downloadReady: false
|
||||
}
|
||||
completeBackup: { loading: false, message: `Backup failed: ${status.errorMessage}`, success: false, progress: 0 }
|
||||
}));
|
||||
} else {
|
||||
// Update progress
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
...prev.completeBackup,
|
||||
progress: status.progress,
|
||||
message: `Creating backup... ${status.progress}%`
|
||||
}
|
||||
completeBackup: { ...prev.completeBackup, progress: status.progress, message: `Creating backup... ${status.progress}%` }
|
||||
}));
|
||||
}
|
||||
} catch (pollError: any) {
|
||||
clearInterval(pollInterval);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: `Failed to check backup status: ${pollError.message}`,
|
||||
success: false,
|
||||
progress: 0,
|
||||
downloadReady: false
|
||||
}
|
||||
completeBackup: { loading: false, message: `Failed to check backup status: ${pollError.message}`, success: false, progress: 0 }
|
||||
}));
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
}, 2000);
|
||||
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: error.message || 'Failed to start backup',
|
||||
success: false,
|
||||
progress: 0,
|
||||
downloadReady: false
|
||||
}
|
||||
completeBackup: { loading: false, message: error.message || 'Failed to start backup', success: false, progress: 0 }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackup = (jobId: string) => {
|
||||
const downloadUrl = databaseApi.downloadBackup(jobId);
|
||||
const handleRestoreFromFile = async (filename: string) => {
|
||||
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');
|
||||
link.href = downloadUrl;
|
||||
link.download = ''; // Filename will be set by server
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
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 file = event.target.files?.[0];
|
||||
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!'
|
||||
);
|
||||
|
||||
const handleDeleteBackupFile = async (filename: string) => {
|
||||
const confirmed = window.confirm(`Delete backup "${filename}"? This cannot be undone.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeRestore: { loading: true, message: 'Restoring complete backup...', success: undefined }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await databaseApi.restoreComplete(file);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeRestore: {
|
||||
loading: false,
|
||||
message: result.success ? result.message : result.message,
|
||||
success: result.success
|
||||
}
|
||||
}));
|
||||
await databaseApi.deleteBackupFile(filename);
|
||||
loadBackups();
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeRestore: { loading: false, message: error.message || 'Complete restore failed', success: false }
|
||||
}));
|
||||
alert(`Failed to delete backup: ${error.message}`);
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
@@ -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">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">📦 Create Backup</h3>
|
||||
<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>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleCompleteBackup}
|
||||
disabled={databaseStatus.completeBackup.loading || databaseStatus.completeBackup.downloadReady}
|
||||
loading={databaseStatus.completeBackup.loading}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Create Backup'}
|
||||
</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>
|
||||
<Button
|
||||
onClick={handleCompleteBackup}
|
||||
disabled={databaseStatus.completeBackup.loading}
|
||||
loading={databaseStatus.completeBackup.loading}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Create Backup'}
|
||||
</Button>
|
||||
|
||||
{databaseStatus.completeBackup.loading && databaseStatus.completeBackup.progress !== undefined && (
|
||||
<div className="mt-3">
|
||||
@@ -1181,34 +1123,117 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore Section */}
|
||||
<div className="border theme-border rounded-lg p-4 border-orange-200 dark:border-orange-800">
|
||||
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Backup</h3>
|
||||
<p className="text-sm theme-text mb-3">
|
||||
<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.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleCompleteRestore}
|
||||
disabled={databaseStatus.completeRestore.loading}
|
||||
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"
|
||||
/>
|
||||
{/* Backup List / Restore Section */}
|
||||
<div className="border theme-border rounded-lg p-4 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold theme-header">📋 Backups</h3>
|
||||
<button
|
||||
onClick={loadBackups}
|
||||
disabled={backupList.loading}
|
||||
className="text-sm theme-text opacity-70 hover:opacity-100 flex items-center gap-1"
|
||||
title="Refresh backup list"
|
||||
>
|
||||
<span className={backupList.loading ? 'animate-spin' : ''}>↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{databaseStatus.completeRestore.message && (
|
||||
<div className={`text-sm p-2 rounded mt-3 ${
|
||||
databaseStatus.completeRestore.success
|
||||
<p className="text-sm theme-text mb-4">
|
||||
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.
|
||||
</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-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>
|
||||
)}
|
||||
{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>
|
||||
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>
|
||||
@@ -1243,10 +1268,9 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
<p className="font-medium mb-1">💡 Best Practices:</p>
|
||||
<ul className="text-xs space-y-1 ml-4">
|
||||
<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>Test restores</strong> in a development environment when possible</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>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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1043,27 +1043,12 @@ export const collectionApi = {
|
||||
|
||||
// Database management endpoints
|
||||
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 }> => {
|
||||
const response = await api.post('/database/clear');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Async backup creation
|
||||
backupComplete: async (): Promise<{ success: boolean; jobId: string; status: string; message: string }> => {
|
||||
const response = await api.post('/database/backup-complete');
|
||||
return response.data;
|
||||
@@ -1083,37 +1068,31 @@ export const databaseApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
downloadBackup: (jobId: string): string => {
|
||||
return `/api/database/backup-download/${jobId}`;
|
||||
},
|
||||
|
||||
listBackups: async (): Promise<{
|
||||
// Filesystem-based backup management
|
||||
listBackupFiles: async (): Promise<{
|
||||
success: boolean;
|
||||
backups: Array<{
|
||||
jobId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
fileSizeBytes: number;
|
||||
filename: string;
|
||||
sizeBytes: number;
|
||||
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;
|
||||
},
|
||||
|
||||
deleteBackup: async (jobId: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await api.delete(`/database/backup/${jobId}`);
|
||||
restoreFromFile: async (filename: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await api.post(`/database/backups/${encodeURIComponent(filename)}/restore`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
restoreComplete: async (file: File): Promise<{ success: boolean; message: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await api.post('/database/restore-complete', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
downloadBackupFile: (filename: string): string => {
|
||||
return `/api/database/backups/${encodeURIComponent(filename)}/download`;
|
||||
},
|
||||
|
||||
deleteBackupFile: async (filename: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await api.delete(`/database/backups/${encodeURIComponent(filename)}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user