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

View File

@@ -40,13 +40,13 @@ export default function SettingsPage() {
const [authorsSchema, setAuthorsSchema] = useState<any>(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 };
completeBackup: { loading: boolean; message: string; success?: boolean };
completeRestore: { loading: boolean; message: string; success?: boolean };
completeClear: { loading: boolean; message: string; success?: boolean };
}>({
backup: { loading: false, message: '' },
restore: { loading: false, message: '' },
clear: { loading: false, message: '' }
completeBackup: { loading: false, message: '' },
completeRestore: { loading: false, message: '' },
completeClear: { loading: false, message: '' }
});
// Load settings from localStorage on mount
@@ -166,14 +166,15 @@ export default function SettingsPage() {
}
};
const handleDatabaseBackup = async () => {
const handleCompleteBackup = async () => {
setDatabaseStatus(prev => ({
...prev,
backup: { loading: true, message: 'Creating backup...', success: undefined }
completeBackup: { loading: true, message: 'Creating complete backup...', success: undefined }
}));
try {
const backupBlob = await databaseApi.backup();
const backupBlob = await databaseApi.backupComplete();
// Create download link
const url = window.URL.createObjectURL(backupBlob);
@@ -181,7 +182,7 @@ export default function SettingsPage() {
link.href = url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.download = `storycove_backup_${timestamp}.sql`;
link.download = `storycove_complete_backup_${timestamp}.zip`;
document.body.appendChild(link);
link.click();
@@ -190,12 +191,12 @@ export default function SettingsPage() {
setDatabaseStatus(prev => ({
...prev,
backup: { loading: false, message: 'Backup downloaded successfully', success: true }
completeBackup: { loading: false, message: 'Complete backup downloaded successfully', success: true }
}));
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
backup: { loading: false, message: error.message || 'Backup failed', success: false }
completeBackup: { loading: false, message: error.message || 'Complete backup failed', success: false }
}));
}
@@ -203,42 +204,42 @@ export default function SettingsPage() {
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
backup: { loading: false, message: '', success: undefined }
completeBackup: { loading: false, message: '', success: undefined }
}));
}, 5000);
};
const handleDatabaseRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleCompleteRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
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')) {
if (!file.name.endsWith('.zip')) {
setDatabaseStatus(prev => ({
...prev,
restore: { loading: false, message: 'Please select a .sql file', success: false }
completeRestore: { loading: false, message: 'Please select a .zip 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!'
'Are you sure you want to restore the complete backup? This will PERMANENTLY DELETE all current data AND files (cover images, avatars) and replace them with the backup data. This action cannot be undone!'
);
if (!confirmed) return;
setDatabaseStatus(prev => ({
...prev,
restore: { loading: true, message: 'Restoring database...', success: undefined }
completeRestore: { loading: true, message: 'Restoring complete backup...', success: undefined }
}));
try {
const result = await databaseApi.restore(file);
const result = await databaseApi.restoreComplete(file);
setDatabaseStatus(prev => ({
...prev,
restore: {
completeRestore: {
loading: false,
message: result.success ? result.message : result.message,
success: result.success
@@ -247,7 +248,7 @@ export default function SettingsPage() {
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
restore: { loading: false, message: error.message || 'Restore failed', success: false }
completeRestore: { loading: false, message: error.message || 'Complete restore failed', success: false }
}));
}
@@ -255,37 +256,37 @@ export default function SettingsPage() {
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
restore: { loading: false, message: '', success: undefined }
completeRestore: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
const handleDatabaseClear = async () => {
const handleCompleteClear = 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!'
'Are you ABSOLUTELY SURE you want to clear the entire database AND all files? This will PERMANENTLY DELETE ALL stories, authors, series, tags, collections, AND all uploaded images (covers, avatars). 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?'
'This is your final warning! Clicking OK will DELETE EVERYTHING in your StoryCove database AND all uploaded files. Are you completely certain you want to proceed?'
);
if (!doubleConfirmed) return;
setDatabaseStatus(prev => ({
...prev,
clear: { loading: true, message: 'Clearing database...', success: undefined }
completeClear: { loading: true, message: 'Clearing database and files...', success: undefined }
}));
try {
const result = await databaseApi.clear();
const result = await databaseApi.clearComplete();
setDatabaseStatus(prev => ({
...prev,
clear: {
completeClear: {
loading: false,
message: result.success
? `Database cleared successfully. Deleted ${result.deletedRecords} records.`
? `Database and files cleared successfully. Deleted ${result.deletedRecords} records.`
: result.message,
success: result.success
}
@@ -293,7 +294,7 @@ export default function SettingsPage() {
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
clear: { loading: false, message: error.message || 'Clear operation failed', success: false }
completeClear: { loading: false, message: error.message || 'Clear operation failed', success: false }
}));
}
@@ -301,7 +302,7 @@ export default function SettingsPage() {
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
clear: { loading: false, message: '', success: undefined }
completeClear: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
@@ -616,90 +617,90 @@ export default function SettingsPage() {
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Database Management</h2>
<p className="theme-text mb-6">
Backup, restore, or clear your StoryCove database. These are powerful tools - use with caution!
Backup, restore, or clear your StoryCove database and files. These comprehensive operations include both your data and uploaded images.
</p>
<div className="space-y-6">
{/* Backup Section */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">💾 Backup Database</h3>
{/* Complete Backup Section */}
<div className="border theme-border rounded-lg p-4 border-blue-200 dark:border-blue-800">
<h3 className="text-lg font-semibold theme-header mb-3">📦 Create Backup</h3>
<p className="text-sm theme-text mb-3">
Download a complete backup of your database as an SQL file. This includes all stories, authors, tags, series, and collections.
Download a complete backup as a ZIP file. This includes your database AND all uploaded files (cover images, avatars). This is a comprehensive backup of your entire StoryCove installation.
</p>
<Button
onClick={handleDatabaseBackup}
disabled={databaseStatus.backup.loading}
loading={databaseStatus.backup.loading}
onClick={handleCompleteBackup}
disabled={databaseStatus.completeBackup.loading}
loading={databaseStatus.completeBackup.loading}
variant="primary"
className="w-full sm:w-auto"
>
{databaseStatus.backup.loading ? 'Creating Backup...' : 'Download Backup'}
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Download Backup'}
</Button>
{databaseStatus.backup.message && (
{databaseStatus.completeBackup.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.backup.success
databaseStatus.completeBackup.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.backup.message}
{databaseStatus.completeBackup.message}
</div>
)}
</div>
{/* Restore Section */}
<div className="border theme-border rounded-lg p-4 border-orange-200 dark:border-orange-800">
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Database</h3>
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Backup</h3>
<p className="text-sm theme-text mb-3">
<strong className="text-orange-600 dark:text-orange-400">⚠️ Warning:</strong> This will completely replace your current database with the backup file. All existing data will be permanently deleted.
<strong className="text-orange-600 dark:text-orange-400">⚠️ Warning:</strong> This will completely replace your current database AND all files with the backup. All existing data and uploaded files will be permanently deleted.
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".sql"
onChange={handleDatabaseRestore}
disabled={databaseStatus.restore.loading}
accept=".zip"
onChange={handleCompleteRestore}
disabled={databaseStatus.completeRestore.loading}
className="flex-1 text-sm theme-text file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:theme-accent-bg file:text-white hover:file:bg-opacity-90 file:cursor-pointer"
/>
</div>
{databaseStatus.restore.message && (
{databaseStatus.completeRestore.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.restore.success
databaseStatus.completeRestore.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.restore.message}
{databaseStatus.completeRestore.message}
</div>
)}
{databaseStatus.restore.loading && (
{databaseStatus.completeRestore.loading && (
<div className="text-sm theme-text mt-3 flex items-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
Restoring database...
Restoring backup...
</div>
)}
</div>
{/* Clear Section */}
<div className="border theme-border rounded-lg p-4 border-red-200 dark:border-red-800">
<h3 className="text-lg font-semibold theme-header mb-3">🗑️ Clear Database</h3>
{/* Clear Everything Section */}
<div className="border theme-border rounded-lg p-4 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/10">
<h3 className="text-lg font-semibold theme-header mb-3">🗑️ Clear Everything</h3>
<p className="text-sm theme-text mb-3">
<strong className="text-red-600 dark:text-red-400">⚠️ Danger Zone:</strong> This will permanently delete ALL data from your database. Stories, authors, tags, series, and collections will be completely removed. This action cannot be undone!
<strong className="text-red-600 dark:text-red-400">⚠️ Danger Zone:</strong> This will permanently delete ALL data from your database AND all uploaded files (cover images, avatars). Everything will be completely removed. This action cannot be undone!
</p>
<Button
onClick={handleDatabaseClear}
disabled={databaseStatus.clear.loading}
loading={databaseStatus.clear.loading}
onClick={handleCompleteClear}
disabled={databaseStatus.completeClear.loading}
loading={databaseStatus.completeClear.loading}
variant="secondary"
className="w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white border-red-600"
className="w-full sm:w-auto bg-red-700 hover:bg-red-800 text-white border-red-700"
>
{databaseStatus.clear.loading ? 'Clearing Database...' : 'Clear All Data'}
{databaseStatus.completeClear.loading ? 'Clearing Everything...' : 'Clear Everything'}
</Button>
{databaseStatus.clear.message && (
{databaseStatus.completeClear.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.clear.success
databaseStatus.completeClear.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.clear.message}
{databaseStatus.completeClear.message}
</div>
)}
</div>
@@ -708,8 +709,9 @@ export default function SettingsPage() {
<p className="font-medium mb-1">💡 Best Practices:</p>
<ul className="text-xs space-y-1 ml-4">
<li>• <strong>Always backup</strong> before performing restore or clear operations</li>
<li>• <strong>Test restores</strong> in a development environment when possible</li>
<li>• <strong>Store backups safely</strong> in multiple locations for important data</li>
<li>• <strong>Test restores</strong> in a development environment when possible</li>
<li>• <strong>Backup files (.zip)</strong> contain both database and all uploaded files</li>
<li>• <strong>Verify backup files</strong> are complete before relying on them</li>
</ul>
</div>

View File

@@ -520,6 +520,27 @@ export const databaseApi = {
const response = await api.post('/database/clear');
return response.data;
},
backupComplete: async (): Promise<Blob> => {
const response = await api.post('/database/backup-complete', {}, {
responseType: 'blob'
});
return response.data;
},
restoreComplete: async (file: File): Promise<{ success: boolean; message: string }> => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/database/restore-complete', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
clearComplete: async (): Promise<{ success: boolean; message: string; deletedRecords?: number }> => {
const response = await api.post('/database/clear-complete');
return response.data;
},
};
// Image utility

File diff suppressed because one or more lines are too long