From 218c3f825d772340f78add021cff0fce08c4d14c Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 23 Sep 2026 13:49:25 +0300 Subject: [PATCH] fix(backup): restore new fields, share_links and photo originals - export/restore share_links (was silently dropped, FK blocked restore) - keep groups.tutor_id and groups.cover_path, entries.photo_original_path, project_files.detached_at on restore - include uploads/.originals files in backup archive - insert users before groups to satisfy tutor_id FK - return 500 JSON instead of hanging when restore fails --- server.js | 84 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/server.js b/server.js index 2811c8a..7e6e495 100644 --- a/server.js +++ b/server.js @@ -1294,6 +1294,14 @@ function optUploadPath(v, max) { return reqUploadPath(v, max); } +const ORIGINALS_PATH_RE = /^\/uploads\/\.originals\/[\w.,()-]+$/; + +function optOriginalsPath(v, max) { + if (v === null || v === undefined) return null; + if (typeof v !== 'string' || v.length > max || !ORIGINALS_PATH_RE.test(v)) throw new Error('Invalid originals path'); + return v; +} + const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']); function optAiText(v, max) { @@ -1418,6 +1426,8 @@ function normalizeRestoreData(data) { time_start: optTime(x.time_start), time_end: optTime(x.time_end), branch_id: optInt(x.branch_id, 0, 2147483647), + tutor_id: optInt(x.tutor_id, 0, 2147483647), + cover_path: optUploadPath(x.cover_path, 255), })); const students = (data.students || []).map(x => ({ id: reqInt(x.id), @@ -1439,6 +1449,7 @@ function normalizeRestoreData(data) { ai_checked_at: optTs(x.ai_checked_at), ai_error: optAiText(x.ai_error, 500), photo_path: optUploadPath(x.photo_path, 255), + photo_original_path: optOriginalsPath(x.photo_original_path, 255), deleted_at: optTs(x.deleted_at), created_at: optTs(x.created_at), })); @@ -1448,6 +1459,7 @@ function normalizeRestoreData(data) { token: reqToken(x.token), path: reqUploadPath(x.path, 255), name: reqStr(x.name, 255), + detached_at: optTs(x.detached_at), created_at: optTs(x.created_at), })); const branches = (data.branches || []).map(x => ({ @@ -1529,7 +1541,7 @@ function normalizeRestoreData(data) { app.get('/api/backup', requireAdmin, async (req, res) => { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { - const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp] = await Promise.all([ + const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl] = await Promise.all([ pool.query('SELECT * FROM groups ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM entries ORDER BY id'), @@ -1542,10 +1554,11 @@ app.get('/api/backup', requireAdmin, async (req, res) => { pool.query('SELECT * FROM entry_photos ORDER BY id'), pool.query('SELECT * FROM modules ORDER BY id'), pool.query('SELECT * FROM student_photos ORDER BY id'), + pool.query('SELECT * FROM share_links ORDER BY id'), ]); const settings = {}; st.rows.forEach(r => { settings[r.key] = r.value; }); - const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows }; + const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows }; fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true }); const dir = path.join(__dirname, 'uploads'); @@ -1554,6 +1567,14 @@ app.get('/api/backup', requireAdmin, async (req, res) => { const fp = path.join(dir, f); if (fs.statSync(fp).isFile() && SAFE_NAME.test(f)) fs.copyFileSync(fp, path.join(staging, 'uploads', f)); } + const orig = path.join(dir, '.originals'); + if (fs.existsSync(orig)) { + fs.mkdirSync(path.join(staging, 'uploads', '.originals'), { recursive: true }); + for (const f of fs.readdirSync(orig)) { + const ofp = path.join(orig, f); + if (fs.statSync(ofp).isFile() && SAFE_NAME.test(f)) fs.copyFileSync(ofp, path.join(staging, 'uploads', '.originals', f)); + } + } } const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-'); const outPath = path.join(os.tmpdir(), `whatido-backup-${stamp}.tar.gz`); @@ -1641,6 +1662,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req await client.query('DELETE FROM entries'); await client.query('DELETE FROM modules'); await client.query('DELETE FROM students'); + await client.query('DELETE FROM share_links'); await client.query('DELETE FROM groups'); await client.query('DELETE FROM user_branches'); await client.query('DELETE FROM sessions'); @@ -1652,10 +1674,28 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [x.id, x.name, x.address, x.phone, x.created_at] ); } + for (const x of ndata.users) { + await client.query( + 'INSERT INTO users (id, username, password_hash, name, role, is_active, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [x.id, x.username, x.password_hash, x.name, x.role, x.is_active, x.created_at] + ); + } + for (const x of ndata.user_branches) { + await client.query( + 'INSERT INTO user_branches (user_id, branch_id) VALUES ($1,$2)', + [x.user_id, x.branch_id] + ); + } for (const x of ndata.groups) { await client.query( - 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end, branch_id, tutor_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)', - [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end, x.branch_id, x.tutor_id] + 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end, branch_id, tutor_id, cover_path) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', + [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end, x.branch_id, x.tutor_id, x.cover_path] + ); + } + for (const x of ndata.share_links) { + await client.query( + 'INSERT INTO share_links (id, token, name, group_id, student_name, date_from, date_to, show_student_names, expires_at, access_password_hash, message, link_url, show_student_message, show_entry_date, show_group_photos, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)', + [x.id, x.token, x.name, x.group_id, x.student_name, x.date_from, x.date_to, x.show_student_names, x.expires_at, x.access_password_hash, x.message, x.link_url, x.show_student_message, x.show_entry_date, x.show_group_photos, x.created_at] ); } for (const x of ndata.students) { @@ -1672,14 +1712,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } for (const x of ndata.entries) { await client.query( - 'INSERT INTO entries (id, student_name, group_id, module_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)', - [x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at] + 'INSERT INTO entries (id, student_name, group_id, module_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, photo_original_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)', + [x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.photo_original_path, x.deleted_at, x.created_at] ); } for (const x of ndata.project_files) { await client.query( - 'INSERT INTO project_files (id, entry_id, token, path, name, created_at) VALUES ($1,$2,$3,$4,$5,$6)', - [x.id, x.entry_id, x.token, x.path, x.name, x.created_at] + 'INSERT INTO project_files (id, entry_id, token, path, name, detached_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [x.id, x.entry_id, x.token, x.path, x.name, x.detached_at, x.created_at] ); } for (const x of ndata.group_photos) { @@ -1702,34 +1742,23 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [x.id, x.student_id, x.photo_path, x.created_at] ); } - for (const x of ndata.users) { - await client.query( - 'INSERT INTO users (id, username, password_hash, name, role, is_active, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', - [x.id, x.username, x.password_hash, x.name, x.role, x.is_active, x.created_at] - ); - } - for (const x of ndata.user_branches) { - await client.query( - 'INSERT INTO user_branches (user_id, branch_id) VALUES ($1,$2)', - [x.user_id, x.branch_id] - ); - } for (const [k, v] of Object.entries(ndata.settings)) { await client.query( 'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value', [k, String(v ?? '')] ); } - for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos']) { + for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links']) { const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl); await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]); } await client.query('COMMIT'); } catch (e) { - await client.query('ROLLBACK'); + await client.query('ROLLBACK').catch(() => {}); fs.rmSync(staging, { recursive: true, force: true }); cleanupUpload(req); - throw e; + console.error('Restore failed:', e.message); + return res.status(500).json({ error: 'Ошибка восстановления: ' + e.message }); } finally { client.release(); } @@ -1748,6 +1777,15 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req const fp = path.join(src, f); if (fs.statSync(fp).isFile()) fs.copyFileSync(fp, path.join(dir, f)); } + const orgSrc = path.join(src, '.originals'); + if (fs.existsSync(orgSrc)) { + fs.mkdirSync(path.join(dir, '.originals'), { recursive: true }); + for (const f of fs.readdirSync(orgSrc)) { + if (!SAFE_NAME.test(f)) continue; + const ofp = path.join(orgSrc, f); + if (fs.statSync(ofp).isFile()) fs.copyFileSync(ofp, path.join(dir, '.originals', f)); + } + } } } fs.rmSync(staging, { recursive: true, force: true });