various fixes

This commit is contained in:
Stefan Hardegger
2025-08-11 08:15:20 +02:00
parent 5d195b63ef
commit 51e3d20c24
6 changed files with 520 additions and 71 deletions

View File

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

View File

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

View File

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