various fixes
This commit is contained in:
@@ -81,4 +81,74 @@ public class DatabaseController {
|
||||
.body(Map.of("success", false, "message", "Failed to clear database: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/backup-complete")
|
||||
public ResponseEntity<Resource> backupComplete() {
|
||||
try {
|
||||
Resource backup = databaseManagementService.createCompleteBackup();
|
||||
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
|
||||
String filename = "storycove_complete_backup_" + timestamp + ".zip";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.header(HttpHeaders.CONTENT_TYPE, "application/zip")
|
||||
.body(backup);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to create complete backup: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@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()));
|
||||
}
|
||||
}
|
||||
|
||||
@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",
|
||||
"deletedRecords", deletedRecords
|
||||
));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "Failed to clear database and files: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.storycove.entity.*;
|
||||
import com.storycove.repository.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -11,10 +13,14 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import javax.sql.DataSource;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
public class DatabaseManagementService {
|
||||
@@ -40,6 +46,96 @@ public class DatabaseManagementService {
|
||||
@Autowired
|
||||
private TypesenseService typesenseService;
|
||||
|
||||
@Autowired
|
||||
private ReadingPositionRepository readingPositionRepository;
|
||||
|
||||
@Value("${storycove.images.upload-dir:/app/images}")
|
||||
private String uploadDir;
|
||||
|
||||
/**
|
||||
* Create a comprehensive backup including database and files in ZIP format
|
||||
*/
|
||||
public Resource createCompleteBackup() throws SQLException, IOException {
|
||||
Path tempZip = Files.createTempFile("storycove-backup", ".zip");
|
||||
|
||||
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(tempZip))) {
|
||||
// 1. Add database dump
|
||||
addDatabaseDumpToZip(zipOut);
|
||||
|
||||
// 2. Add all image files
|
||||
addFilesToZip(zipOut);
|
||||
|
||||
// 3. Add metadata
|
||||
addMetadataToZip(zipOut);
|
||||
}
|
||||
|
||||
// Return the ZIP file as a resource
|
||||
byte[] zipData = Files.readAllBytes(tempZip);
|
||||
Files.deleteIfExists(tempZip);
|
||||
|
||||
return new ByteArrayResource(zipData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from complete backup (ZIP format)
|
||||
*/
|
||||
public void restoreFromCompleteBackup(InputStream backupStream) throws IOException, SQLException {
|
||||
System.err.println("Starting complete backup restore...");
|
||||
Path tempDir = Files.createTempDirectory("storycove-restore");
|
||||
System.err.println("Created temp directory: " + tempDir);
|
||||
|
||||
try {
|
||||
// 1. Extract ZIP to temp directory
|
||||
System.err.println("Extracting ZIP archive...");
|
||||
extractZipArchive(backupStream, tempDir);
|
||||
System.err.println("ZIP extraction completed.");
|
||||
|
||||
// 2. Validate backup structure
|
||||
System.err.println("Validating backup structure...");
|
||||
validateBackupStructure(tempDir);
|
||||
System.err.println("Backup structure validation completed.");
|
||||
|
||||
// 3. Clear existing data and files
|
||||
System.err.println("Clearing existing data and files...");
|
||||
clearAllDataAndFiles();
|
||||
System.err.println("Clear operation completed.");
|
||||
|
||||
// 4. Restore database
|
||||
Path databaseFile = tempDir.resolve("database.sql");
|
||||
if (Files.exists(databaseFile)) {
|
||||
System.err.println("Restoring database from SQL file...");
|
||||
try (InputStream sqlStream = Files.newInputStream(databaseFile)) {
|
||||
restoreFromBackup(sqlStream);
|
||||
}
|
||||
System.err.println("Database restore completed.");
|
||||
} else {
|
||||
System.err.println("Warning: No database.sql file found in backup.");
|
||||
}
|
||||
|
||||
// 5. Restore files
|
||||
Path filesDir = tempDir.resolve("files");
|
||||
if (Files.exists(filesDir)) {
|
||||
System.err.println("Restoring files...");
|
||||
restoreFiles(filesDir);
|
||||
System.err.println("File restore completed.");
|
||||
} else {
|
||||
System.err.println("No files directory found in backup - skipping file restore.");
|
||||
}
|
||||
|
||||
System.err.println("Complete backup restore finished successfully.");
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error during complete backup restore: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
throw e;
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
System.err.println("Cleaning up temp directory: " + tempDir);
|
||||
deleteDirectory(tempDir);
|
||||
System.err.println("Cleanup completed.");
|
||||
}
|
||||
}
|
||||
|
||||
public Resource createBackup() throws SQLException, IOException {
|
||||
StringBuilder sqlDump = new StringBuilder();
|
||||
|
||||
@@ -197,6 +293,9 @@ public class DatabaseManagementService {
|
||||
int seriesCount = (int) seriesRepository.count();
|
||||
int tagCount = (int) tagRepository.count();
|
||||
|
||||
// Clean up reading positions first (to avoid foreign key constraint violations)
|
||||
readingPositionRepository.deleteAll();
|
||||
|
||||
// Delete main entities (cascade will handle junction tables)
|
||||
collectionRepository.deleteAll();
|
||||
storyRepository.deleteAll();
|
||||
@@ -301,4 +400,259 @@ public class DatabaseManagementService {
|
||||
|
||||
return "'" + escapedValue + "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data AND files (for complete restore)
|
||||
*/
|
||||
@Transactional
|
||||
public int clearAllDataAndFiles() {
|
||||
// First clear the database
|
||||
int totalDeleted = clearAllData();
|
||||
|
||||
// Then clear all uploaded files
|
||||
clearAllFiles();
|
||||
|
||||
// Clear search indexes
|
||||
clearSearchIndexes();
|
||||
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all uploaded files
|
||||
*/
|
||||
private void clearAllFiles() {
|
||||
Path imagesPath = Paths.get(uploadDir);
|
||||
|
||||
if (Files.exists(imagesPath)) {
|
||||
try {
|
||||
Files.walk(imagesPath)
|
||||
.filter(Files::isRegularFile)
|
||||
.forEach(filePath -> {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException e) {
|
||||
System.err.println("Warning: Failed to delete file: " + filePath + " - " + e.getMessage());
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
System.err.println("Warning: Failed to clear files directory: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search indexes
|
||||
*/
|
||||
private void clearSearchIndexes() {
|
||||
try {
|
||||
System.err.println("Clearing search indexes after complete clear...");
|
||||
typesenseService.recreateStoriesCollection();
|
||||
typesenseService.recreateAuthorsCollection();
|
||||
// Note: Collections collection will be recreated when needed by the service
|
||||
System.err.println("Search indexes cleared successfully.");
|
||||
} catch (Exception e) {
|
||||
// Log the error but don't fail the clear operation
|
||||
System.err.println("Warning: Failed to clear search indexes: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add database dump to ZIP archive
|
||||
*/
|
||||
private void addDatabaseDumpToZip(ZipOutputStream zipOut) throws SQLException, IOException {
|
||||
Resource sqlBackup = createBackup();
|
||||
|
||||
ZipEntry sqlEntry = new ZipEntry("database.sql");
|
||||
zipOut.putNextEntry(sqlEntry);
|
||||
|
||||
try (InputStream sqlStream = sqlBackup.getInputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = sqlStream.read(buffer)) != -1) {
|
||||
zipOut.write(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all files to ZIP archive
|
||||
*/
|
||||
private void addFilesToZip(ZipOutputStream zipOut) throws IOException {
|
||||
Path imagesPath = Paths.get(uploadDir);
|
||||
|
||||
if (!Files.exists(imagesPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Files.walk(imagesPath)
|
||||
.filter(Files::isRegularFile)
|
||||
.forEach(filePath -> {
|
||||
try {
|
||||
Path relativePath = imagesPath.relativize(filePath);
|
||||
String zipEntryName = "files/" + relativePath.toString().replace('\\', '/');
|
||||
|
||||
ZipEntry entry = new ZipEntry(zipEntryName);
|
||||
zipOut.putNextEntry(entry);
|
||||
Files.copy(filePath, zipOut);
|
||||
zipOut.closeEntry();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to add file to backup: " + filePath, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add metadata to ZIP archive
|
||||
*/
|
||||
private void addMetadataToZip(ZipOutputStream zipOut) throws IOException, SQLException {
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
metadata.put("version", "1.0");
|
||||
metadata.put("format", "storycove-complete-backup");
|
||||
metadata.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
metadata.put("generator", "StoryCove Database Management Service");
|
||||
|
||||
// Add statistics
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
stats.put("stories", getTableCount(connection, "stories"));
|
||||
stats.put("authors", getTableCount(connection, "authors"));
|
||||
stats.put("collections", getTableCount(connection, "collections"));
|
||||
stats.put("tags", getTableCount(connection, "tags"));
|
||||
stats.put("series", getTableCount(connection, "series"));
|
||||
}
|
||||
metadata.put("statistics", stats);
|
||||
|
||||
// Count files
|
||||
Path imagesPath = Paths.get(uploadDir);
|
||||
int fileCount = 0;
|
||||
if (Files.exists(imagesPath)) {
|
||||
fileCount = (int) Files.walk(imagesPath).filter(Files::isRegularFile).count();
|
||||
}
|
||||
metadata.put("fileCount", fileCount);
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
String metadataJson = mapper.writeValueAsString(metadata);
|
||||
|
||||
ZipEntry metadataEntry = new ZipEntry("metadata.json");
|
||||
zipOut.putNextEntry(metadataEntry);
|
||||
zipOut.write(metadataJson.getBytes(StandardCharsets.UTF_8));
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ZIP archive to directory
|
||||
*/
|
||||
private void extractZipArchive(InputStream zipStream, Path targetDir) throws IOException {
|
||||
try (ZipInputStream zis = new ZipInputStream(zipStream)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
Path entryPath = targetDir.resolve(entry.getName());
|
||||
|
||||
// Security check: ensure the entry path is within the target directory
|
||||
if (!entryPath.normalize().startsWith(targetDir.normalize())) {
|
||||
throw new IOException("ZIP entry is outside of target directory: " + entry.getName());
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(entryPath);
|
||||
} else {
|
||||
Files.createDirectories(entryPath.getParent());
|
||||
Files.copy(zis, entryPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backup structure
|
||||
*/
|
||||
private void validateBackupStructure(Path backupDir) throws IOException {
|
||||
Path metadataFile = backupDir.resolve("metadata.json");
|
||||
Path databaseFile = backupDir.resolve("database.sql");
|
||||
|
||||
if (!Files.exists(metadataFile)) {
|
||||
throw new IOException("Invalid backup: metadata.json not found");
|
||||
}
|
||||
|
||||
if (!Files.exists(databaseFile)) {
|
||||
throw new IOException("Invalid backup: database.sql not found");
|
||||
}
|
||||
|
||||
// Validate metadata
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
Map<String, Object> metadata = mapper.readValue(Files.newInputStream(metadataFile), Map.class);
|
||||
|
||||
String format = (String) metadata.get("format");
|
||||
if (!"storycove-complete-backup".equals(format)) {
|
||||
throw new IOException("Invalid backup format: " + format);
|
||||
}
|
||||
|
||||
String version = (String) metadata.get("version");
|
||||
if (!"1.0".equals(version)) {
|
||||
throw new IOException("Unsupported backup version: " + version);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Failed to validate backup metadata: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore files from backup
|
||||
*/
|
||||
private void restoreFiles(Path filesDir) throws IOException {
|
||||
Path targetDir = Paths.get(uploadDir);
|
||||
Files.createDirectories(targetDir);
|
||||
|
||||
Files.walk(filesDir)
|
||||
.filter(Files::isRegularFile)
|
||||
.forEach(sourceFile -> {
|
||||
try {
|
||||
Path relativePath = filesDir.relativize(sourceFile);
|
||||
Path targetFile = targetDir.resolve(relativePath);
|
||||
|
||||
Files.createDirectories(targetFile.getParent());
|
||||
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to restore file: " + sourceFile, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete directory recursively
|
||||
*/
|
||||
private void deleteDirectory(Path directory) throws IOException {
|
||||
if (Files.exists(directory)) {
|
||||
Files.walk(directory)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(path -> {
|
||||
try {
|
||||
Files.delete(path);
|
||||
} catch (IOException e) {
|
||||
System.err.println("Warning: Failed to delete temp file: " + path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of records in a table
|
||||
*/
|
||||
private int getTableCount(Connection connection, String tableName) throws SQLException {
|
||||
try (PreparedStatement stmt = connection.prepareStatement("SELECT COUNT(*) FROM \"" + tableName + "\"");
|
||||
ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return rs.getInt(1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -445,7 +446,8 @@ public class StoryService {
|
||||
}
|
||||
|
||||
// Remove tags (this will update tag usage counts)
|
||||
story.getTags().forEach(tag -> story.removeTag(tag));
|
||||
// Create a copy to avoid ConcurrentModificationException
|
||||
new ArrayList<>(story.getTags()).forEach(tag -> story.removeTag(tag));
|
||||
|
||||
// Delete from Typesense first (if available)
|
||||
if (typesenseService != null) {
|
||||
|
||||
Reference in New Issue
Block a user