const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']); const LESSON_REPORT_TEXT_MAX = 5000; const LESSON_REPORT_TOPIC_MAX = 300; const BACKUP_FORMAT_VERSION = 2; const BACKUP_MIN_FORMAT_VERSION = 1; const BACKUP_TABLES = [ 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', 'notification_reads', 'banned_ips', ]; const BACKUP_SEQUENCE_TABLES = [ 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', ]; function isSupportedBackupVersion(data) { if (!data || typeof data !== 'object' || !Array.isArray(data.groups)) return false; const v = Number(data.version); return Number.isInteger(v) && v >= BACKUP_MIN_FORMAT_VERSION && v <= BACKUP_FORMAT_VERSION; } function restoredCounts(ndata) { const out = {}; for (const tbl of BACKUP_TABLES) out[tbl] = Array.isArray(ndata[tbl]) ? ndata[tbl].length : 0; out.settings = Object.keys(ndata.settings || {}).length; return out; } const SAFE_NAME = /^[\w,.()-]+$/; function isSafeUploadPath(p) { if (typeof p !== 'string' || !p.startsWith('/uploads/')) return false; const name = p.slice('/uploads/'.length); return name !== '' && !name.includes('/') && !name.includes('..') && SAFE_NAME.test(name); } function reqInt(v) { const n = Number(v); if (!Number.isInteger(n)) throw new Error('Invalid integer'); return n; } function optInt(v, lo = -Infinity, hi = Infinity) { if (v === null || v === undefined || v === '') return null; const n = Number(v); if (!Number.isInteger(n) || n < lo || n > hi) throw new Error('Invalid integer'); return n; } function reqStr(v, max) { if (typeof v !== 'string') throw new Error('Invalid string'); const s = v.trim(); if (!s || s.length > max) throw new Error('Invalid string length'); return s; } function optStr(v, max) { if (v === null || v === undefined) return null; return reqStr(v, max); } function optTs(v) { if (v === null || v === undefined) return null; if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp'); return v; } function reqTs(v) { const s = optTs(v); if (!s) throw new Error('Invalid timestamp'); return s; } function optJsonText(v, max) { if (v === null || v === undefined) return null; if (typeof v === 'object') { if (Array.isArray(v)) throw new Error('Invalid json'); v = JSON.stringify(v); } const s = String(v); if (!s || s.length > max) throw new Error('Invalid json'); return s; } function reqIp(v) { const s = reqStr(v, 64); if (!/^[0-9a-fA-F:.]+$/.test(s)) throw new Error('Invalid ip'); return s; } function optTime(v) { if (v === null || v === undefined) return null; if (typeof v !== 'string' || !/^\d{2}:\d{2}(:\d{2})?$/.test(v)) throw new Error('Invalid time'); return v; } function optDate(v) { if (v === null || v === undefined) return null; if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error('Invalid date'); return v; } function reqDate(v) { const s = optDate(v); if (!s) throw new Error('Invalid date'); return s; } function optBool(v) { if (v === null || v === undefined) return null; return !!v; } function reqToken(v) { if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token'); return v; } function reqUploadPath(v, max) { if (typeof v !== 'string' || v.length > max) throw new Error('Invalid path'); if (!isSafeUploadPath(v)) throw new Error('Invalid upload path'); return v; } function optUploadPath(v, max) { if (v === null || v === undefined) return null; 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; } function reqPhotoRefPath(v, max) { if (typeof v !== 'string' || v.length > max) throw new Error('Invalid photo path'); if (isSafeUploadPath(v) || ORIGINALS_PATH_RE.test(v)) return v; throw new Error('Invalid photo path'); } function optPhotoRefPath(v, max) { if (v === null || v === undefined) return null; return reqPhotoRefPath(v, max); } function photoRefKey(p) { if (typeof p !== 'string') return null; if (isSafeUploadPath(p) || ORIGINALS_PATH_RE.test(p)) return p.slice('/uploads/'.length); return null; } const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']); function optAiText(v, max) { if (v === null || v === undefined) return null; return reqStr(v, max); } function reqAiStatus(v, fallback) { if (v === null || v === undefined) return fallback; const s = String(v); if (s === 'processing') return 'pending'; return AI_STATUSES.has(s) ? s : fallback; } const PROFILE_HREF_RE = /^(https?:\/\/|mailto:|tel:|\/|#)/i; const PROFILE_EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/; function profText(v, max) { if (v === null || v === undefined) return null; if (typeof v !== 'string') throw new Error('Ожидалась строка'); const s = v.trim(); if (!s) return null; if (s.length > max) throw new Error('Слишком длинное значение'); return s; } function profIcon(v) { const s = String(v || '').trim().toLowerCase(); return /^[a-z0-9-]{1,32}$/.test(s) ? s : 'link'; } function profHref(v) { const s = String(v || '').trim(); if (!s || s.length > 500) return null; return (PROFILE_HREF_RE.test(s) || PROFILE_EMAIL_RE.test(s)) ? s : null; } function profList(v, max, fn) { if (v === null || v === undefined) return []; if (!Array.isArray(v)) throw new Error('Ожидался список'); const out = []; for (const item of v.slice(0, max)) { const row = fn(item); if (row) out.push(row); } return out; } function sanitizeStudentProfile(raw) { if (raw === null || raw === undefined) return null; if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Ожидался объект профиля'); const out = { role: profText(raw.role, 200), status: profText(raw.status, 60), status_note: profText(raw.status_note, 120), city: profText(raw.city, 120), mentor: profText(raw.mentor, 150), joined: profText(raw.joined, 120), bio: profText(raw.bio, 2000), quote: profText(raw.quote, 300), tags: profList(raw.tags, 20, t => profText(t, 40)), achievements: profList(raw.achievements, 40, a => profText(a, 200)), contacts: profList(raw.contacts, 20, c => { if (!c || typeof c !== 'object') return null; const label = profText(c.label, 120); if (!label) return null; return { icon: profIcon(c.icon), label, href: profHref(c.href) }; }), skills: profList(raw.skills, 80, s => { if (!s || typeof s !== 'object') return null; const name = profText(s.name, 120); if (!name) return null; const value = (s.value === null || s.value === undefined || s.value === '') ? null : optInt(s.value, 0, 100); return { group: profText(s.group, 80) || 'Навыки', name, level: profText(s.level, 40), value }; }), experience: profList(raw.experience, 30, e => { if (!e || typeof e !== 'object') return null; const title = profText(e.title, 160); if (!title) return null; return { title, company: profText(e.company, 160), period: profText(e.period, 80), date: profText(e.date, 40), badge: profText(e.badge, 40), description: profText(e.description, 800), tags: profList(e.tags, 10, t => profText(t, 40)), }; }), education: profList(raw.education, 60, m => { if (!m || typeof m !== 'object') return null; const module = profText(m.module, 200); if (!module) return null; const progress = (m.progress === null || m.progress === undefined || m.progress === '') ? null : optInt(m.progress, 0, 100); return { module, progress, grade: profText(m.grade, 80), teacher: profText(m.teacher, 150) }; }), stats: profList(raw.stats, 12, s => { if (!s || typeof s !== 'object') return null; const label = profText(s.label, 80); const value = (s.value === null || s.value === undefined) ? null : String(s.value).trim().slice(0, 20); if (!label || !value) return null; return { icon: profIcon(s.icon || 'star'), value, suffix: profText(s.suffix, 20), label, hint: profText(s.hint, 120), delta: profText(s.delta, 60), }; }), }; const hasData = Object.values(out).some(v => (Array.isArray(v) ? v.length > 0 : v !== null)); return hasData ? out : null; } function normalizeRestoreData(data) { const groups = (data.groups || []).map(x => ({ id: reqInt(x.id), name: reqStr(x.name, 100), created_at: optTs(x.created_at), day_of_week: optInt(x.day_of_week, 0, 6), 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), deleted_at: optTs(x.deleted_at), purge_at: optTs(x.purge_at), })); const students = (data.students || []).map(x => ({ id: reqInt(x.id), name: reqStr(x.name, 150), created_at: optTs(x.created_at), group_id: optInt(x.group_id, 0, 2147483647), photo_path: optUploadPath(x.photo_path, 255), profile: sanitizeStudentProfile(x.profile), })); const entries = (data.entries || []).map(x => ({ id: reqInt(x.id), student_name: reqStr(x.student_name, 150), group_id: reqInt(x.group_id), module_id: optInt(x.module_id, 0, 2147483647), description: reqStr(x.description, 100000), description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000), description_ai: optAiText(x.description_ai, 100000), ai_status: reqAiStatus(x.ai_status, 'skipped'), 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), purge_at: optTs(x.purge_at), created_at: optTs(x.created_at), })); const project_files = (data.project_files || []).map(x => ({ id: reqInt(x.id), entry_id: optInt(x.entry_id, 0, 2147483647), 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 => ({ id: reqInt(x.id), name: reqStr(x.name, 200), address: optStr(x.address, 1000), phone: optStr(x.phone, 50), created_at: optTs(x.created_at), })); const users = (data.users || []).map(x => ({ id: reqInt(x.id), username: reqStr(x.username, 100), password_hash: reqStr(x.password_hash, 255), name: optStr(x.name, 150), role: (x.role === 'admin' || x.role === 'tutor') ? x.role : 'tutor', is_active: !!x.is_active, created_at: optTs(x.created_at), })); const user_branches = (data.user_branches || []).map(x => ({ user_id: reqInt(x.user_id), branch_id: reqInt(x.branch_id), })); const modules = (data.modules || []).map(x => ({ id: reqInt(x.id), name: reqStr(x.name, 200), lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0, is_active: x.is_active !== false, photo_path: optUploadPath(x.photo_path, 255), created_at: optTs(x.created_at), })); const entry_photos = (data.entry_photos || []).map(x => ({ id: reqInt(x.id), entry_id: reqInt(x.entry_id), photo_path: reqUploadPath(x.photo_path, 255), caption: optStr(x.caption, 10000), sort_order: optInt(x.sort_order, -2147483648, 2147483647), created_at: optTs(x.created_at), })); const student_photos = (data.student_photos || []).map(x => ({ id: reqInt(x.id), student_id: reqInt(x.student_id), photo_path: reqUploadPath(x.photo_path, 255), created_at: optTs(x.created_at), })); const group_photos = (data.group_photos || []).map(x => ({ id: reqInt(x.id), group_id: reqInt(x.group_id), photo_path: reqUploadPath(x.photo_path, 255), caption: optStr(x.caption, 10000), taken_at: optDate(x.taken_at), sort_order: optInt(x.sort_order, -2147483648, 2147483647), created_at: optTs(x.created_at), })); const share_links = (data.share_links || []).map(x => ({ id: reqInt(x.id), token: optStr(x.token, 40), name: reqStr(x.name, 200), group_id: optInt(x.group_id, 0, 2147483647), student_name: optStr(x.student_name, 150), date_from: optDate(x.date_from), date_to: optDate(x.date_to), show_student_names: optBool(x.show_student_names), expires_at: optTs(x.expires_at), access_password_hash: optStr(x.access_password_hash, 255), message: optStr(x.message, 2000), link_url: optStr(x.link_url, 500), show_student_message: optBool(x.show_student_message), show_entry_date: optBool(x.show_entry_date), show_group_photos: optBool(x.show_group_photos), created_at: optTs(x.created_at), })); const lesson_reports = (data.lesson_reports || []).map(x => ({ id: reqInt(x.id), group_id: reqInt(x.group_id), lesson_date: reqDate(x.lesson_date), lesson_time: optTime(x.lesson_time), topic: optStr(x.topic, LESSON_REPORT_TOPIC_MAX), text: reqStr(x.text, LESSON_REPORT_TEXT_MAX), text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX), text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX), ai_status: optStr(x.ai_status, 20), ai_checked_at: optTs(x.ai_checked_at), ai_error: optStr(x.ai_error, 500), author_id: optInt(x.author_id, 0, 2147483647), branch_id: optInt(x.branch_id, 0, 2147483647), created_at: optTs(x.created_at), updated_at: optTs(x.updated_at), })); const lesson_report_versions = (data.lesson_report_versions || []).map(x => ({ id: reqInt(x.id), lesson_report_id: reqInt(x.lesson_report_id), text: reqStr(x.text, LESSON_REPORT_TEXT_MAX), source: optStr(x.source, 20), author_id: optInt(x.author_id, 0, 2147483647), created_at: optTs(x.created_at), })); const settings = {}; for (const [k, v] of Object.entries(data.settings || {})) { settings[reqStr(k, 100)] = reqStr(String(v), 10000); } const audit_log = (data.audit_log || []).map(x => ({ id: reqInt(x.id), user_id: optInt(x.user_id, 0, 2147483647), action: reqStr(x.action, 100), target: optJsonText(x.target, 200000), ip: optStr(x.ip, 45), created_at: optTs(x.created_at), })); const NOTIFICATION_LEVELS = new Set(['info', 'success', 'warning', 'critical']); const notifications = (data.notifications || []).map(x => ({ id: reqInt(x.id), type: reqStr(x.type, 50), level: (x.level && NOTIFICATION_LEVELS.has(x.level)) ? x.level : 'info', title: reqStr(x.title, 200), body: optStr(x.body, 2000), link: optStr(x.link, 255), target: optJsonText(x.target, 20000), admin_only: !!x.admin_only, branch_id: optInt(x.branch_id, 0, 2147483647), created_at: optTs(x.created_at), })); const notification_reads = (data.notification_reads || []).map(x => ({ user_id: reqInt(x.user_id), notification_id: reqInt(x.notification_id), read_at: optTs(x.read_at), })); const banned_ips = (data.banned_ips || []).map(x => ({ ip: reqIp(x.ip), reason: reqStr(x.reason, 100), banned_until: reqTs(x.banned_until), created_at: optTs(x.created_at), })); const PHOTO_JOB_STATUSES = new Set(['pending', 'processing', 'done', 'error', 'rejected']); const photo_jobs = (data.photo_jobs || []).map(x => ({ id: reqInt(x.id), entry_id: reqInt(x.entry_id), action: (x.action && PHOTO_JOB_ACTIONS.has(x.action)) ? x.action : 'ai', params: optJsonText(x.params, 20000), before_path: optPhotoRefPath(x.before_path, 255), after_path: optPhotoRefPath(x.after_path, 255), status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending', applied: !!x.applied, attempts: optInt(x.attempts, 0, 2147483647) ?? 0, error: optStr(x.error, 4000), created_at: optTs(x.created_at), finished_at: optTs(x.finished_at), })); return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips }; } module.exports = { PHOTO_JOB_ACTIONS, LESSON_REPORT_TEXT_MAX, LESSON_REPORT_TOPIC_MAX, SAFE_NAME, isSafeUploadPath, photoRefKey, sanitizeStudentProfile, reqInt, optInt, reqStr, optStr, optTs, reqTs, optDate, optUploadPath, normalizeRestoreData, BACKUP_FORMAT_VERSION, BACKUP_MIN_FORMAT_VERSION, BACKUP_TABLES, BACKUP_SEQUENCE_TABLES, restoredCounts, isSupportedBackupVersion, };