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: