From 590e2590d675dcbc7a883e607a0e6f6583df4eda Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 31 Jul 2025 07:12:12 +0200 Subject: [PATCH] DB Backup and Restore --- .../com/storycove/config/SecurityConfig.java | 5 +- .../controller/DatabaseController.java | 84 ++++++ .../repository/AuthorRepository.java | 2 + .../repository/CollectionRepository.java | 2 + .../storycove/repository/StoryRepository.java | 2 + .../service/DatabaseManagementService.java | 196 ++++++++++++++ frontend/src/app/settings/page.tsx | 255 +++++++++++++++++- frontend/src/lib/api.ts | 24 ++ 8 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/storycove/controller/DatabaseController.java create mode 100644 backend/src/main/java/com/storycove/service/DatabaseManagementService.java diff --git a/backend/src/main/java/com/storycove/config/SecurityConfig.java b/backend/src/main/java/com/storycove/config/SecurityConfig.java index ac113ab..0365f01 100644 --- a/backend/src/main/java/com/storycove/config/SecurityConfig.java +++ b/backend/src/main/java/com/storycove/config/SecurityConfig.java @@ -56,7 +56,10 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins.split(","))); + List 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); diff --git a/backend/src/main/java/com/storycove/controller/DatabaseController.java b/backend/src/main/java/com/storycove/controller/DatabaseController.java new file mode 100644 index 0000000..494e82b --- /dev/null +++ b/backend/src/main/java/com/storycove/controller/DatabaseController.java @@ -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 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> 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> 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())); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/AuthorRepository.java b/backend/src/main/java/com/storycove/repository/AuthorRepository.java index e798cfb..51cdb32 100644 --- a/backend/src/main/java/com/storycove/repository/AuthorRepository.java +++ b/backend/src/main/java/com/storycove/repository/AuthorRepository.java @@ -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 { @Query(value = "SELECT author_rating FROM authors WHERE id = :id", nativeQuery = true) Integer findAuthorRatingById(@Param("id") UUID id); + } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/CollectionRepository.java b/backend/src/main/java/com/storycove/repository/CollectionRepository.java index 2d95c96..7584fb2 100644 --- a/backend/src/main/java/com/storycove/repository/CollectionRepository.java +++ b/backend/src/main/java/com/storycove/repository/CollectionRepository.java @@ -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 { */ @Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags ORDER BY c.updatedAt DESC") List findAllWithTags(); + } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/StoryRepository.java b/backend/src/main/java/com/storycove/repository/StoryRepository.java index 38c040d..cf7462f 100644 --- a/backend/src/main/java/com/storycove/repository/StoryRepository.java +++ b/backend/src/main/java/com/storycove/repository/StoryRepository.java @@ -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 { @Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)") List findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName); + } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/DatabaseManagementService.java b/backend/src/main/java/com/storycove/service/DatabaseManagementService.java new file mode 100644 index 0000000..457aae4 --- /dev/null +++ b/backend/src/main/java/com/storycove/service/DatabaseManagementService.java @@ -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 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; + } +} \ No newline at end of file diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 54d4471..317a00a 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import AppLayout from '../../components/layout/AppLayout'; import { useTheme } from '../../lib/theme'; import Button from '../../components/ui/Button'; -import { storyApi, authorApi } from '../../lib/api'; +import { storyApi, authorApi, databaseApi } from '../../lib/api'; type FontFamily = 'serif' | 'sans' | 'mono'; type FontSize = 'small' | 'medium' | 'large' | 'extra-large'; @@ -39,6 +39,15 @@ export default function SettingsPage() { }); const [authorsSchema, setAuthorsSchema] = useState(null); const [showSchema, setShowSchema] = useState(false); + const [databaseStatus, setDatabaseStatus] = useState<{ + backup: { loading: boolean; message: string; success?: boolean }; + restore: { loading: boolean; message: string; success?: boolean }; + clear: { loading: boolean; message: string; success?: boolean }; + }>({ + backup: { loading: false, message: '' }, + restore: { loading: false, message: '' }, + clear: { loading: false, message: '' } + }); // Load settings from localStorage on mount useEffect(() => { @@ -157,6 +166,146 @@ export default function SettingsPage() { } }; + const handleDatabaseBackup = async () => { + setDatabaseStatus(prev => ({ + ...prev, + backup: { loading: true, message: 'Creating backup...', success: undefined } + })); + + try { + const backupBlob = await databaseApi.backup(); + + // Create download link + const url = window.URL.createObjectURL(backupBlob); + const link = document.createElement('a'); + link.href = url; + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + link.download = `storycove_backup_${timestamp}.sql`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + setDatabaseStatus(prev => ({ + ...prev, + backup: { loading: false, message: 'Backup downloaded successfully', success: true } + })); + } catch (error: any) { + setDatabaseStatus(prev => ({ + ...prev, + backup: { loading: false, message: error.message || 'Backup failed', success: false } + })); + } + + // Clear message after 5 seconds + setTimeout(() => { + setDatabaseStatus(prev => ({ + ...prev, + backup: { loading: false, message: '', success: undefined } + })); + }, 5000); + }; + + const handleDatabaseRestore = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Reset the input so the same file can be selected again + event.target.value = ''; + + if (!file.name.endsWith('.sql')) { + setDatabaseStatus(prev => ({ + ...prev, + restore: { loading: false, message: 'Please select a .sql file', success: false } + })); + return; + } + + const confirmed = window.confirm( + 'Are you sure you want to restore the database? This will PERMANENTLY DELETE all current data and replace it with the backup data. This action cannot be undone!' + ); + + if (!confirmed) return; + + setDatabaseStatus(prev => ({ + ...prev, + restore: { loading: true, message: 'Restoring database...', success: undefined } + })); + + try { + const result = await databaseApi.restore(file); + setDatabaseStatus(prev => ({ + ...prev, + restore: { + loading: false, + message: result.success ? result.message : result.message, + success: result.success + } + })); + } catch (error: any) { + setDatabaseStatus(prev => ({ + ...prev, + restore: { loading: false, message: error.message || 'Restore failed', success: false } + })); + } + + // Clear message after 10 seconds for restore (longer because it's important) + setTimeout(() => { + setDatabaseStatus(prev => ({ + ...prev, + restore: { loading: false, message: '', success: undefined } + })); + }, 10000); + }; + + const handleDatabaseClear = async () => { + const confirmed = window.confirm( + 'Are you ABSOLUTELY SURE you want to clear the entire database? This will PERMANENTLY DELETE ALL stories, authors, series, tags, and collections. This action cannot be undone!' + ); + + if (!confirmed) return; + + const doubleConfirmed = window.confirm( + 'This is your final warning! Clicking OK will DELETE EVERYTHING in your StoryCove database. Are you completely certain you want to proceed?' + ); + + if (!doubleConfirmed) return; + + setDatabaseStatus(prev => ({ + ...prev, + clear: { loading: true, message: 'Clearing database...', success: undefined } + })); + + try { + const result = await databaseApi.clear(); + setDatabaseStatus(prev => ({ + ...prev, + clear: { + loading: false, + message: result.success + ? `Database cleared successfully. Deleted ${result.deletedRecords} records.` + : result.message, + success: result.success + } + })); + } catch (error: any) { + setDatabaseStatus(prev => ({ + ...prev, + clear: { loading: false, message: error.message || 'Clear operation failed', success: false } + })); + } + + // Clear message after 10 seconds for clear (longer because it's important) + setTimeout(() => { + setDatabaseStatus(prev => ({ + ...prev, + clear: { loading: false, message: '', success: undefined } + })); + }, 10000); + }; + return (
@@ -463,6 +612,110 @@ export default function SettingsPage() {
+ {/* Database Management */} +
+

Database Management

+

+ Backup, restore, or clear your StoryCove database. These are powerful tools - use with caution! +

+ +
+ {/* Backup Section */} +
+

💾 Backup Database

+

+ Download a complete backup of your database as an SQL file. This includes all stories, authors, tags, series, and collections. +

+ + {databaseStatus.backup.message && ( +
+ {databaseStatus.backup.message} +
+ )} +
+ + {/* Restore Section */} +
+

📥 Restore Database

+

+ ⚠️ Warning: This will completely replace your current database with the backup file. All existing data will be permanently deleted. +

+
+ +
+ {databaseStatus.restore.message && ( +
+ {databaseStatus.restore.message} +
+ )} + {databaseStatus.restore.loading && ( +
+
+ Restoring database... +
+ )} +
+ + {/* Clear Section */} +
+

🗑️ Clear Database

+

+ ⚠️ Danger Zone: This will permanently delete ALL data from your database. Stories, authors, tags, series, and collections will be completely removed. This action cannot be undone! +

+ + {databaseStatus.clear.message && ( +
+ {databaseStatus.clear.message} +
+ )} +
+ +
+

💡 Best Practices:

+
    +
  • Always backup before performing restore or clear operations
  • +
  • Test restores in a development environment when possible
  • +
  • Store backups safely in multiple locations for important data
  • +
  • Verify backup files are complete before relying on them
  • +
+
+
+
+ {/* Actions */}