refactoring of backup/restore functionality

This commit is contained in:
Stefan Hardegger
2026-07-08 10:00:18 +02:00
parent 2a9b487cbb
commit 7e4e5a1385
13 changed files with 533 additions and 643 deletions

View File

@@ -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);
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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:

View File

@@ -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

View File

@@ -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>

View File

@@ -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