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:
|
||||
|
||||
Reference in New Issue
Block a user