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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user