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
This commit is contained in:
dev
2026-09-23 13:49:25 +03:00
parent 4623358f21
commit 218c3f825d
+61 -23
View File
@@ -1294,6 +1294,14 @@ function optUploadPath(v, max) {
return reqUploadPath(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']); const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
function optAiText(v, max) { function optAiText(v, max) {
@@ -1418,6 +1426,8 @@ function normalizeRestoreData(data) {
time_start: optTime(x.time_start), time_start: optTime(x.time_start),
time_end: optTime(x.time_end), time_end: optTime(x.time_end),
branch_id: optInt(x.branch_id, 0, 2147483647), 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 => ({ const students = (data.students || []).map(x => ({
id: reqInt(x.id), id: reqInt(x.id),
@@ -1439,6 +1449,7 @@ function normalizeRestoreData(data) {
ai_checked_at: optTs(x.ai_checked_at), ai_checked_at: optTs(x.ai_checked_at),
ai_error: optAiText(x.ai_error, 500), ai_error: optAiText(x.ai_error, 500),
photo_path: optUploadPath(x.photo_path, 255), photo_path: optUploadPath(x.photo_path, 255),
photo_original_path: optOriginalsPath(x.photo_original_path, 255),
deleted_at: optTs(x.deleted_at), deleted_at: optTs(x.deleted_at),
created_at: optTs(x.created_at), created_at: optTs(x.created_at),
})); }));
@@ -1448,6 +1459,7 @@ function normalizeRestoreData(data) {
token: reqToken(x.token), token: reqToken(x.token),
path: reqUploadPath(x.path, 255), path: reqUploadPath(x.path, 255),
name: reqStr(x.name, 255), name: reqStr(x.name, 255),
detached_at: optTs(x.detached_at),
created_at: optTs(x.created_at), created_at: optTs(x.created_at),
})); }));
const branches = (data.branches || []).map(x => ({ const branches = (data.branches || []).map(x => ({
@@ -1529,7 +1541,7 @@ function normalizeRestoreData(data) {
app.get('/api/backup', requireAdmin, async (req, res) => { app.get('/api/backup', requireAdmin, async (req, res) => {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try { 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 groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries 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 entry_photos ORDER BY id'),
pool.query('SELECT * FROM modules ORDER BY id'), pool.query('SELECT * FROM modules ORDER BY id'),
pool.query('SELECT * FROM student_photos ORDER BY id'), pool.query('SELECT * FROM student_photos ORDER BY id'),
pool.query('SELECT * FROM share_links ORDER BY id'),
]); ]);
const settings = {}; const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; }); 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.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true }); fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
const dir = path.join(__dirname, 'uploads'); const dir = path.join(__dirname, 'uploads');
@@ -1554,6 +1567,14 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
const fp = path.join(dir, f); const fp = path.join(dir, f);
if (fs.statSync(fp).isFile() && SAFE_NAME.test(f)) fs.copyFileSync(fp, path.join(staging, 'uploads', 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 stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const outPath = path.join(os.tmpdir(), `whatido-backup-${stamp}.tar.gz`); 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 entries');
await client.query('DELETE FROM modules'); await client.query('DELETE FROM modules');
await client.query('DELETE FROM students'); await client.query('DELETE FROM students');
await client.query('DELETE FROM share_links');
await client.query('DELETE FROM groups'); await client.query('DELETE FROM groups');
await client.query('DELETE FROM user_branches'); await client.query('DELETE FROM user_branches');
await client.query('DELETE FROM sessions'); 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] [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) { for (const x of ndata.groups) {
await client.query( 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)', '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.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) { 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) { for (const x of ndata.entries) {
await client.query( 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)', '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.deleted_at, x.created_at] [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) { for (const x of ndata.project_files) {
await client.query( await client.query(
'INSERT INTO project_files (id, entry_id, token, path, name, created_at) VALUES ($1,$2,$3,$4,$5,$6)', '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.created_at] [x.id, x.entry_id, x.token, x.path, x.name, x.detached_at, x.created_at]
); );
} }
for (const x of ndata.group_photos) { 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] [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)) { for (const [k, v] of Object.entries(ndata.settings)) {
await client.query( await client.query(
'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value', 'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
[k, String(v ?? '')] [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); 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('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
} }
await client.query('COMMIT'); await client.query('COMMIT');
} catch (e) { } catch (e) {
await client.query('ROLLBACK'); await client.query('ROLLBACK').catch(() => {});
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
cleanupUpload(req); cleanupUpload(req);
throw e; console.error('Restore failed:', e.message);
return res.status(500).json({ error: 'Ошибка восстановления: ' + e.message });
} finally { } finally {
client.release(); client.release();
} }
@@ -1748,6 +1777,15 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
const fp = path.join(src, f); const fp = path.join(src, f);
if (fs.statSync(fp).isFile()) fs.copyFileSync(fp, path.join(dir, 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 }); fs.rmSync(staging, { recursive: true, force: true });