DB Backup and Restore

This commit is contained in:
Stefan Hardegger
2025-07-31 07:12:12 +02:00
parent 57859d7a84
commit 590e2590d6
8 changed files with 568 additions and 2 deletions

View File

@@ -56,7 +56,10 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins.split(",")));
List<String> origins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.toList();
configuration.setAllowedOriginPatterns(origins);
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);

View File

@@ -0,0 +1,84 @@
package com.storycove.controller;
import com.storycove.service.DatabaseManagementService;
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.Map;
@RestController
@RequestMapping("/api/database")
public class DatabaseController {
@Autowired
private DatabaseManagementService databaseManagementService;
@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()));
}
}
}

View File

@@ -4,6 +4,7 @@ import com.storycove.entity.Author;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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;
@@ -52,4 +53,5 @@ public interface AuthorRepository extends JpaRepository<Author, UUID> {
@Query(value = "SELECT author_rating FROM authors WHERE id = :id", nativeQuery = true)
Integer findAuthorRatingById(@Param("id") UUID id);
}

View File

@@ -2,6 +2,7 @@ package com.storycove.repository;
import com.storycove.entity.Collection;
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;
@@ -51,4 +52,5 @@ public interface CollectionRepository extends JpaRepository<Collection, UUID> {
*/
@Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags ORDER BY c.updatedAt DESC")
List<Collection> findAllWithTags();
}

View File

@@ -7,6 +7,7 @@ import com.storycove.entity.Tag;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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;
@@ -117,4 +118,5 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName);
}

View File

@@ -0,0 +1,196 @@
package com.storycove.service;
import com.storycove.entity.*;
import com.storycove.repository.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.sql.DataSource;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.util.Arrays;
import java.util.List;
@Service
public class DatabaseManagementService {
@Autowired
private DataSource dataSource;
@Autowired
private StoryRepository storyRepository;
@Autowired
private AuthorRepository authorRepository;
@Autowired
private SeriesRepository seriesRepository;
@Autowired
private TagRepository tagRepository;
@Autowired
private CollectionRepository collectionRepository;
@Autowired
private TypesenseService typesenseService;
public Resource createBackup() throws SQLException, IOException {
StringBuilder sqlDump = new StringBuilder();
try (Connection connection = dataSource.getConnection()) {
// Add header
sqlDump.append("-- StoryCove Database Backup\n");
sqlDump.append("-- Generated at: ").append(new java.util.Date()).append("\n\n");
// Disable foreign key checks during restore (PostgreSQL syntax)
sqlDump.append("SET session_replication_role = replica;\n\n");
// List of tables in dependency order (children first for deletion, parents first for insertion)
List<String> tables = Arrays.asList(
"story_tags", "author_urls", "collection_stories",
"stories", "authors", "series", "tags", "collections"
);
// Generate TRUNCATE statements for each table (assuming tables already exist)
for (String tableName : tables) {
sqlDump.append("-- Table: ").append(tableName).append("\n");
sqlDump.append("TRUNCATE TABLE \"").append(tableName).append("\" CASCADE;\n");
// Get table data
try (PreparedStatement stmt = connection.prepareStatement("SELECT * FROM \"" + tableName + "\"");
ResultSet rs = stmt.executeQuery()) {
ResultSetMetaData metaData = rs.getMetaData();
int columnCount = metaData.getColumnCount();
// Build column names for INSERT statement
StringBuilder columnNames = new StringBuilder();
for (int i = 1; i <= columnCount; i++) {
if (i > 1) columnNames.append(", ");
columnNames.append("\"").append(metaData.getColumnName(i)).append("\"");
}
while (rs.next()) {
sqlDump.append("INSERT INTO \"").append(tableName).append("\" (")
.append(columnNames).append(") VALUES (");
for (int i = 1; i <= columnCount; i++) {
if (i > 1) sqlDump.append(", ");
Object value = rs.getObject(i);
if (value == null) {
sqlDump.append("NULL");
} else if (value instanceof String || value instanceof Timestamp ||
value instanceof java.util.UUID) {
// Escape single quotes and wrap in quotes
String escapedValue = value.toString().replace("'", "''");
sqlDump.append("'").append(escapedValue).append("'");
} else if (value instanceof Boolean) {
sqlDump.append(((Boolean) value) ? "true" : "false");
} else {
sqlDump.append(value.toString());
}
}
sqlDump.append(");\n");
}
}
sqlDump.append("\n");
}
// Re-enable foreign key checks (PostgreSQL syntax)
sqlDump.append("SET session_replication_role = DEFAULT;\n");
}
byte[] backupData = sqlDump.toString().getBytes(StandardCharsets.UTF_8);
return new ByteArrayResource(backupData);
}
@Transactional
public void restoreFromBackup(InputStream backupStream) throws IOException, SQLException {
// Read the SQL file
StringBuilder sqlContent = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(backupStream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
// Skip comments and empty lines
if (!line.trim().startsWith("--") && !line.trim().isEmpty()) {
sqlContent.append(line).append("\n");
}
}
}
// Execute the SQL statements
try (Connection connection = dataSource.getConnection()) {
connection.setAutoCommit(false);
try {
// Split by semicolon and execute each statement
String[] statements = sqlContent.toString().split(";");
for (String statement : statements) {
String trimmedStatement = statement.trim();
if (!trimmedStatement.isEmpty()) {
try (PreparedStatement stmt = connection.prepareStatement(trimmedStatement)) {
stmt.execute();
}
}
}
connection.commit();
// Reindex search after successful restore
try {
// Note: Manual reindexing may be needed after restore
// The search indices will need to be rebuilt through the settings page
} catch (Exception e) {
// Log the error but don't fail the restore
System.err.println("Warning: Search indices may need manual reindexing after restore");
}
} catch (SQLException e) {
connection.rollback();
throw e;
} finally {
connection.setAutoCommit(true);
}
}
}
@Transactional
public int clearAllData() {
int totalDeleted = 0;
try {
// Count entities before deletion
int collectionCount = (int) collectionRepository.count();
int storyCount = (int) storyRepository.count();
int authorCount = (int) authorRepository.count();
int seriesCount = (int) seriesRepository.count();
int tagCount = (int) tagRepository.count();
// Delete main entities (cascade will handle junction tables)
collectionRepository.deleteAll();
storyRepository.deleteAll();
authorRepository.deleteAll();
seriesRepository.deleteAll();
tagRepository.deleteAll();
totalDeleted = collectionCount + storyCount + authorCount + seriesCount + tagCount;
// Note: Search indexes will need to be manually recreated after clearing
// Use the settings page to recreate Typesense collections after clearing the database
} catch (Exception e) {
throw new RuntimeException("Failed to clear database: " + e.getMessage(), e);
}
return totalDeleted;
}
}