Add daily and weekly automatic backup schedules
This commit is contained in:
+7
-6
@@ -5,7 +5,7 @@ import { extname, join, normalize } from "node:path";
|
||||
import { backup, DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
|
||||
|
||||
const APP_VERSION="3.3.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
|
||||
const APP_VERSION="3.3.1",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
|
||||
const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
|
||||
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
|
||||
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
|
||||
@@ -43,7 +43,7 @@ function initializeDatabase(){
|
||||
const addWarranty=db.prepare("INSERT OR IGNORE INTO warranty_presets (id,name,duration_value,duration_unit,is_default) VALUES (?,?,?,?,?)");
|
||||
for(const [name,value,unit,isDefault] of [["No Warranty",0,"days",1],["30 Days",30,"days",0],["60 Days",60,"days",0],["90 Days",90,"days",0],["1 Year",1,"years",0]])addWarranty.run(crypto.randomUUID(),name,value,unit,isDefault);
|
||||
db.prepare("UPDATE products SET fulfillment_method='Shipped' WHERE fulfillment_method='' AND status='sold' AND (ship_address1!='' OR ship_city!='' OR ship_zip!='')").run();
|
||||
const setting=db.prepare("INSERT OR IGNORE INTO app_settings (key,value) VALUES (?,?)");for(const [key,value] of [["schema_version",String(SCHEMA_VERSION)],["backup_enabled","0"],["backup_hour","2"],["backup_retention","14"],["last_scheduled_backup",""]])setting.run(key,value);
|
||||
const setting=db.prepare("INSERT OR IGNORE INTO app_settings (key,value) VALUES (?,?)");for(const [key,value] of [["schema_version",String(SCHEMA_VERSION)],["backup_enabled","0"],["backup_frequency","daily"],["backup_weekday","0"],["backup_hour","2"],["backup_retention","14"],["last_scheduled_backup",""]])setting.run(key,value);
|
||||
db.prepare("UPDATE app_settings SET value=? WHERE key='schema_version'").run(String(SCHEMA_VERSION));
|
||||
}
|
||||
const hashPassword=password=>{const salt=randomBytes(16).toString("hex");return `scrypt$${salt}$${scryptSync(password,salt,64).toString("hex")}`};
|
||||
@@ -77,9 +77,10 @@ const backupName=(prefix="vboxstock")=>`${prefix}-${new Date().toISOString().rep
|
||||
async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name}
|
||||
const setting=key=>db.prepare("SELECT value FROM app_settings WHERE key=?").get(key)?.value||"";
|
||||
function saveSetting(key,value){db.prepare("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=CURRENT_TIMESTAMP").run(key,String(value))}
|
||||
function nextAutomaticBackup(){if(setting("backup_enabled")!=="1")return null;const now=new Date(),next=new Date(now),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0)),frequency=setting("backup_frequency")==="weekly"?"weekly":"daily",weekday=Math.max(0,Math.min(6,Number(setting("backup_weekday"))||0));next.setHours(hour,0,0,0);if(frequency==="daily"){if(next<=now)next.setDate(next.getDate()+1)}else{let days=(weekday-next.getDay()+7)%7;if(days===0&&next<=now)days=7;next.setDate(next.getDate()+days)}return next.toISOString()}
|
||||
async function backupFiles(){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt))}
|
||||
async function pruneScheduledBackups(){const keep=Math.max(1,Math.min(365,Number(setting("backup_retention"))||14)),files=(await backupFiles()).filter(x=>x.name.startsWith("scheduled-"));for(const file of files.slice(keep))await unlink(join(backupDir,file.name)).catch(()=>{})}
|
||||
async function runScheduledBackup(){if(setting("backup_enabled")!=="1")return;const now=new Date(),day=now.toISOString().slice(0,10),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0));if(now.getHours()<hour||setting("last_scheduled_backup")===day)return;const name=await createBackup("scheduled");saveSetting("last_scheduled_backup",day);db.prepare("INSERT INTO audit_log (username,action,target,details) VALUES ('system','scheduled_backup_created',?,'Automatic scheduled backup')").run(name);await pruneScheduledBackups()}
|
||||
async function pruneScheduledBackups(){const keep=Math.max(1,Math.min(365,Number(setting("backup_retention"))||14)),files=(await backupFiles()).filter(x=>x.name.startsWith("automatic-")||x.name.startsWith("scheduled-"));for(const file of files.slice(keep))await unlink(join(backupDir,file.name)).catch(()=>{})}
|
||||
async function runScheduledBackup(){if(setting("backup_enabled")!=="1")return;const now=new Date(),day=now.toLocaleDateString("en-CA"),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0)),frequency=setting("backup_frequency")==="weekly"?"weekly":"daily",weekday=Math.max(0,Math.min(6,Number(setting("backup_weekday"))||0));if(now.getHours()<hour||setting("last_scheduled_backup")===day||(frequency==="weekly"&&now.getDay()!==weekday))return;const name=await createBackup("automatic");saveSetting("last_scheduled_backup",day);db.prepare("INSERT INTO audit_log (username,action,target,details) VALUES ('system','scheduled_backup_created',?,'Automatic scheduled backup')").run(name);await pruneScheduledBackups()}
|
||||
setInterval(()=>runScheduledBackup().catch(error=>console.error("Scheduled backup failed:",error.message)),15*60*1000).unref();setTimeout(()=>runScheduledBackup().catch(error=>console.error("Scheduled backup failed:",error.message)),1000).unref();
|
||||
function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)}
|
||||
const csvCell=value=>`"${String(value??"").replaceAll('"','""')}"`,csv=(headers,rows)=>[headers.map(csvCell).join(","),...rows.map(row=>row.map(csvCell).join(","))].join("\r\n")+"\r\n";
|
||||
@@ -110,8 +111,8 @@ async function authApi(req,res,url,session){
|
||||
|
||||
async function adminApi(req,res,url,session){
|
||||
const user=session.user;
|
||||
if(url.pathname==="/api/admin/diagnostics"&&req.method==="GET"){const databaseInfo=await stat(databasePath),disk=await statfs(dataDir),backups=await backupFiles();let writable=true;try{const probe=join(dataDir,`.write-test-${crypto.randomUUID()}`);await writeFile(probe,"ok");await unlink(probe)}catch{writable=false}return json(res,200,{appVersion:APP_VERSION,schemaVersion:Number(setting("schema_version"))||SCHEMA_VERSION,nodeVersion:process.version,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone,dataDirectory:dataDir,dataWritable:writable,databaseSize:databaseInfo.size,backupCount:backups.length,lastBackupAt:backups[0]?.createdAt||null,diskFree:disk.bavail*disk.bsize,diskTotal:disk.blocks*disk.bsize,backupSettings:{enabled:setting("backup_enabled")==="1",hour:Number(setting("backup_hour"))||0,retention:Number(setting("backup_retention"))||14,lastScheduledBackup:setting("last_scheduled_backup")||null}})}
|
||||
if(url.pathname==="/api/admin/backup-settings"&&req.method==="PATCH"){const v=await body(req),enabled=Boolean(v.enabled),hour=Number(v.hour),retention=Number(v.retention);if(!Number.isInteger(hour)||hour<0||hour>23||!Number.isInteger(retention)||retention<1||retention>365)throw new Error("Backup hour must be 0–23 and retention must be 1–365.");saveSetting("backup_enabled",enabled?"1":"0");saveSetting("backup_hour",hour);saveSetting("backup_retention",retention);await pruneScheduledBackups();audit(req,user,"backup_schedule_updated","",`${enabled?"enabled":"disabled"}; ${hour}:00; retain ${retention}`);return json(res,200,{ok:true})}
|
||||
if(url.pathname==="/api/admin/diagnostics"&&req.method==="GET"){const databaseInfo=await stat(databasePath),disk=await statfs(dataDir),backups=await backupFiles();let writable=true;try{const probe=join(dataDir,`.write-test-${crypto.randomUUID()}`);await writeFile(probe,"ok");await unlink(probe)}catch{writable=false}return json(res,200,{appVersion:APP_VERSION,schemaVersion:Number(setting("schema_version"))||SCHEMA_VERSION,nodeVersion:process.version,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone,dataDirectory:dataDir,dataWritable:writable,databaseSize:databaseInfo.size,backupCount:backups.length,lastBackupAt:backups[0]?.createdAt||null,diskFree:disk.bavail*disk.bsize,diskTotal:disk.blocks*disk.bsize,backupSettings:{enabled:setting("backup_enabled")==="1",frequency:setting("backup_frequency")==="weekly"?"weekly":"daily",weekday:Number(setting("backup_weekday"))||0,hour:Number(setting("backup_hour"))||0,retention:Number(setting("backup_retention"))||14,lastScheduledBackup:setting("last_scheduled_backup")||null,nextScheduledBackup:nextAutomaticBackup()}})}
|
||||
if(url.pathname==="/api/admin/backup-settings"&&req.method==="PATCH"){const v=await body(req),enabled=Boolean(v.enabled),frequency=String(v.frequency||"daily"),weekday=Number(v.weekday),hour=Number(v.hour),retention=Number(v.retention);if(!new Set(["daily","weekly"]).has(frequency)||!Number.isInteger(weekday)||weekday<0||weekday>6||!Number.isInteger(hour)||hour<0||hour>23||!Number.isInteger(retention)||retention<1||retention>365)throw new Error("Choose a valid frequency, weekday, backup hour, and retention period.");saveSetting("backup_enabled",enabled?"1":"0");saveSetting("backup_frequency",frequency);saveSetting("backup_weekday",weekday);saveSetting("backup_hour",hour);saveSetting("backup_retention",retention);await pruneScheduledBackups();audit(req,user,"backup_schedule_updated","",`${enabled?"enabled":"disabled"}; ${frequency}${frequency==="weekly"?` day ${weekday}`:""}; ${hour}:00; retain ${retention}`);return json(res,200,{ok:true,nextScheduledBackup:nextAutomaticBackup()})}
|
||||
if(url.pathname==="/api/admin/warranties"&&req.method==="GET")return json(res,200,allWarranties());
|
||||
if(url.pathname==="/api/admin/warranties"&&req.method==="POST"){const v=await body(req),name=cleanWarrantyName(v.name),durationValue=Number(v.durationValue),durationUnit=String(v.durationUnit||"days");if(!Number.isInteger(durationValue)||durationValue<0||durationValue>3650||!new Set(["days","months","years"]).has(durationUnit))throw new Error("Enter a valid warranty duration.");const id=crypto.randomUUID();db.prepare("INSERT INTO warranty_presets (id,name,duration_value,duration_unit) VALUES (?,?,?,?)").run(id,name,durationValue,durationUnit);audit(req,user,"warranty_created",name,`${durationValue} ${durationUnit}`);return json(res,201,{id,name,durationValue,durationUnit,active:true,isDefault:false,usageCount:0})}
|
||||
const warrantyMatch=url.pathname.match(/^\/api\/admin\/warranties\/([^/]+)$/);
|
||||
|
||||
Reference in New Issue
Block a user