feat(notifications): центр уведомлений о системных событиях
Добавлена система уведомлений о системных и фоновых событиях (новые записи журнала, обработка фото, авто-проверка текста, блокировки IP, бэкапы). - backend (server.js, worker.js): - каталог NOTIFY_TYPES с метаданными и уровнями - таблицы notifications и notification_reads в db/init.sql и db/migration.sql - SSE-стрим GET /api/notifications/stream через Redis pub/sub с in-memory fallback - REST API: список, счётчик непрочитанных, отметка о прочтении, удаление, очистка - настройки уведомлений в settings (notify_enabled, notify_retention_days, notify_<тип>) - автоматическая очистка старых уведомлений по расписанию - frontend: - колокольчик со счётчиком непрочитанных в шапке (admin.js) - страница списка уведомлений public/notifications.html и public/js/notifications.js - секция настроек уведомлений в public/settings.html и public/js/settings.js - стили для уведомлений в public/admin.css - тесты и документация: - добавлены проверки в api.smoketest.js - обновлены README.md и AGENTS.md
This commit is contained in:
@@ -52,8 +52,10 @@ lister.on('notification', (msg) => {
|
||||
const type = payload && payload.type ? payload.type : 'entry_created';
|
||||
if (type === 'ai_status') {
|
||||
broadcastAiStatus(payload);
|
||||
notifyEntryAi(payload).catch(err => console.error('Notify AI:', err.message));
|
||||
} else {
|
||||
broadcastEntryChanged();
|
||||
notifyEntryCreated(payload && payload.id).catch(err => console.error('Notify entry:', err.message));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -175,6 +177,229 @@ function invalidateShare() { cacheDrop('share:payload:'); }
|
||||
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
||||
function invalidateAll() { cache.clear().catch(err => console.error('Cache clear failed:', err.message)); }
|
||||
|
||||
// --- Notifications ---
|
||||
const NOTIFY_CHANNEL = 'whatido:notifications';
|
||||
const NOTIFY_RETENTION_DEFAULT_DAYS = 30;
|
||||
|
||||
const NOTIFY_TYPES = {
|
||||
'entry.new': { label: 'Новая запись в журнале', hint: 'Ответ ученика отправлен через форму или запись добавлена вручную', icon: 'book-open', level: 'info', enabled: true, admin: false },
|
||||
'entry.ai.corrected': { label: 'ИИ исправил текст', hint: 'Автопроверка изменила текст записи', icon: 'sparkles', level: 'info', enabled: false, admin: false },
|
||||
'entry.ai.error': { label: 'Ошибка автопроверки текста', hint: 'ИИ не смог обработать запись после всех попыток', icon: 'bot', level: 'warning', enabled: true, admin: false },
|
||||
'photo.job.done': { label: 'Фото обработано', hint: 'Нейросеть или сервер улучшили фото в записи', icon: 'image', level: 'info', enabled: true, admin: false },
|
||||
'photo.job.error': { label: 'Ошибка обработки фото', hint: 'Очередь улучшения фото исчерпала попытки', icon: 'image-off', level: 'warning', enabled: true, admin: false },
|
||||
'ip.ban': { label: 'IP отправлен в бан', hint: 'Автоблокировка за спам или подбор пароля либо блокировка вручную', icon: 'shield-off', level: 'warning', enabled: true, admin: true },
|
||||
'backup.restore': { label: 'Восстановление из бэкапа', hint: 'Данные системы заменены содержимым архива', icon: 'database', level: 'critical', enabled: true, admin: true },
|
||||
'backup.create': { label: 'Создан архив бэкапа', hint: 'Архив данных скачан из админ-панели', icon: 'download', level: 'info', enabled: false, admin: true },
|
||||
'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true },
|
||||
};
|
||||
|
||||
function notifySettingKey(type) {
|
||||
return 'notify_' + String(type).replace(/\./g, '_');
|
||||
}
|
||||
|
||||
function notifyCatalog() {
|
||||
return Object.entries(NOTIFY_TYPES)
|
||||
.filter(([, spec]) => !spec.hidden)
|
||||
.map(([type, spec]) => ({
|
||||
type,
|
||||
key: notifySettingKey(type),
|
||||
label: spec.label,
|
||||
hint: spec.hint,
|
||||
icon: spec.icon,
|
||||
level: spec.level || 'info',
|
||||
admin_only: !!spec.admin,
|
||||
default_enabled: spec.enabled !== false,
|
||||
}));
|
||||
}
|
||||
|
||||
async function notifyTypeEnabled(type) {
|
||||
const spec = NOTIFY_TYPES[type];
|
||||
if (!spec) return false;
|
||||
if (String(await getSetting('notify_enabled', 'true')) === 'false') return false;
|
||||
const def = spec.enabled !== false ? 'true' : 'false';
|
||||
return String(await getSetting(notifySettingKey(type), def)) !== 'false';
|
||||
}
|
||||
|
||||
const notifyClients = new Set();
|
||||
|
||||
function notificationVisible(user, n) {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
if (n.admin_only) return false;
|
||||
if (n.branch_id === null || n.branch_id === undefined) return true;
|
||||
return (user.branch_ids || []).map(Number).includes(Number(n.branch_id));
|
||||
}
|
||||
|
||||
function writeNotifyFrame(client, event, data) {
|
||||
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function publishNotification(row) {
|
||||
cache.publish(NOTIFY_CHANNEL, row).catch(err => console.error('Notify publish failed:', err.message));
|
||||
}
|
||||
|
||||
cache.on(NOTIFY_CHANNEL, message => {
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(message); } catch (e) { return; }
|
||||
if (!payload || !payload.id) return;
|
||||
for (const client of [...notifyClients]) {
|
||||
if (!notificationVisible(client.user, payload)) continue;
|
||||
try { writeNotifyFrame(client, 'notification', payload); } catch (e) { notifyClients.delete(client); }
|
||||
}
|
||||
});
|
||||
|
||||
async function pushNotification({ type, title, body, link, target, branchId, level, adminOnly }) {
|
||||
const spec = NOTIFY_TYPES[type];
|
||||
if (!spec) return null;
|
||||
if (!(await notifyTypeEnabled(type))) return null;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notifications (type, level, title, body, link, target, admin_only, branch_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, type, level, title, body, link, target, admin_only, branch_id, created_at`,
|
||||
[
|
||||
type,
|
||||
level || spec.level || 'info',
|
||||
String(title || spec.label).slice(0, 200),
|
||||
body ? String(body).slice(0, 2000) : null,
|
||||
link || null,
|
||||
target && Object.keys(target).length ? JSON.stringify(target) : null,
|
||||
adminOnly === undefined ? !!spec.admin : !!adminOnly,
|
||||
branchId || null,
|
||||
]
|
||||
);
|
||||
const row = rows[0];
|
||||
publishNotification(row);
|
||||
return row;
|
||||
} catch (e) {
|
||||
console.error('Notification failed:', type, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyEntry(entryId, { type, title, body, link, target, level }) {
|
||||
const id = parseInt(entryId, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT e.id, e.student_name, e.group_id, g.name AS group_name, g.branch_id
|
||||
FROM entries e LEFT JOIN groups g ON g.id = e.group_id WHERE e.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
const ctx = rows[0];
|
||||
const fill = s => String(s === null || s === undefined ? '' : s)
|
||||
.replace(/\{student\}/g, ctx.student_name || '—')
|
||||
.replace(/\{group\}/g, ctx.group_name || '—');
|
||||
return pushNotification({
|
||||
type,
|
||||
level,
|
||||
title: fill(title),
|
||||
body: fill(body),
|
||||
link: link || 'journal.html',
|
||||
target: Object.assign({ entry_id: ctx.id, student_name: ctx.student_name, group_name: ctx.group_name }, target || {}),
|
||||
branchId: ctx.branch_id,
|
||||
});
|
||||
}
|
||||
|
||||
async function notifyEntryCreated(entryId) {
|
||||
return notifyEntry(entryId, {
|
||||
type: 'entry.new',
|
||||
title: 'Новая запись: {student}',
|
||||
body: 'Группа {group}',
|
||||
});
|
||||
}
|
||||
|
||||
async function notifyEntryAi(payload) {
|
||||
const status = payload && payload.status;
|
||||
if (status !== 'done' && status !== 'error') return null;
|
||||
if (status === 'done' && String(payload.description ?? '') === String(payload.description_original ?? '')) return null;
|
||||
if (status === 'error') {
|
||||
const err = payload.error ? ' · ' + String(payload.error).slice(0, 300) : '';
|
||||
return notifyEntry(payload.id, {
|
||||
type: 'entry.ai.error',
|
||||
title: 'ИИ не смог проверить текст: {student}',
|
||||
body: `Группа {group}${err}`,
|
||||
target: { error: payload.error || null },
|
||||
});
|
||||
}
|
||||
return notifyEntry(payload.id, {
|
||||
type: 'entry.ai.corrected',
|
||||
title: 'ИИ исправил текст: {student}',
|
||||
body: 'Группа {group}',
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeOldNotifications() {
|
||||
const raw = parseInt(await getSetting('notify_retention_days', String(NOTIFY_RETENTION_DEFAULT_DAYS)), 10);
|
||||
const days = Number.isFinite(raw) && raw >= 1 ? Math.min(raw, 365) : NOTIFY_RETENTION_DEFAULT_DAYS;
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM notifications WHERE created_at < now() - ($1 || ' days')::interval`,
|
||||
[String(days)]
|
||||
);
|
||||
if (rowCount) console.log(`Notifications pruned: ${rowCount} (older than ${days} days)`);
|
||||
}
|
||||
|
||||
async function ensureNotificationsTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT,
|
||||
link VARCHAR(255),
|
||||
target JSONB,
|
||||
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id)`);
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS notification_reads (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
read_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (user_id, notification_id)
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id)`);
|
||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ('notify_retention_days', $1) ON CONFLICT (key) DO NOTHING`,
|
||||
[String(NOTIFY_RETENTION_DEFAULT_DAYS)]
|
||||
);
|
||||
for (const [type, spec] of Object.entries(NOTIFY_TYPES)) {
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
|
||||
[notifySettingKey(type), spec.enabled !== false ? 'true' : 'false']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function notificationsScope(user) {
|
||||
const s = branchScope(user);
|
||||
if (s.admin) return { cond: '', params: [] };
|
||||
const params = [];
|
||||
let cond = 'n.admin_only = false';
|
||||
if (s.ids.length) {
|
||||
cond += ` AND (n.branch_id IS NULL OR n.branch_id IN (${s.ids.map(id => '$' + params.push(id)).join(',')}))`;
|
||||
} else {
|
||||
cond += ' AND n.branch_id IS NULL';
|
||||
}
|
||||
return { cond, params };
|
||||
}
|
||||
|
||||
async function notificationsCounts(user) {
|
||||
const scope = notificationsScope(user);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT count(*)::int AS total,
|
||||
count(*) FILTER (WHERE r.user_id IS NULL)::int AS unread
|
||||
FROM notifications n
|
||||
LEFT JOIN notification_reads r ON r.notification_id = n.id AND r.user_id = $1
|
||||
${scope.cond ? 'WHERE ' + scope.cond : ''}`,
|
||||
[user.id, ...scope.params]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
const BAN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
||||
const banKey = ip => 'ban:' + ip;
|
||||
@@ -188,6 +413,13 @@ async function banIP(req, reason, ms) {
|
||||
await banIpAddr(ipOf(req), reason, ms, req);
|
||||
}
|
||||
|
||||
const BAN_REASON_LABELS = {
|
||||
honeypot: 'Антиспам-поле',
|
||||
'login-bruteforce': 'Подбор пароля входа',
|
||||
'share-password-bruteforce': 'Подбор пароля ссылки',
|
||||
manual: 'Вручную',
|
||||
};
|
||||
|
||||
async function banIpAddr(ip, reason, ms, actorReq) {
|
||||
const until = new Date(Date.now() + ms);
|
||||
await cache.set(banKey(ip), { reason, banned_until: until.toISOString() }, ms);
|
||||
@@ -196,6 +428,15 @@ async function banIpAddr(ip, reason, ms, actorReq) {
|
||||
[ip, reason, until.toISOString()]
|
||||
);
|
||||
await logAudit(actorReq, 'ip.ban', { ip, reason });
|
||||
const hours = Math.max(1, Math.round(ms / 3600000));
|
||||
await pushNotification({
|
||||
type: 'ip.ban',
|
||||
title: `IP отправлен в бан: ${ip}`,
|
||||
body: `${BAN_REASON_LABELS[reason] || reason} · блокировка на ${hours} ч.`,
|
||||
link: 'bans.html',
|
||||
target: { ip, reason },
|
||||
adminOnly: true,
|
||||
});
|
||||
console.log(`IP banned: ${ip} (${reason})`);
|
||||
}
|
||||
|
||||
@@ -1144,6 +1385,130 @@ app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Notifications ---
|
||||
const notificationLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 600,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: cache.rateLimitStore('notify', 15 * 60 * 1000),
|
||||
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
||||
});
|
||||
|
||||
app.get('/api/notifications', requireAuth, notificationLimiter, async (req, res) => {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 30, 1), 100);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
const unreadOnly = req.query.unread === '1';
|
||||
const scope = notificationsScope(req.user);
|
||||
const params = [req.user.id, ...scope.params];
|
||||
const where = [];
|
||||
if (scope.cond) where.push(scope.cond);
|
||||
if (unreadOnly) where.push('r.user_id IS NULL');
|
||||
const { rows } = await pool.query(
|
||||
`SELECT n.id, n.type, n.level, n.title, n.body, n.link, n.target, n.admin_only, n.branch_id, n.created_at,
|
||||
(r.user_id IS NOT NULL) AS read
|
||||
FROM notifications n
|
||||
LEFT JOIN notification_reads r ON r.notification_id = n.id AND r.user_id = $1
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY n.id DESC LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`,
|
||||
params
|
||||
);
|
||||
const counts = await notificationsCounts(req.user);
|
||||
res.json({ items: rows, total: counts.total, unread: counts.unread });
|
||||
});
|
||||
|
||||
app.get('/api/notifications/meta', requireAdmin, async (_, res) => {
|
||||
res.json({
|
||||
enabled: String(await getSetting('notify_enabled', 'true')) !== 'false',
|
||||
retention_days: parseInt(await getSetting('notify_retention_days', String(NOTIFY_RETENTION_DEFAULT_DAYS)), 10) || NOTIFY_RETENTION_DEFAULT_DAYS,
|
||||
types: notifyCatalog(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/notifications/stream', async (req, res) => {
|
||||
let user = null;
|
||||
try {
|
||||
const token = req.headers['x-auth-token'] || req.query.token;
|
||||
user = await loadUserByToken(token);
|
||||
} catch (e) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
if (!user || !user.is_active) return res.status(401).end();
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write(':ok\n\n');
|
||||
const client = { res, user };
|
||||
notifyClients.add(client);
|
||||
notificationsCounts(user)
|
||||
.then(counts => writeNotifyFrame(client, 'ready', counts))
|
||||
.catch(err => console.error('Notify counts failed:', err.message));
|
||||
const ping = setInterval(() => {
|
||||
try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); notifyClients.delete(client); }
|
||||
}, 25000);
|
||||
req.on('close', () => { clearInterval(ping); notifyClients.delete(client); });
|
||||
});
|
||||
|
||||
app.post('/api/notifications/read-all', requireAuth, notificationLimiter, async (req, res) => {
|
||||
const scope = notificationsScope(req.user);
|
||||
const { rowCount } = await pool.query(
|
||||
`INSERT INTO notification_reads (user_id, notification_id)
|
||||
SELECT $1, n.id FROM notifications n
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM notification_reads r WHERE r.notification_id = n.id AND r.user_id = $1
|
||||
)${scope.cond ? ' AND (' + scope.cond + ')' : ''}
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[req.user.id, ...scope.params]
|
||||
);
|
||||
const counts = await notificationsCounts(req.user);
|
||||
res.json({ ok: true, marked: rowCount, unread: counts.unread });
|
||||
});
|
||||
|
||||
app.post('/api/notifications/:id/read', requireAuth, notificationLimiter, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const scope = notificationsScope(req.user);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT n.id FROM notifications n WHERE n.id = $1${scope.cond ? ' AND (' + scope.cond + ')' : ''}`,
|
||||
[id, ...scope.params]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
await pool.query(
|
||||
`INSERT INTO notification_reads (user_id, notification_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[req.user.id, id]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/notifications/test', requireAdmin, notificationLimiter, async (req, res) => {
|
||||
const row = await pushNotification({
|
||||
type: 'system.test',
|
||||
title: 'Тестовое уведомление',
|
||||
body: `Отправлено из настроек пользователем ${req.user.username}`,
|
||||
link: 'notifications.html',
|
||||
adminOnly: true,
|
||||
});
|
||||
res.json({ ok: true, id: row ? row.id : null, delivered: !!row });
|
||||
});
|
||||
|
||||
app.delete('/api/notifications/:id', requireAdmin, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const { rowCount } = await pool.query('DELETE FROM notifications WHERE id = $1', [id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Not found' });
|
||||
await logAudit(req, 'notifications.delete', { id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/notifications', requireAdmin, async (req, res) => {
|
||||
const { rowCount } = await pool.query('DELETE FROM notifications');
|
||||
await logAudit(req, 'notifications.clear', { deleted: rowCount });
|
||||
res.json({ ok: true, deleted: rowCount });
|
||||
});
|
||||
|
||||
// --- Users (admin only) ---
|
||||
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
@@ -1351,6 +1716,15 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
||||
if (key === 'system_logo' && String(value) !== '' && !isSafeUploadPath(String(value))) {
|
||||
return res.status(400).json({ error: 'system_logo — некорректный путь' });
|
||||
}
|
||||
if (key === 'notify_retention_days') {
|
||||
const n = parseInt(String(value), 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 365) {
|
||||
return res.status(400).json({ error: 'notify_retention_days должен быть целым числом от 1 до 365' });
|
||||
}
|
||||
}
|
||||
if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
|
||||
return res.status(400).json({ error: `${key} должен быть true или false` });
|
||||
}
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -1974,6 +2348,13 @@ app.post('/api/backup', requireAdmin, async (req, res) => {
|
||||
const expiresAt = Date.now() + BACKUP_TTL_MS;
|
||||
backupTickets.set(token, { file: archive.file, name: archive.name, size: archive.size, expiresAt });
|
||||
await logAudit(req, 'backup.download', { size: archive.size });
|
||||
await pushNotification({
|
||||
type: 'backup.create',
|
||||
title: 'Создан архив бэкапа',
|
||||
body: `${archive.name} · ${(archive.size / 1024 / 1024).toFixed(1)} МБ`,
|
||||
link: 'settings.html#sec-backup',
|
||||
adminOnly: true,
|
||||
});
|
||||
res.json({
|
||||
url: `/api/backup/${token}`,
|
||||
filename: archive.name,
|
||||
@@ -2007,6 +2388,13 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const archive = await buildBackupArchive();
|
||||
await logAudit(req, 'backup.download', { size: archive.size });
|
||||
await pushNotification({
|
||||
type: 'backup.create',
|
||||
title: 'Создан архив бэкапа',
|
||||
body: `${archive.name} · ${(archive.size / 1024 / 1024).toFixed(1)} МБ`,
|
||||
link: 'settings.html#sec-backup',
|
||||
adminOnly: true,
|
||||
});
|
||||
sendBackupArchive(res, archive);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -2213,6 +2601,13 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
||||
await ensureFirstAdmin().catch(err => console.error('First admin:', err));
|
||||
await logAudit(req, 'backup.restore', {});
|
||||
await pushNotification({
|
||||
type: 'backup.restore',
|
||||
title: 'Восстановление из бэкапа завершено',
|
||||
body: `Данные заменены архивом · пользователь ${req.user.username}`,
|
||||
link: 'settings.html',
|
||||
adminOnly: true,
|
||||
});
|
||||
invalidateAll();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
@@ -5542,6 +5937,9 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
|
||||
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
|
||||
try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
|
||||
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();
|
||||
try { await pool.query(`INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Camera setting:', err); }
|
||||
try { await pool.query(`INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Trash purge days setting:', err); }
|
||||
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
||||
@@ -5570,6 +5968,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
photoAiUrl: PHOTO_AI_URL,
|
||||
uploadsDir: UPLOADS_DIR,
|
||||
storage,
|
||||
notifyEvent: notifyEntry,
|
||||
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
|
||||
});
|
||||
photoWorker.start();
|
||||
|
||||
Reference in New Issue
Block a user