feat: verify offline user data backup and recovery

This commit is contained in:
2026-09-06 09:04:03 +03:00
parent f88a0c7bfe
commit d4d9d17a9f
4 changed files with 137 additions and 1 deletions
+76
View File
@@ -0,0 +1,76 @@
import fs from 'node:fs';
import path from 'node:path';
import {createHash} from 'node:crypto';
import {DatabaseSync} from 'node:sqlite';
import {fileURLToPath} from 'node:url';
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const safeName = name => typeof name==='string' && name.length>0 && path.basename(name)===name && name!=='.' && name!=='..';
const regular = file => {if(!fs.lstatSync(file).isFile())throw new Error('Yedek yalnız normal dosyaları kabul eder.');};
function verifyDatabase(file) {
const db=new DatabaseSync(file,{readOnly:true});
try {
const result=db.prepare('PRAGMA integrity_check').all();
if(result.length!==1 || Object.values(result[0])[0]!=='ok')throw new Error('Veritabanı bütünlük kontrolü başarısız.');
return db.prepare('SELECT dosya FROM belgeler').all().map(r=>r.dosya);
} finally {db.close();}
}
export function verifyUserBackup(directory) {
const manifest=JSON.parse(fs.readFileSync(path.join(directory,'manifest.json'),'utf8'));
if(manifest.version!==1 || !Array.isArray(manifest.files))throw new Error('Geçersiz yedek manifesti.');
const seen=new Set();
for(const entry of manifest.files) {
if(entry.name!=='users.sqlite' && !(entry.name?.startsWith('belgeler/') && safeName(entry.name.slice(9))))throw new Error('Geçersiz yedek dosya yolu.');
if(seen.has(entry.name))throw new Error('Tekrarlı yedek girdisi.');seen.add(entry.name);
const file=path.join(directory,entry.name);regular(file);
if(hash(file)!==entry.sha256 || fs.statSync(file).size!==entry.bytes)throw new Error('Yedek dosyası eksik veya değişmiş: '+entry.name);
}
if(!seen.has('users.sqlite'))throw new Error('Veritabanı yedekte yok.');
for(const name of verifyDatabase(path.join(directory,'users.sqlite')))
if(!safeName(name) || !seen.has('belgeler/'+name))throw new Error('Veritabanının referans verdiği belge yedekte yok.');
return manifest;
}
export function backupUserData(source,destination,{offline=false}={}) {
if(!offline)throw new Error('Önce uygulama ve yazan işleri durdurun; offline seçeneği gerekli.');
const dbFile=path.join(source,'users.sqlite');regular(dbFile);
fs.mkdirSync(destination,{mode:0o700}); // Never overwrite an existing backup.
let db;
try {
fs.mkdirSync(path.join(destination,'belgeler'),{mode:0o700});
db=new DatabaseSync(dbFile,{readOnly:true});
db.prepare('VACUUM INTO ?').run(path.resolve(destination,'users.sqlite'));db.close();db=null;
const references=verifyDatabase(path.join(destination,'users.sqlite'));
for(const name of new Set(references)) {
if(!safeName(name))throw new Error('Geçersiz belge dosya adı.');
const file=path.join(source,'belgeler',name);regular(file);
fs.copyFileSync(file,path.join(destination,'belgeler',name),fs.constants.COPYFILE_EXCL);
}
const names=['users.sqlite',...[...new Set(references)].map(name=>'belgeler/'+name)];
const files=names.map(name=>{const file=path.join(destination,name);fs.chmodSync(file,0o600);return {name,bytes:fs.statSync(file).size,sha256:hash(file)};});
fs.writeFileSync(path.join(destination,'manifest.json'),JSON.stringify({version:1,createdAt:new Date().toISOString(),scope:'users.sqlite and referenced documents',files},null,2),{mode:0o600,flag:'wx'});
return verifyUserBackup(destination);
} catch(error) {db?.close();fs.rmSync(destination,{recursive:true,force:true});throw error;}
}
export function restoreUserData(backup,destination) {
const manifest=verifyUserBackup(backup);
fs.mkdirSync(destination,{mode:0o700}); // Restore only to a NEW directory.
try {
fs.mkdirSync(path.join(destination,'belgeler'),{mode:0o700});
for(const entry of manifest.files) {
const file=path.join(destination,entry.name);
fs.copyFileSync(path.join(backup,entry.name),file,fs.constants.COPYFILE_EXCL);fs.chmodSync(file,0o600);
if(hash(file)!==entry.sha256)throw new Error('Geri yükleme sırasında dosya değişti.');
}
verifyDatabase(path.join(destination,'users.sqlite'));
return {files:manifest.files.length,destination};
}catch(error){fs.rmSync(destination,{recursive:true,force:true});throw error;}
}
if(process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
const [command,source,destination,flag]=process.argv.slice(2);
try {
if(command==='backup' && source && destination)backupUserData(source,destination,{offline:flag==='--offline'});
else if(command==='verify' && source)verifyUserBackup(source);
else if(command==='restore' && source && destination)restoreUserData(source,destination);
else throw new Error('Kullanım: backup <veri-dizini> <yeni-yedek-dizini> --offline | verify <yedek> | restore <yedek> <yeni-dizin>');
console.log('İşlem doğrulandı.');
}catch(error){console.error(error.message);process.exitCode=1;}
}