-
Notifications
You must be signed in to change notification settings - Fork 2
fixed feat/issue-124 #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fixed feat/issue-124 #233
Changes from all commits
f67613b
2c3c8b9
956b81d
28b9839
579160e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,18 +5,7 @@ | |||||||||||||||||||||||||||||||||||
| * @see https://github.com/VolvoxLLC/volvox-bot/issues/129 | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||
| existsSync, | ||||||||||||||||||||||||||||||||||||
| mkdirSync, | ||||||||||||||||||||||||||||||||||||
| readdirSync, | ||||||||||||||||||||||||||||||||||||
| readFileSync, | ||||||||||||||||||||||||||||||||||||
| statSync, | ||||||||||||||||||||||||||||||||||||
| unlinkSync, | ||||||||||||||||||||||||||||||||||||
| writeFileSync, | ||||||||||||||||||||||||||||||||||||
| } from 'node:fs'; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // TODO: Consider switching to fs.promises for async operations to improve performance | ||||||||||||||||||||||||||||||||||||
| // and avoid blocking the event loop with synchronous file system operations. | ||||||||||||||||||||||||||||||||||||
| import { access, mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; | ||||||||||||||||||||||||||||||||||||
| import path from 'node:path'; | ||||||||||||||||||||||||||||||||||||
| import { fileURLToPath } from 'node:url'; | ||||||||||||||||||||||||||||||||||||
| import { SAFE_CONFIG_KEYS, SENSITIVE_FIELDS } from '../api/utils/configAllowlist.js'; | ||||||||||||||||||||||||||||||||||||
|
|
@@ -45,12 +34,14 @@ let scheduledBackupInterval = null; | |||||||||||||||||||||||||||||||||||
| * Get or create the backup directory. | ||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * @param {string} [dir] - Override backup directory path | ||||||||||||||||||||||||||||||||||||
| * @returns {string} The backup directory path | ||||||||||||||||||||||||||||||||||||
| * @returns {Promise<string>} The backup directory path | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export function getBackupDir(dir) { | ||||||||||||||||||||||||||||||||||||
| export async function getBackupDir(dir) { | ||||||||||||||||||||||||||||||||||||
| const backupDir = dir ?? DEFAULT_BACKUP_DIR; | ||||||||||||||||||||||||||||||||||||
| if (!existsSync(backupDir)) { | ||||||||||||||||||||||||||||||||||||
| mkdirSync(backupDir, { recursive: true }); | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| await access(backupDir); | ||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||
| await mkdir(backupDir, { recursive: true }); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return backupDir; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
@@ -192,20 +183,20 @@ function makeBackupFilename(date = new Date()) { | |||||||||||||||||||||||||||||||||||
| * Create a timestamped backup of the current config in the backup directory. | ||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * @param {string} [backupDir] - Override backup directory | ||||||||||||||||||||||||||||||||||||
| * @returns {{id: string, path: string, size: number, createdAt: string}} Backup metadata | ||||||||||||||||||||||||||||||||||||
| * @returns {Promise<{id: string, path: string, size: number, createdAt: string}>} Backup metadata | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export function createBackup(backupDir) { | ||||||||||||||||||||||||||||||||||||
| const dir = getBackupDir(backupDir); | ||||||||||||||||||||||||||||||||||||
| export async function createBackup(backupDir) { | ||||||||||||||||||||||||||||||||||||
| const dir = await getBackupDir(backupDir); | ||||||||||||||||||||||||||||||||||||
| const now = new Date(); | ||||||||||||||||||||||||||||||||||||
| const filename = makeBackupFilename(now); | ||||||||||||||||||||||||||||||||||||
| const filePath = path.join(dir, filename); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const payload = exportConfig(); | ||||||||||||||||||||||||||||||||||||
| const json = JSON.stringify(payload, null, 2); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| writeFileSync(filePath, json, 'utf8'); | ||||||||||||||||||||||||||||||||||||
| await writeFile(filePath, json, 'utf8'); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const { size } = statSync(filePath); | ||||||||||||||||||||||||||||||||||||
| const { size } = await stat(filePath); | ||||||||||||||||||||||||||||||||||||
| const id = filename.replace('.json', ''); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| info('Config backup created', { id, path: filePath, size }); | ||||||||||||||||||||||||||||||||||||
|
|
@@ -223,16 +214,17 @@ export function createBackup(backupDir) { | |||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * @param {string} filename - Backup filename | ||||||||||||||||||||||||||||||||||||
| * @param {string} dir - Directory containing the backup file | ||||||||||||||||||||||||||||||||||||
| * @returns {{id: string, filename: string, createdAt: string, size: number} | null} | ||||||||||||||||||||||||||||||||||||
| * @returns {Promise<{id: string, filename: string, createdAt: string, size: number} | null>} | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| function parseBackupMeta(filename, dir) { | ||||||||||||||||||||||||||||||||||||
| async function parseBackupMeta(filename, dir) { | ||||||||||||||||||||||||||||||||||||
| const match = BACKUP_FILENAME_PATTERN.exec(filename); | ||||||||||||||||||||||||||||||||||||
| if (!match) return null; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const filePath = path.join(dir, filename); | ||||||||||||||||||||||||||||||||||||
| let size = 0; | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| size = statSync(filePath).size; | ||||||||||||||||||||||||||||||||||||
| const st = await stat(filePath); | ||||||||||||||||||||||||||||||||||||
| size = st.size; | ||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
@@ -252,22 +244,20 @@ function parseBackupMeta(filename, dir) { | |||||||||||||||||||||||||||||||||||
| * List all available backups, sorted newest first. | ||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * @param {string} [backupDir] - Override backup directory | ||||||||||||||||||||||||||||||||||||
| * @returns {Array<{id: string, filename: string, createdAt: string, size: number}>} | ||||||||||||||||||||||||||||||||||||
| * @returns {Promise<Array<{id: string, filename: string, createdAt: string, size: number}>>} | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export function listBackups(backupDir) { | ||||||||||||||||||||||||||||||||||||
| const dir = getBackupDir(backupDir); | ||||||||||||||||||||||||||||||||||||
| export async function listBackups(backupDir) { | ||||||||||||||||||||||||||||||||||||
| const dir = await getBackupDir(backupDir); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| let files; | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| files = readdirSync(dir); | ||||||||||||||||||||||||||||||||||||
| files = await readdir(dir); | ||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const backups = files | ||||||||||||||||||||||||||||||||||||
| .map((filename) => parseBackupMeta(filename, dir)) | ||||||||||||||||||||||||||||||||||||
| .filter(Boolean) | ||||||||||||||||||||||||||||||||||||
| .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); | ||||||||||||||||||||||||||||||||||||
| const results = await Promise.all(files.map((filename) => parseBackupMeta(filename, dir))); | ||||||||||||||||||||||||||||||||||||
| const backups = results.filter(Boolean).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+259
to
+260
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Avoid unbounded
♻️ Suggested bounded approach- const results = await Promise.all(files.map((filename) => parseBackupMeta(filename, dir)));
- const backups = results.filter(Boolean).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+ const backups = [];
+ for (const filename of files) {
+ const meta = await parseBackupMeta(filename, dir);
+ if (meta) backups.push(meta);
+ }
+ backups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return backups; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
@@ -277,26 +267,28 @@ export function listBackups(backupDir) { | |||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * @param {string} id - Backup ID (filename without .json) | ||||||||||||||||||||||||||||||||||||
| * @param {string} [backupDir] - Override backup directory | ||||||||||||||||||||||||||||||||||||
| * @returns {Object} Parsed backup payload | ||||||||||||||||||||||||||||||||||||
| * @returns {Promise<Object>} Parsed backup payload | ||||||||||||||||||||||||||||||||||||
| * @throws {Error} If backup file not found or invalid | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export function readBackup(id, backupDir) { | ||||||||||||||||||||||||||||||||||||
| export async function readBackup(id, backupDir) { | ||||||||||||||||||||||||||||||||||||
| // Validate ID against strict pattern: backup-YYYY-MM-DDTHH-mm-ss-SSS-NNNN | ||||||||||||||||||||||||||||||||||||
| const BACKUP_ID_PATTERN = | ||||||||||||||||||||||||||||||||||||
| /^backup-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{3}-[0-9]{4}$/; | ||||||||||||||||||||||||||||||||||||
| if (!BACKUP_ID_PATTERN.test(id)) { | ||||||||||||||||||||||||||||||||||||
| throw new Error('Invalid backup ID'); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const dir = getBackupDir(backupDir); | ||||||||||||||||||||||||||||||||||||
| const dir = await getBackupDir(backupDir); | ||||||||||||||||||||||||||||||||||||
| const filename = `${id}.json`; | ||||||||||||||||||||||||||||||||||||
| const filePath = path.join(dir, filename); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (!existsSync(filePath)) { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| await access(filePath); | ||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||
| throw new Error(`Backup not found: ${id}`); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const raw = readFileSync(filePath, 'utf8'); | ||||||||||||||||||||||||||||||||||||
| const raw = await readFile(filePath, 'utf8'); | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+285
to
+291
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove TOCTOU between existence check and file read. The 🛠️ Suggested fix (single read path with normalized errors)- try {
- await access(filePath);
- } catch {
- throw new Error(`Backup not found: ${id}`);
- }
-
- const raw = await readFile(filePath, 'utf8');
+ let raw;
+ try {
+ raw = await readFile(filePath, 'utf8');
+ } catch (err) {
+ if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
+ throw new Error(`Backup not found: ${id}`);
+ }
+ throw new Error(`Failed to read backup: ${id}`);
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| return JSON.parse(raw); | ||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||
|
|
@@ -313,7 +305,7 @@ export function readBackup(id, backupDir) { | |||||||||||||||||||||||||||||||||||
| * @throws {Error} If backup not found or invalid | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export async function restoreBackup(id, backupDir) { | ||||||||||||||||||||||||||||||||||||
| const payload = readBackup(id, backupDir); | ||||||||||||||||||||||||||||||||||||
| const payload = await readBackup(id, backupDir); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const validationErrors = validateImportPayload(payload); | ||||||||||||||||||||||||||||||||||||
| if (validationErrors.length > 0) { | ||||||||||||||||||||||||||||||||||||
|
|
@@ -338,12 +330,12 @@ export async function restoreBackup(id, backupDir) { | |||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * @param {{daily?: number, weekly?: number}} [retention] - Retention counts | ||||||||||||||||||||||||||||||||||||
| * @param {string} [backupDir] - Override backup directory | ||||||||||||||||||||||||||||||||||||
| * @returns {string[]} IDs of deleted backups | ||||||||||||||||||||||||||||||||||||
| * @returns {Promise<string[]>} IDs of deleted backups | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export function pruneBackups(retention, backupDir) { | ||||||||||||||||||||||||||||||||||||
| export async function pruneBackups(retention, backupDir) { | ||||||||||||||||||||||||||||||||||||
| const { daily = DEFAULT_RETENTION.daily, weekly = DEFAULT_RETENTION.weekly } = retention ?? {}; | ||||||||||||||||||||||||||||||||||||
| const dir = getBackupDir(backupDir); | ||||||||||||||||||||||||||||||||||||
| const all = listBackups(dir); | ||||||||||||||||||||||||||||||||||||
| const dir = await getBackupDir(backupDir); | ||||||||||||||||||||||||||||||||||||
| const all = await listBackups(dir); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (all.length === 0) return []; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -372,7 +364,7 @@ export function pruneBackups(retention, backupDir) { | |||||||||||||||||||||||||||||||||||
| for (const backup of all) { | ||||||||||||||||||||||||||||||||||||
| if (!toKeep.has(backup.id)) { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| unlinkSync(path.join(dir, backup.filename)); | ||||||||||||||||||||||||||||||||||||
| await unlink(path.join(dir, backup.filename)); | ||||||||||||||||||||||||||||||||||||
| deleted.push(backup.id); | ||||||||||||||||||||||||||||||||||||
| info('Pruned old backup', { id: backup.id }); | ||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||
|
|
@@ -407,12 +399,14 @@ export function startScheduledBackups(opts = {}) { | |||||||||||||||||||||||||||||||||||
| info('Starting scheduled config backups', { intervalMs }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| scheduledBackupInterval = setInterval(() => { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| createBackup(backupDir); | ||||||||||||||||||||||||||||||||||||
| pruneBackups(retention, backupDir); | ||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||
| logError('Scheduled backup failed', { error: err.message }); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| void (async () => { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| await createBackup(backupDir); | ||||||||||||||||||||||||||||||||||||
| await pruneBackups(retention, backupDir); | ||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||
| logError('Scheduled backup failed', { error: err.message }); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| })(); | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+402
to
+409
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent overlapping scheduled backup runs. The interval callback launches async work and returns immediately; if one run exceeds 🛡️ Suggested in-flight guard let scheduledBackupInterval = null;
+let scheduledBackupInFlight = false;
@@
scheduledBackupInterval = setInterval(() => {
+ if (scheduledBackupInFlight) {
+ warn('Previous scheduled backup run is still in progress — skipping tick');
+ return;
+ }
void (async () => {
+ scheduledBackupInFlight = true;
try {
await createBackup(backupDir);
await pruneBackups(retention, backupDir);
} catch (err) {
logError('Scheduled backup failed', { error: err.message });
+ } finally {
+ scheduledBackupInFlight = false;
}
})();
}, intervalMs);🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| }, intervalMs); | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
401
to
410
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Prevent the interval from keeping the process alive unnecessarily (e.g. in tests) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Normalize error responses for async list/prune handlers.
These handlers await filesystem-backed operations without local error handling, so failures can bypass the route-specific JSON error contract used by neighboring endpoints.
🧩 Suggested consistency fix
router.get( '/', (req, res, next) => requireGlobalAdmin('Backup access', req, res, next), async (_req, res) => { - const backups = await listBackups(); - res.json(backups); + try { + const backups = await listBackups(); + return res.json(backups); + } catch (err) { + return res.status(500).json({ error: 'Failed to list backups', details: err.message }); + } }, ); @@ router.post( '/prune', (req, res, next) => requireGlobalAdmin('Backup access', req, res, next), async (req, res) => { @@ - const deleted = await pruneBackups(retention); - return res.json({ deleted, count: deleted.length }); + try { + const deleted = await pruneBackups(retention); + return res.json({ deleted, count: deleted.length }); + } catch (err) { + return res.status(500).json({ error: 'Failed to prune backups', details: err.message }); + } }, );Also applies to: 423-443
🤖 Prompt for AI Agents