mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 14:23:42 +00:00
fix(dashboard): resolve edge cases in backup restore and settings UI
Backend: - Add atomic restore with mutex to prevent concurrent corruption - Implement rollback on failure for backup restore - Add symlink security validation Frontend: - Add 'backups' tab to URL validation in settings hook - Add error boundary for lazy-loaded settings sections - Clamp progress bar values to prevent visual bugs - Add AbortController cleanup to prevent memory leaks - Fix misleading debug mode description and add actual console logging
This commit is contained in:
@@ -15,6 +15,9 @@ interface BackupFile {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
/** Simple in-memory mutex for restore operations */
|
||||
let restoreLock = false;
|
||||
|
||||
/** Get Claude settings.json path */
|
||||
function getClaudeSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
@@ -85,6 +88,14 @@ router.get('/backups', (_req: Request, res: Response): void => {
|
||||
* Body: { timestamp?: string } - If not provided, restores latest
|
||||
*/
|
||||
router.post('/restore', (req: Request, res: Response): void => {
|
||||
// Check concurrent restore lock
|
||||
if (restoreLock) {
|
||||
res.status(409).json({ error: 'Restore already in progress' });
|
||||
return;
|
||||
}
|
||||
|
||||
restoreLock = true;
|
||||
|
||||
try {
|
||||
const { timestamp } = req.body;
|
||||
const backups = getBackupFiles();
|
||||
@@ -107,19 +118,6 @@ router.post('/restore', (req: Request, res: Response): void => {
|
||||
backup = found;
|
||||
}
|
||||
|
||||
// Validate backup JSON
|
||||
try {
|
||||
const content = fs.readFileSync(backup.path, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
res.status(400).json({ error: 'Backup file is corrupted' });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
res.status(400).json({ error: 'Backup file is corrupted or invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Security checks
|
||||
if (isSymlink(backup.path)) {
|
||||
res.status(400).json({ error: 'Backup file is a symlink - refusing for security' });
|
||||
@@ -132,16 +130,65 @@ router.post('/restore', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore
|
||||
fs.copyFileSync(backup.path, settingsPath);
|
||||
// Read and validate backup JSON (do this once atomically)
|
||||
let backupContent: string;
|
||||
try {
|
||||
backupContent = fs.readFileSync(backup.path, 'utf8');
|
||||
const parsed = JSON.parse(backupContent);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
res.status(400).json({ error: 'Backup file is corrupted' });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
res.status(400).json({ error: 'Backup file is corrupted or invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
timestamp: backup.timestamp,
|
||||
date: backup.date.toISOString(),
|
||||
});
|
||||
// Atomic restore with rollback capability
|
||||
const settingsDir = path.dirname(settingsPath);
|
||||
const tempPath = path.join(settingsDir, 'settings.json.restore-tmp');
|
||||
const backupPath = path.join(settingsDir, 'settings.json.rollback-tmp');
|
||||
|
||||
try {
|
||||
// Step 1: Backup current settings for rollback
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
fs.copyFileSync(settingsPath, backupPath);
|
||||
}
|
||||
|
||||
// Step 2: Write validated content to temp file
|
||||
fs.writeFileSync(tempPath, backupContent, 'utf8');
|
||||
|
||||
// Step 3: Atomic rename (replaces existing file)
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
// Step 4: Cleanup rollback backup on success
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
timestamp: backup.timestamp,
|
||||
date: backup.date.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Rollback on failure
|
||||
try {
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.renameSync(backupPath, settingsPath);
|
||||
}
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
} finally {
|
||||
restoreLock = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user