diff --git a/.gitignore b/.gitignore index 454a81b..b9d927a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .svelte-kit/ build/ data/ +backups/ *.log .env .env.* diff --git a/Dockerfile b/Dockerfile index 8371d66..fdb4bf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-bookworm-slim # Tools needed to compile better-sqlite3 RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates tzdata \ && rm -rf /var/lib/apt/lists/* # Run as the node user (uid 1000) — already exists in node images diff --git a/docker-compose.yml b/docker-compose.yml index 88410ec..d12f294 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,4 +15,5 @@ services: - ./data:/app/data environment: - NODE_ENV=development + - TZ=Asia/Dushanbe command: npm run dev diff --git a/src/hooks.server.js b/src/hooks.server.js index b3a79c8..7c70148 100644 --- a/src/hooks.server.js +++ b/src/hooks.server.js @@ -1,8 +1,10 @@ import { getDb } from '$lib/server/db.js'; +import { startBackupScheduler } from '$lib/server/backup.js'; // Open (and warm) the database on server startup so the first request // doesn't pay the cost. getDb(); +startBackupScheduler(); /** @type {import('@sveltejs/kit').Handle} */ export async function handle({ event, resolve }) { diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 9caf0e2..69f1834 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -24,6 +24,7 @@ {$t('nav.parts')} {$t('nav.movements')} {$t('nav.suppliers')} + {$t('nav.admin')} diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 47725a8..49e82de 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -8,6 +8,7 @@ "parts": "Parts", "movements": "Movements", "suppliers": "Suppliers", + "admin": "Backups", "new_part": "New part", "new_movement": "Record movement" }, @@ -97,6 +98,27 @@ "not_enough_stock": "Not enough stock on hand." } }, + "admin": { + "title": "Backups & Restore", + "warning_title": "Important: copy backups to a USB stick regularly!", + "warning_body": "Backups are kept on this computer only. If the hard drive fails, all of your data and all backups will be lost. At least once a week, plug in a USB stick and click the Download button next to a recent backup, then save the file onto the stick.", + "backup_now": "Back up now", + "auto_note": "A backup is taken automatically every 5 minutes when there are changes.", + "list_title": "Available backups", + "no_backups": "No backups yet.", + "created_at": "When", + "size": "Size", + "download": "Download", + "restore": "Restore", + "restore_confirm": "Restore the database from this backup? The current data will be replaced (a safety backup of the current data will be made first).", + "prune_note": "Backups from the last 7 days are kept in full. Older backups are thinned to one per day automatically.", + "flash": { + "backup_taken": "Backup created.", + "backup_failed": "Backup failed. See the server logs.", + "restored": "Database restored.", + "restore_failed": "Restore failed. See the server logs." + } + }, "suppliers": { "title": "Suppliers", "name": "Name", diff --git a/src/lib/i18n/tg.json b/src/lib/i18n/tg.json index a1f2fce..3341e79 100644 --- a/src/lib/i18n/tg.json +++ b/src/lib/i18n/tg.json @@ -8,6 +8,7 @@ "parts": "Қисмҳо", "movements": "Ҳаракатҳо", "suppliers": "Таъминкунандагон", + "admin": "Нусхаҳо", "new_part": "Қисми нав", "new_movement": "Сабти ҳаракат" }, @@ -97,6 +98,27 @@ "not_enough_stock": "Дар анбор миқдори кофӣ нест." } }, + "admin": { + "title": "Нусхабардорӣ ва барқарорсозӣ", + "warning_title": "Муҳим: нусхаҳоро мунтазам ба USB-флешка нусхабардорӣ кунед!", + "warning_body": "Нусхаҳо танҳо дар ин компютер нигоҳ дошта мешаванд. Агар диски сахт вайрон шавад, ҳамаи маълумот ва ҳамаи нусхаҳо нест мешаванд. Ҳафтае як маротиба USB-флешкаро пайваст кунед, тугмаи «Зеркашӣ»-ро дар сатри як нусхаи нав пахш кунед ва файлро ба флешка захира кунед.", + "backup_now": "Ҳозир нусха гирифтан", + "auto_note": "Ҳар 5 дақиқа агар тағйирот бошад, нусха худкор гирифта мешавад.", + "list_title": "Нусхаҳои мавҷуда", + "no_backups": "Ҳоло нусхае нест.", + "created_at": "Сана", + "size": "Андоза", + "download": "Зеркашӣ", + "restore": "Барқарор кардан", + "restore_confirm": "Маълумотро аз ин нусха барқарор мекунед? Маълумоти ҷорӣ иваз карда мешавад (аввал нусхаи эҳтиётии маълумоти ҷорӣ гирифта мешавад).", + "prune_note": "Нусхаҳои 7 рӯзи охир пурра нигоҳ дошта мешаванд. Нусхаҳои кӯҳна то як адад дар як рӯз худкор кам карда мешаванд.", + "flash": { + "backup_taken": "Нусха сохта шуд.", + "backup_failed": "Нусхабардорӣ ноком шуд. Логи серверро бинед.", + "restored": "Маълумот барқарор карда шуд.", + "restore_failed": "Барқарорсозӣ ноком шуд. Логи серверро бинед." + } + }, "suppliers": { "title": "Таъминкунандагон", "name": "Ном", diff --git a/src/lib/server/backup.js b/src/lib/server/backup.js new file mode 100644 index 0000000..91f48d5 --- /dev/null +++ b/src/lib/server/backup.js @@ -0,0 +1,165 @@ +// Backup / restore for data/avtoambor.db. +// +// - Scheduler ticks every 5 minutes; takes a backup only if the DB file's +// mtime has advanced since the previous backup (so an idle shop doesn't +// accumulate identical snapshots). +// - Backups land in ./backups/ at the repo root, named +// avtoambor-YYYY-MM-DD_HH-MM-SS.db (sortable, human-readable). +// - After each new backup, prune older snapshots: keep ALL backups from the +// last 7 days; for anything older, keep only the most recent backup of +// each calendar day. +// - Restore closes the live DB, removes WAL/SHM, copies the chosen backup +// over the .db file, and lets the next getDb() reopen. + +import { getDb, closeDb, DB_FILE } from './db.js'; +import { + mkdirSync, existsSync, readdirSync, statSync, unlinkSync, copyFileSync +} from 'node:fs'; +import { resolve, dirname, join } from 'node:path'; + +const DATA_DIR = dirname(DB_FILE); +export const BACKUP_DIR = resolve(DATA_DIR, '..', 'backups'); +const PREFIX = 'avtoambor-'; +const EXT = '.db'; +const FILE_RE = /^avtoambor-(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.db$/; +const RETAIN_DAYS = 7; +const DAY_MS = 24 * 60 * 60 * 1000; +const TICK_MS = 5 * 60 * 1000; + +function ensureDir() { + if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true }); +} + +function pad(n) { return String(n).padStart(2, '0'); } + +function stamp(d = new Date()) { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; +} + +function parseStamp(name) { + const m = name.match(FILE_RE); + if (!m) return null; + const [, Y, Mo, D, H, Mi, S] = m; + return new Date(+Y, +Mo - 1, +D, +H, +Mi, +S); +} + +function dayKey(d) { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export function safeName(name) { + return typeof name === 'string' + && !name.includes('/') + && !name.includes('\\') + && FILE_RE.test(name); +} + +export function listBackups() { + ensureDir(); + return readdirSync(BACKUP_DIR) + .filter((n) => n.startsWith(PREFIX) && n.endsWith(EXT) && FILE_RE.test(n)) + .map((name) => { + const p = join(BACKUP_DIR, name); + const st = statSync(p); + return { + name, + path: p, + size: st.size, + createdAt: parseStamp(name) ?? st.mtime + }; + }) + .sort((a, b) => b.createdAt - a.createdAt); +} + +// Latest mtime across the SQLite triplet (.db, -wal, -shm). +function dbSourceMtime() { + let max = 0; + for (const suffix of ['', '-wal', '-shm']) { + const p = DB_FILE + suffix; + if (existsSync(p)) max = Math.max(max, statSync(p).mtimeMs); + } + return max; +} + +export async function takeBackup() { + ensureDir(); + const name = `${PREFIX}${stamp()}${EXT}`; + const dest = join(BACKUP_DIR, name); + await getDb().backup(dest); + const st = statSync(dest); + pruneOldBackups(); + return { name, path: dest, createdAt: new Date(), size: st.size }; +} + +// Keep everything in the last RETAIN_DAYS; for older snapshots, keep only +// the newest one per calendar day. Deletions are silent on error. +export function pruneOldBackups(now = new Date()) { + const cutoff = now.getTime() - RETAIN_DAYS * DAY_MS; + const all = listBackups(); // newest first + const seenDays = new Set(); + for (const b of all) { + const t = b.createdAt.getTime(); + if (t >= cutoff) continue; // inside retention window — keep + const key = dayKey(b.createdAt); + if (seenDays.has(key)) { + try { unlinkSync(b.path); } catch { /* ignore */ } + } else { + seenDays.add(key); // first (newest) of this day — keep + } + } +} + +export async function restoreBackup(name) { + if (!safeName(name)) throw new Error('Invalid backup name'); + const src = join(BACKUP_DIR, name); + if (!existsSync(src)) throw new Error('Backup not found'); + + // Snapshot the current DB first so a misclick is recoverable. + try { await takeBackup(); } catch (e) { console.error('[backup] pre-restore snapshot failed', e); } + + // From here on: no awaits. Node is single-threaded so other requests + // cannot interleave and reopen the DB on partial state. + closeDb(); + for (const suffix of ['-wal', '-shm']) { + const p = DB_FILE + suffix; + if (existsSync(p)) { + try { unlinkSync(p); } catch { /* ignore */ } + } + } + copyFileSync(src, DB_FILE); + // Next getDb() reopens against the restored file. +} + +// Module-level scheduler state. globalThis-guarded so dev HMR doesn't stack +// multiple intervals on top of each other. +const STARTED_FLAG = '__avtoambor_backup_started'; + +export function startBackupScheduler() { + if (globalThis[STARTED_FLAG]) return; + globalThis[STARTED_FLAG] = true; + + ensureDir(); + + // Baseline: pretend the most recent backup's timestamp is "what we've + // already captured", so we don't immediately take a duplicate at boot. + let lastSeenMtime = 0; + const existing = listBackups(); + if (existing.length > 0) lastSeenMtime = existing[0].createdAt.getTime(); + + const tick = async () => { + try { + const mtime = dbSourceMtime(); + if (mtime > lastSeenMtime) { + await takeBackup(); + lastSeenMtime = mtime; + } + } catch (e) { + console.error('[backup] scheduled tick failed', e); + } + }; + + // Run once shortly after boot, then every TICK_MS. + setTimeout(tick, 5_000); + setInterval(tick, TICK_MS); +} diff --git a/src/lib/server/db.js b/src/lib/server/db.js index 04d718e..89e3ea5 100644 --- a/src/lib/server/db.js +++ b/src/lib/server/db.js @@ -20,4 +20,13 @@ export function getDb() { return _db; } +// Close the live connection. Used by the restore flow before +// overwriting the .db file; next getDb() call will reopen. +export function closeDb() { + if (_db) { + try { _db.close(); } catch { /* ignore */ } + _db = null; + } +} + export const DB_FILE = DB_PATH; diff --git a/src/routes/admin/+page.server.js b/src/routes/admin/+page.server.js new file mode 100644 index 0000000..ac950ed --- /dev/null +++ b/src/routes/admin/+page.server.js @@ -0,0 +1,35 @@ +import { fail } from '@sveltejs/kit'; +import { listBackups, takeBackup, restoreBackup } from '$lib/server/backup.js'; + +export function load() { + return { + backups: listBackups().map((b) => ({ + name: b.name, + size: b.size, + createdAt: b.createdAt.toISOString() + })) + }; +} + +export const actions = { + backup: async () => { + try { + const b = await takeBackup(); + return { ok: true, message: 'admin.flash.backup_taken', name: b.name }; + } catch (e) { + console.error('[admin] manual backup failed', e); + return fail(500, { message: 'admin.flash.backup_failed' }); + } + }, + restore: async ({ request }) => { + const form = await request.formData(); + const name = String(form.get('name') ?? ''); + try { + await restoreBackup(name); + return { ok: true, message: 'admin.flash.restored' }; + } catch (e) { + console.error('[admin] restore failed', e); + return fail(500, { message: 'admin.flash.restore_failed' }); + } + } +}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 0000000..145632f --- /dev/null +++ b/src/routes/admin/+page.svelte @@ -0,0 +1,118 @@ + + +{$t('admin.title')} + + + {$t('admin.warning_title')} + {$t('admin.warning_body')} + + +{#if form?.message} + + {$t(form.message)} + +{/if} + + + async ({ update }) => { await update(); }}> + {$t('admin.backup_now')} + + {$t('admin.auto_note')} + + +{$t('admin.list_title')} + +{#if backups.length === 0} + {$t('admin.no_backups')} +{:else} + + + + {$t('admin.created_at')} + {$t('admin.size')} + + + + + {#each backups as b} + + {formatWhen(b.createdAt, $locale)} + {formatSize(b.size)} + + + {$t('admin.download')} + + async ({ update }) => { await update(); await invalidateAll(); }} + on:submit={(e) => { if (!confirm($t('admin.restore_confirm'))) e.preventDefault(); }}> + + {$t('admin.restore')} + + + + {/each} + + + {$t('admin.prune_note')} +{/if} + + diff --git a/src/routes/admin/download/[name]/+server.js b/src/routes/admin/download/[name]/+server.js new file mode 100644 index 0000000..f2f7eb0 --- /dev/null +++ b/src/routes/admin/download/[name]/+server.js @@ -0,0 +1,24 @@ +import { error } from '@sveltejs/kit'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { BACKUP_DIR, safeName } from '$lib/server/backup.js'; + +export function GET({ params }) { + const { name } = params; + if (!safeName(name)) throw error(400, 'Invalid backup name'); + + const path = join(BACKUP_DIR, name); + if (!existsSync(path)) throw error(404, 'Backup not found'); + + const buf = readFileSync(path); + const size = statSync(path).size; + + return new Response(buf, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(size), + 'Content-Disposition': `attachment; filename="${name}"`, + 'Cache-Control': 'no-store' + } + }); +}
{$t('admin.warning_body')}
{$t('admin.auto_note')}
{$t('admin.no_backups')}
{$t('admin.prune_note')}