First cut
This commit is contained in:
68
server/src/io/write-content.ts
Normal file
68
server/src/io/write-content.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { stringifyCanonical } from '@dynamic-sites/shared';
|
||||
import { writeAuditLog } from '../db.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const REPO_ROOT = process.env.REPO_ROOT || '.';
|
||||
const MAX_BACKUPS = 20;
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function fileHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write of canonical JSON to a repo-relative path.
|
||||
* Creates a pre-write backup and audit log entry.
|
||||
*/
|
||||
export function writeContentFile(
|
||||
repoRelativePath: string,
|
||||
data: unknown,
|
||||
opts?: { proposalId?: string; source?: string }
|
||||
): void {
|
||||
const absPath = path.join(REPO_ROOT, repoRelativePath);
|
||||
const canonical = stringifyCanonical(data);
|
||||
|
||||
// Pre-write backup
|
||||
let beforeHash: string | undefined;
|
||||
if (fs.existsSync(absPath)) {
|
||||
const existing = fs.readFileSync(absPath, 'utf-8');
|
||||
beforeHash = fileHash(existing);
|
||||
|
||||
const backupDir = path.join(REPO_ROOT, 'content', '.backups', repoRelativePath);
|
||||
ensureDir(backupDir);
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`));
|
||||
|
||||
// Prune old backups
|
||||
const backups = fs.readdirSync(backupDir).sort();
|
||||
while (backups.length > MAX_BACKUPS) {
|
||||
const oldest = backups.shift()!;
|
||||
fs.unlinkSync(path.join(backupDir, oldest));
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic write: temp file + rename
|
||||
ensureDir(path.dirname(absPath));
|
||||
const tmpPath = absPath + '.tmp.' + process.pid;
|
||||
fs.writeFileSync(tmpPath, canonical, 'utf-8');
|
||||
fs.renameSync(tmpPath, absPath);
|
||||
|
||||
const afterHash = fileHash(canonical);
|
||||
|
||||
// Audit log
|
||||
writeAuditLog({
|
||||
proposalId: opts?.proposalId,
|
||||
repoRelativePath,
|
||||
beforeHash,
|
||||
afterHash,
|
||||
source: opts?.source || 'http',
|
||||
});
|
||||
|
||||
logger.info({ event: 'content.written', path: repoRelativePath, size: canonical.length }, 'Content file written');
|
||||
}
|
||||
Reference in New Issue
Block a user