Files
harheimertc/server/utils/data-file-rotation.js
Torsten Schulz (local) b4e1c50ea3
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m57s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
semgrep problems fix
2026-06-10 13:55:50 +02:00

144 lines
4.8 KiB
JavaScript

import { promises as fs } from 'fs'
import path from 'path'
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
function getProjectRoot() {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
return path.resolve(cwd, '..')
}
return cwd
}
function getBackupRoot() {
const configured = process.env.DATA_FILE_BACKUP_DIR
if (!configured) {
return path.join(getProjectRoot(), 'backups', 'data-rotation')
}
if (path.isAbsolute(configured)) {
return configured
}
return path.join(getProjectRoot(), configured)
}
function sanitizeFileKey(filePath) {
const projectRoot = getProjectRoot()
const relative = path.relative(projectRoot, filePath)
const normalized = relative.split(path.sep).join('__')
return normalized.replace(/[^a-zA-Z0-9._-]/g, '_')
}
function buildBackupName(date = new Date()) {
const randomSuffix = Math.random().toString(36).slice(2, 8)
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
}
export function resolveDataFileBackupPath(backupDir, backupName) {
if (typeof backupName !== 'string' || !backupName.endsWith('.bak') || path.basename(backupName) !== backupName) {
throw new Error('Ungueltiger Backup-Dateiname')
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedBackupDir = path.resolve(backupDir)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedBackupPath = path.resolve(resolvedBackupDir, backupName)
if (path.dirname(resolvedBackupPath) !== resolvedBackupDir) {
throw new Error('Backup-Datei liegt ausserhalb des Backup-Ordners')
}
return resolvedBackupPath
}
async function ensureDirectory(dirPath) {
await fs.mkdir(dirPath, { recursive: true })
}
async function rotateOldBackups(backupDir, maxBackups) {
if (!Number.isFinite(maxBackups) || maxBackups < 1) {
return
}
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
const backups = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
.map((entry) => entry.name)
.sort()
const overflowCount = Math.max(0, backups.length - maxBackups)
if (overflowCount === 0) {
return
}
const toDelete = backups.slice(0, overflowCount)
await Promise.all(toDelete.map((name) => fs.unlink(resolveDataFileBackupPath(backupDir, name)).catch(() => {})))
}
export function getBackupDirectoryForDataFile(filePath) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath)
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
}
export async function listDataFileBackups(filePath) {
const backupDir = getBackupDirectoryForDataFile(filePath)
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
.map((entry) => entry.name)
.sort()
.reverse()
}
export async function writeDataFileWithRotation(filePath, content, {
encoding = 'utf-8',
maxBackups = DEFAULT_MAX_BACKUPS
} = {}) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath)
await ensureDirectory(path.dirname(resolvedPath))
let existingContent = null
try {
existingContent = await fs.readFile(resolvedPath, encoding)
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
}
if (existingContent !== null && existingContent === content) {
return {
changed: false,
backupPath: null
}
}
let backupPath = null
if (existingContent !== null) {
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
await ensureDirectory(backupDir)
backupPath = resolveDataFileBackupPath(backupDir, buildBackupName())
await fs.copyFile(resolvedPath, backupPath)
await rotateOldBackups(backupDir, maxBackups)
}
const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`
await fs.writeFile(tmpPath, content, encoding)
await fs.rename(tmpPath, resolvedPath)
return {
changed: true,
backupPath
}
}
export async function restoreDataFileBackup(filePath, backupName, options = {}) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath)
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
const sourceBackupPath = resolveDataFileBackupPath(backupDir, backupName)
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
return writeDataFileWithRotation(resolvedPath, backupContent, options)
}