feat(backup): формат бэкапа v2 — audit/уведомления/баны, counts и одноразовый тикет
Валидация и нормализация restore вынесены из server.js в backup-restore.js, покрыты юнит-тестами backup.selftest.js (30+ проверок, без стенда). В бэкап добавлены audit_log, notifications, notification_reads, banned_ips, счётчики counts, метаданные приложения и версия формата (принимаются 1..2). Тикет бэкапа стал одноразовым: файл удаляется сразу после отдачи, uploadBackup фильтрует расширения. sessions в бэкап не входит намеренно. sweepOrphanedUploads учитывает аватары, обложки, оригиналы фото и photo_jobs — иначе restore сносил файлы сразу после восстановления. storage.getStream при STORAGE_LOCAL_FALLBACK читает локальный файл, а не S3.
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
|
||||
const LESSON_REPORT_TEXT_MAX = 5000;
|
||||
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),
|
||||
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,
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user