Воркер и сервер принимают параметры ИИ-обработки фото: модель апскейла (x2plus / general-x4v3 / animevideo-v3), режим лиц (off / face / all), модель лиц (gfpgan / codeformer) и strength. Пустое тело запроса ведёт себя как раньше: action='ai', params=NULL (инвариант I2). worker.js: - таймаут выбирается по params.face: PHOTO_AI_TIMEOUT_MS для апскейла, PHOTO_AI_FACE_TIMEOUT_MS (600000) для face-режима - runAiEnhance шлёт model/face/face_model/strength и понимает оба контракта: JSON с image_base64 и сырой image/jpeg старого сервиса - тело не-2xx ответа больше не выбрасывается: readErrorBody() добавляет причину к сообщению, иначе оператор видит «ИИ-сервис ответил 400» без объяснения - applyResult пишет в аудит model/face/face_model/device/faces_found/ elapsed_ms/warnings и выбирает текст уведомления по факту режима; warnings видны оператору, если лица не нашлись - CONFIG: + face_timeout_ms, default_model, face_model server.js: - POST /api/entries/:id/photo/enhance-ai принимает и валидирует тело до запроса записи — невалидный вход даёт 400, а не 404/500 - PHOTO_JOB_ACTIONS вынесен на уровень модуля, + ai_face и ai_upscale - GET /api/photo-ai/health (requireAdmin) — прямой прокси /health - photoAiHealth(timeoutMs), в «Статусе стека» вызывается с 2000 мс - getStackInfo(): блок photo_ai (engine, host, device, vram, модели) - настройки photo_ai_face_mode / photo_ai_face_model / photo_ai_device_pref с валидацией в PUT /api/settings, дефолты в init.sql, migration.sql, public-settings и ensurePhotoJobsTable() Приёмка (живой стек, CPU + отдельно CUDA) — в TODO_PHOTO_FACE_AI.md, журнал раздела 4: I1 байт-в-байт 5/5 и совпадение sha256 с raw-путём, I2, I3 при пустом PHOTO_AI_URL, I5 на обрыве и на 503 с Retry-After, 7 невалидных тел → 400, api.smoketest.js 57 PASS.
6176 lines
263 KiB
JavaScript
6176 lines
263 KiB
JavaScript
const express = require('express');
|
||
const { Pool, types } = require('pg');
|
||
const multer = require('multer');
|
||
const rateLimit = require('express-rate-limit');
|
||
const helmet = require('helmet');
|
||
const bcrypt = require('bcrypt');
|
||
const heicConvert = require('heic-convert');
|
||
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
|
||
const { createZipWriter, renderStudentReport } = require('./student-report');
|
||
|
||
const { createStorage } = require('./storage');
|
||
const { createRedis } = require('./redis');
|
||
const { buildEntryDiff, textDiff, normalizeEditSource, stripDiffs } = require('./diff');
|
||
|
||
const https = require('https');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
|
||
types.setTypeParser(1082, v => v);
|
||
|
||
const app = express();
|
||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||
const storage = createStorage({ dir: UPLOADS_DIR });
|
||
|
||
const pgClient = require('pg').Client;
|
||
const lister = new pgClient({ connectionString: process.env.DATABASE_URL });
|
||
let listerConnected = false;
|
||
function connectLister() {
|
||
if (listerConnected) return;
|
||
listerConnected = true;
|
||
lister.connect()
|
||
.then(() => lister.query('LISTEN entries_changed'))
|
||
.catch(e => {
|
||
listerConnected = false;
|
||
console.error('LISTEN entries_changed failed:', e.message);
|
||
setTimeout(connectLister, 5000);
|
||
});
|
||
}
|
||
connectLister();
|
||
lister.on('error', (e) => {
|
||
console.error('LISTEN connection error:', e.message);
|
||
});
|
||
lister.on('end', () => {
|
||
listerConnected = false;
|
||
setTimeout(connectLister, 3000);
|
||
});
|
||
lister.on('notification', (msg) => {
|
||
let payload = null;
|
||
try { payload = JSON.parse(msg.payload || '{}'); } catch (e) { payload = null; }
|
||
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));
|
||
}
|
||
});
|
||
|
||
const cache = createRedis({ url: process.env.REDIS_URL, prefix: process.env.REDIS_PREFIX });
|
||
const SETTINGS_TTL_MS = 30 * 1000;
|
||
const PUBLIC_TTL_MS = 60 * 1000;
|
||
const SHARE_TTL_MS = 60 * 1000;
|
||
const STATS_TTL_MS = 15 * 1000;
|
||
const SYSTEM_TTL_MS = 30 * 1000;
|
||
const SESSION_CACHE_TTL_MS = 30 * 1000;
|
||
|
||
async function cacheGet(key) {
|
||
return cache.get(key);
|
||
}
|
||
|
||
async function cacheSet(key, value, ttlMs) {
|
||
return cache.set(key, value, ttlMs);
|
||
}
|
||
|
||
function cacheDrop(keyPrefix) {
|
||
cache.dropPrefix(keyPrefix).catch(err => console.error('Cache drop failed:', err.message));
|
||
}
|
||
|
||
async function cacheWrap(key, ttlMs, fn) {
|
||
return cache.wrap(key, ttlMs, fn);
|
||
}
|
||
|
||
function scopeKey(user) {
|
||
if (!user) return 'anon';
|
||
const s = branchScope(user);
|
||
if (s.admin) return 'all';
|
||
return s.ids.length ? s.ids.slice().sort((a, b) => a - b).join('-') : 'none';
|
||
}
|
||
|
||
function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload:'); cacheDrop('public-settings'); }
|
||
function invalidateStudents() { cacheDrop('students:'); }
|
||
function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||
function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||
function invalidateSessions() { cacheDrop('session:'); }
|
||
|
||
const EVENTS_CHANNEL = 'whatido:events';
|
||
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
|
||
const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo';
|
||
|
||
function createWorkerBus(channel) {
|
||
return {
|
||
publish() {
|
||
cache.publish(channel, { t: Date.now() }).catch(err => console.error('Publish failed:', err.message));
|
||
},
|
||
subscribe(fn) {
|
||
return cache.on(channel, fn);
|
||
},
|
||
};
|
||
}
|
||
|
||
const sseClients = new Set();
|
||
|
||
function writeFrame(event, data) {
|
||
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
for (const client of sseClients) {
|
||
try { client.write(frame); } catch (e) { sseClients.delete(client); }
|
||
}
|
||
}
|
||
|
||
function dispatchEvent(payload) {
|
||
if (payload && payload.type === 'ai_status') {
|
||
writeFrame('ai_status', {
|
||
id: payload.id,
|
||
ai_status: payload.status,
|
||
ai_error: payload.error || null,
|
||
description: payload.description === undefined ? null : payload.description,
|
||
description_ai: payload.description_ai === undefined ? null : payload.description_ai,
|
||
description_original: payload.description_original === undefined ? null : payload.description_original,
|
||
ts: Date.now()
|
||
});
|
||
return;
|
||
}
|
||
writeFrame('entries_changed', { ts: Date.now() });
|
||
}
|
||
|
||
function broadcastEntryChanged() {
|
||
cache.publish(EVENTS_CHANNEL, { type: 'entries_changed' })
|
||
.catch(err => console.error('Publish failed:', err.message));
|
||
}
|
||
|
||
function broadcastAiStatus(payload) {
|
||
cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload })
|
||
.catch(err => console.error('Publish failed:', err.message));
|
||
}
|
||
|
||
cache.on(EVENTS_CHANNEL, message => {
|
||
let payload = null;
|
||
try { payload = JSON.parse(message); } catch (e) { return; }
|
||
if (payload && payload.type) dispatchEvent(payload);
|
||
});
|
||
|
||
app.get('/api/events', async (req, res) => {
|
||
try {
|
||
const token = req.headers['x-auth-token'] || req.query.token;
|
||
const user = await loadUserByToken(token);
|
||
if (!user || !user.is_active) return res.status(401).end();
|
||
} catch (e) {
|
||
return res.status(500).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');
|
||
sseClients.add(res);
|
||
const ping = setInterval(() => {
|
||
try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); sseClients.delete(res); }
|
||
}, 25000);
|
||
req.on('close', () => { clearInterval(ping); sseClients.delete(res); });
|
||
});
|
||
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;
|
||
const failKey = (kind, ip) => 'fail:' + kind + ':' + ip;
|
||
|
||
function ipOf(req) {
|
||
return String(req.ip || req.socket?.remoteAddress || 'unknown').slice(0, 64);
|
||
}
|
||
|
||
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);
|
||
await pool.query(
|
||
'INSERT INTO banned_ips (ip, reason, banned_until) VALUES ($1, $2, $3) ON CONFLICT (ip) DO UPDATE SET reason = $2, banned_until = $3',
|
||
[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})`);
|
||
}
|
||
|
||
async function unbanIpAddr(ip) {
|
||
await cache.del(banKey(ip));
|
||
await cache.dropMatch('fail:*:' + ip);
|
||
}
|
||
|
||
async function ipGuard(req, res, next) {
|
||
try {
|
||
const entry = await cache.get(banKey(ipOf(req)));
|
||
if (entry && new Date(entry.banned_until) > new Date()) {
|
||
return res.status(403).json({ error: 'Доступ заблокирован' });
|
||
}
|
||
} catch (e) {
|
||
console.error('IP guard failed:', e.message);
|
||
}
|
||
next();
|
||
}
|
||
|
||
function recordFailure(req, kind, limit, ms) {
|
||
const ip = ipOf(req);
|
||
return cache.incr(failKey(kind, ip), FAIL_WINDOW_MS)
|
||
.then(count => {
|
||
if (count >= limit) {
|
||
return cache.del(failKey(kind, ip))
|
||
.then(() => banIP(req, kind, ms))
|
||
.catch(err => console.error('Ban error:', err));
|
||
}
|
||
return null;
|
||
})
|
||
.catch(err => {
|
||
console.error('recordFailure failed:', err.message);
|
||
});
|
||
}
|
||
|
||
let seededBans = new Set();
|
||
|
||
async function loadBans() {
|
||
const { rows } = await pool.query('SELECT ip, reason, banned_until FROM banned_ips WHERE banned_until > now()');
|
||
const active = new Set();
|
||
for (const r of rows) {
|
||
active.add(r.ip);
|
||
const ttl = new Date(r.banned_until).getTime() - Date.now();
|
||
if (ttl > 0) await cache.set(banKey(r.ip), { reason: r.reason, banned_until: r.banned_until }, ttl);
|
||
}
|
||
for (const ip of seededBans) {
|
||
if (!active.has(ip)) await cache.del(banKey(ip));
|
||
}
|
||
seededBans = active;
|
||
}
|
||
|
||
app.set('trust proxy', 'loopback');
|
||
|
||
const apiLimiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
max: 300,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
store: cache.rateLimitStore('api', 15 * 60 * 1000),
|
||
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
||
});
|
||
|
||
const entryLimiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
max: 10,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
store: cache.rateLimitStore('entry', 15 * 60 * 1000),
|
||
message: { error: 'Слишком много запросов. Подождите немного.' },
|
||
});
|
||
|
||
const fileLimiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
max: 300,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
store: cache.rateLimitStore('file', 15 * 60 * 1000),
|
||
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
||
});
|
||
|
||
const ADMIN_USERNAME = (process.env.ADMIN_USERNAME || 'admin').toLowerCase().trim();
|
||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||
if (!ADMIN_PASSWORD) {
|
||
console.warn('ADMIN_PASSWORD не задан. Первый админ не будет создан автоматически.');
|
||
}
|
||
|
||
app.use(helmet({
|
||
contentSecurityPolicy: {
|
||
directives: {
|
||
defaultSrc: ["'self'"],
|
||
scriptSrc: ["'self'"],
|
||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||
imgSrc: ["'self'", "data:", "blob:"],
|
||
mediaSrc: ["'self'", "blob:"],
|
||
connectSrc: ["'self'"],
|
||
objectSrc: ["'none'"],
|
||
baseUri: ["'self'"],
|
||
formAction: ["'self'"],
|
||
frameAncestors: ["'none'"]
|
||
}
|
||
}
|
||
}));
|
||
app.use(express.json({ limit: '1mb' }));
|
||
app.use(ipGuard);
|
||
app.use((req, res, next) => {
|
||
if (!storage.isRemote()) return next();
|
||
res.on('finish', () => {
|
||
if (res.statusCode >= 400) return;
|
||
const uploaded = [];
|
||
if (req.file) uploaded.push(req.file);
|
||
if (Array.isArray(req.files)) uploaded.push(...req.files);
|
||
else if (req.files && typeof req.files === 'object') {
|
||
for (const list of Object.values(req.files)) if (Array.isArray(list)) uploaded.push(...list);
|
||
}
|
||
for (const f of uploaded) {
|
||
if (!f || !f.filename || typeof f.path !== 'string') continue;
|
||
const abs = path.resolve(f.path);
|
||
if (!abs.startsWith(UPLOADS_DIR + path.sep)) continue;
|
||
storage.persist(f.filename, abs).catch(err => console.error('Upload persist failed:', f.filename, err.message));
|
||
}
|
||
});
|
||
next();
|
||
});
|
||
const THUMBS_DIR = path.join(UPLOADS_DIR, '.thumbs');
|
||
const ORIGINALS_DIR = path.join(UPLOADS_DIR, '.originals');
|
||
const THUMB_WIDTH = 480;
|
||
let sharp = null;
|
||
try { sharp = require('sharp'); } catch {}
|
||
if (sharp) {
|
||
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
|
||
}
|
||
try { fs.mkdirSync(ORIGINALS_DIR, { recursive: true }); } catch {}
|
||
try { fs.mkdirSync(storage.cacheDir, { recursive: true }); } catch {}
|
||
|
||
function thumbFileFor(fp) {
|
||
const base = path.basename(fp).replace(/\.[^.]+$/, '') + '.webp';
|
||
return path.join(THUMBS_DIR, base);
|
||
}
|
||
|
||
function thumbUnlinkFor(key) {
|
||
const tp = thumbFileFor(key || '');
|
||
try { if (fs.existsSync(tp)) fs.unlinkSync(tp); } catch {}
|
||
}
|
||
|
||
async function sendImageThumb(res, key) {
|
||
const local = await storage.localize(key);
|
||
if (!local) return res.status(404).end();
|
||
if (!sharp) {
|
||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||
return res.sendFile(local);
|
||
}
|
||
const tp = thumbFileFor(key);
|
||
try {
|
||
if (!fs.existsSync(tp)) {
|
||
const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
|
||
await sharp(local).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 85 }).toFile(tmp);
|
||
fs.renameSync(tmp, tp);
|
||
}
|
||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||
return res.sendFile(tp);
|
||
} catch {
|
||
try { if (fs.existsSync(tp)) fs.unlinkSync(tp); } catch {}
|
||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||
return res.sendFile(local);
|
||
}
|
||
}
|
||
|
||
app.get('/uploads/thumb/:name', fileLimiter, async (req, res) => {
|
||
const name = req.params.name;
|
||
if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).end();
|
||
return sendImageThumb(res, name);
|
||
});
|
||
|
||
app.get('/uploads/.originals/:name', fileLimiter, async (req, res) => {
|
||
const name = req.params.name;
|
||
if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).end();
|
||
return sendImageThumb(res, `.originals/${name}`);
|
||
});
|
||
|
||
app.use((req, res, next) => {
|
||
const p = req.path;
|
||
if (!p.startsWith('/uploads') && !p.startsWith('/vendor')) {
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
}
|
||
next();
|
||
});
|
||
|
||
app.get('/uploads/*', async (req, res) => {
|
||
const key = storage.keyFromPath(req.params[0]);
|
||
if (!key) return res.status(400).end();
|
||
try {
|
||
const ok = await storage.streamTo(res, key, { cacheControl: 'public, max-age=31536000, immutable' });
|
||
if (!ok && !res.headersSent) return res.status(404).end();
|
||
} catch (e) {
|
||
console.error('Upload stream failed:', key, e.message);
|
||
if (!res.headersSent) res.status(404).end();
|
||
}
|
||
});
|
||
app.use('/vendor', express.static(path.join(__dirname, 'public', 'vendor'), { maxAge: '30d' }));
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
function getDiskInfo() {
|
||
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
|
||
if (totalDisk) {
|
||
const blockSize = totalDisk.bsize || 4096;
|
||
const total = totalDisk.blocks * blockSize;
|
||
const free = totalDisk.bfree * blockSize;
|
||
const used = total - free;
|
||
return {
|
||
total: formatBytes(total),
|
||
total_bytes: total,
|
||
used: formatBytes(used),
|
||
used_bytes: used,
|
||
free: formatBytes(free),
|
||
free_bytes: free,
|
||
used_pct: Math.min(100, Math.max(0, Math.round((used / total) * 100))),
|
||
};
|
||
}
|
||
try {
|
||
const { execSync } = require('child_process');
|
||
const out = execSync('df -B1 .', { encoding: 'utf8' });
|
||
const lines = out.trim().split('\n');
|
||
if (lines.length > 1) {
|
||
const parts = lines[1].split(/\s+/);
|
||
const total = parseInt(parts[1], 10);
|
||
const used = parseInt(parts[2], 10);
|
||
const free = parseInt(parts[3], 10);
|
||
return {
|
||
total: formatBytes(total),
|
||
total_bytes: total,
|
||
used: formatBytes(used),
|
||
used_bytes: used,
|
||
free: formatBytes(free),
|
||
free_bytes: free,
|
||
used_pct: Math.min(100, Math.max(0, Math.round((used / total) * 100))),
|
||
};
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
let appMeta = null;
|
||
function getAppMeta() {
|
||
if (appMeta) return appMeta;
|
||
appMeta = { name: 'WhatIDo', version: '', commit: '', commit_date: '' };
|
||
try {
|
||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
||
if (pkg.name) appMeta.name = pkg.name;
|
||
if (pkg.version) appMeta.version = pkg.version;
|
||
} catch {}
|
||
try {
|
||
const v = JSON.parse(fs.readFileSync(path.join(__dirname, 'public', 'version.json'), 'utf8'));
|
||
appMeta.commit = v.short || v.full || '';
|
||
appMeta.commit_date = v.date || '';
|
||
} catch {}
|
||
return appMeta;
|
||
}
|
||
|
||
function getDepVersion(name) {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(path.join(__dirname, 'node_modules', name, 'package.json'), 'utf8')).version || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const STACK_DEPS = ['express', 'pg', 'redis', 'sharp', 'multer', 'tar', 'helmet', 'bcrypt', '@aws-sdk/client-s3'];
|
||
let depVersions = null;
|
||
function getDepVersions() {
|
||
if (!depVersions) {
|
||
depVersions = {};
|
||
for (const name of STACK_DEPS) depVersions[name] = getDepVersion(name);
|
||
}
|
||
return depVersions;
|
||
}
|
||
|
||
function hostOf(value) {
|
||
if (!value) return null;
|
||
try {
|
||
const u = new URL(value);
|
||
return u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getRuntimeInfo() {
|
||
const cpus = os.cpus() || [];
|
||
const release = {};
|
||
try {
|
||
for (const line of fs.readFileSync('/etc/os-release', 'utf8').split('\n')) {
|
||
const sep = line.indexOf('=');
|
||
if (sep > 0) release[line.slice(0, sep)] = line.slice(sep + 1).replace(/^"|"$/g, '');
|
||
}
|
||
} catch {}
|
||
let container = null;
|
||
try {
|
||
if (fs.existsSync('/.dockerenv')) container = 'Docker';
|
||
else if (/docker|containerd|kubepods/.test(fs.readFileSync('/proc/1/cgroup', 'utf8'))) container = 'Docker';
|
||
else if (process.env.KUBERNETES_SERVICE_HOST) container = 'Kubernetes';
|
||
} catch {}
|
||
return {
|
||
container,
|
||
os: release.PRETTY_NAME || [release.NAME, release.VERSION_ID].filter(Boolean).join(' ') || os.platform(),
|
||
os_version: release.VERSION_ID || null,
|
||
kernel: `${os.platform()} ${os.release()}`,
|
||
arch: os.arch(),
|
||
cpus: cpus.length,
|
||
cpu_model: cpus[0] ? String(cpus[0].model || '').trim() || null : null,
|
||
cpu_load: os.loadavg().map(v => Math.round(v * 100) / 100),
|
||
mem_total: formatBytes(os.totalmem()),
|
||
mem_free: formatBytes(os.freemem()),
|
||
mem_total_bytes: os.totalmem(),
|
||
mem_free_bytes: os.freemem(),
|
||
uptime_s: Math.round(os.uptime()),
|
||
};
|
||
}
|
||
|
||
async function getStackInfo(cacheStats) {
|
||
const app = getAppMeta();
|
||
const mem = process.memoryUsage();
|
||
let dbVersion = null;
|
||
try {
|
||
const r = await pool.query('SHOW server_version');
|
||
dbVersion = r.rows[0] ? String(r.rows[0].server_version) : null;
|
||
} catch {}
|
||
const remote = storage.isRemote();
|
||
const photoAi = await photoAiHealth(2000);
|
||
return {
|
||
app: {
|
||
name: app.name,
|
||
version: app.version,
|
||
commit: app.commit,
|
||
commit_date: app.commit_date,
|
||
node: process.version,
|
||
pid: process.pid,
|
||
uptime_s: Math.round(process.uptime()),
|
||
rss: formatBytes(mem.rss),
|
||
heap_used: formatBytes(mem.heapUsed),
|
||
},
|
||
deps: getDepVersions(),
|
||
runtime: getRuntimeInfo(),
|
||
database: {
|
||
engine: 'PostgreSQL',
|
||
version: dbVersion,
|
||
host: hostOf(process.env.DATABASE_URL),
|
||
pool_total: pool.totalCount,
|
||
pool_idle: pool.idleCount,
|
||
pool_waiting: pool.waitingCount,
|
||
},
|
||
cache: {
|
||
engine: 'Redis',
|
||
driver: (cacheStats && cacheStats.driver) || null,
|
||
version: (cacheStats && cacheStats.version) || null,
|
||
ready: !!(cacheStats && cacheStats.ready),
|
||
enabled: cacheStats ? !!cacheStats.enabled : null,
|
||
host: hostOf(process.env.REDIS_URL),
|
||
keys: cacheStats ? cacheStats.keys : null,
|
||
used_memory: (cacheStats && cacheStats.used_memory_human) || null,
|
||
uptime_s: cacheStats ? cacheStats.server_uptime_s : null,
|
||
hits: cacheStats ? cacheStats.hits : null,
|
||
misses: cacheStats ? cacheStats.misses : null,
|
||
fallback_ops: cacheStats ? cacheStats.fallbackOps : null,
|
||
errors: cacheStats ? cacheStats.errors : null,
|
||
},
|
||
storage: {
|
||
engine: remote ? 'S3' : 'Файловая система',
|
||
driver: remote ? 's3' : 'local',
|
||
endpoint: hostOf(process.env.S3_ENDPOINT),
|
||
bucket: remote ? (process.env.S3_BUCKET || null) : null,
|
||
region: remote ? (process.env.S3_REGION || null) : null,
|
||
dir: remote ? null : UPLOADS_DIR,
|
||
local_fallback: process.env.STORAGE_LOCAL_FALLBACK === '1',
|
||
keep_local: process.env.STORAGE_KEEP_LOCAL === '1',
|
||
},
|
||
photo_ai: {
|
||
engine: 'Real-ESRGAN + GFPGAN',
|
||
host: hostOf(PHOTO_AI_URL),
|
||
configured: !!photoAi.configured,
|
||
reachable: !!photoAi.reachable,
|
||
latency_ms: photoAi.latency_ms || 0,
|
||
error: photoAi.error || null,
|
||
ready: photoAi.ready ?? null,
|
||
device: photoAi.device || null,
|
||
device_name: photoAi.device_name || null,
|
||
half: photoAi.half ?? null,
|
||
tile: photoAi.tile ?? null,
|
||
driver: photoAi.driver || null,
|
||
cuda: photoAi.cuda || null,
|
||
vram_total_mb: photoAi.vram_total_mb ?? null,
|
||
vram_free_mb: photoAi.vram_free_mb ?? null,
|
||
models: Array.isArray(photoAi.models) ? photoAi.models : [],
|
||
face_models: Array.isArray(photoAi.face_models) ? photoAi.face_models : [],
|
||
loaded: Array.isArray(photoAi.loaded) ? photoAi.loaded : [],
|
||
},
|
||
};
|
||
}
|
||
|
||
function safeUser(u) {
|
||
return {
|
||
id: u.id,
|
||
username: u.username,
|
||
name: u.name,
|
||
role: u.role,
|
||
is_active: u.is_active,
|
||
branch_ids: u.branch_ids || [],
|
||
};
|
||
}
|
||
|
||
async function loadUserByToken(token) {
|
||
if (!token || typeof token !== 'string') return null;
|
||
const key = 'session:' + token;
|
||
const cached = await cache.get(key);
|
||
if (cached !== undefined) return cached;
|
||
const { rows } = await pool.query(
|
||
`SELECT u.id, u.username, u.name, u.role, u.is_active,
|
||
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
|
||
FROM sessions s
|
||
JOIN users u ON u.id = s.user_id
|
||
LEFT JOIN user_branches ub ON ub.user_id = u.id
|
||
WHERE s.token = $1 AND s.expires_at > now()
|
||
GROUP BY u.id`,
|
||
[token]
|
||
);
|
||
if (!rows.length) return null;
|
||
await cache.set(key, rows[0], SESSION_CACHE_TTL_MS);
|
||
return rows[0];
|
||
}
|
||
|
||
async function requireAuth(req, res, next) {
|
||
try {
|
||
const token = req.headers['x-auth-token'];
|
||
const user = await loadUserByToken(token);
|
||
if (!user || !user.is_active) {
|
||
return res.status(401).json({ error: 'Unauthorized' });
|
||
}
|
||
req.user = user;
|
||
req.authToken = token;
|
||
next();
|
||
} catch (e) {
|
||
console.error('requireAuth error:', e);
|
||
res.status(500).json({ error: 'Internal server error' });
|
||
}
|
||
}
|
||
|
||
function requireAdmin(req, res, next) {
|
||
if (req.user) {
|
||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden: требуется роль администратора' });
|
||
return next();
|
||
}
|
||
requireAuth(req, res, () => {
|
||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Forbidden: требуется роль администратора' });
|
||
next();
|
||
});
|
||
}
|
||
|
||
function branchScope(user) {
|
||
if (user.role === 'admin') return { admin: true, ids: null };
|
||
return { admin: false, ids: user.branch_ids || [] };
|
||
}
|
||
|
||
async function optionalAuth(req, res, next) {
|
||
try {
|
||
const token = req.headers['x-auth-token'];
|
||
if (token && typeof token === 'string') {
|
||
const user = await loadUserByToken(token);
|
||
if (user?.is_active) {
|
||
req.user = user;
|
||
req.authToken = token;
|
||
}
|
||
}
|
||
} catch {}
|
||
next();
|
||
}
|
||
|
||
function branchWhere(user, alias) {
|
||
const s = branchScope(user);
|
||
if (s.admin) return { where: '', params: [] };
|
||
const ids = s.ids;
|
||
if (!ids.length) return { where: ` AND 1 = 0`, params: [] };
|
||
return { where: ` AND ${alias}.branch_id IN (${ids.map((_, i) => '$' + (i + 1)).join(',')})`, params: ids };
|
||
}
|
||
|
||
function assertAccessToGroup(user, groupId, res) {
|
||
const s = branchScope(user);
|
||
if (s.admin) return true;
|
||
return s.ids.includes(Number(groupId));
|
||
}
|
||
|
||
async function groupBelongsToBranches(user, groupId) {
|
||
const s = branchScope(user);
|
||
if (s.admin) return true;
|
||
if (!s.ids.length) return false;
|
||
const { rows } = await pool.query(
|
||
'SELECT 1 AS one FROM groups WHERE id = $1 AND branch_id = ANY($2::int[])',
|
||
[groupId, s.ids]
|
||
);
|
||
return !!rows.length;
|
||
}
|
||
|
||
async function entryAccessible(user, entryId) {
|
||
const { rows } = await pool.query(
|
||
`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`,
|
||
[entryId]
|
||
);
|
||
if (!rows.length) return { found: false };
|
||
const groupId = rows[0].group_id;
|
||
if (user.role === 'admin') return { found: true, group_id: groupId };
|
||
const allowed = groupId && (await groupBelongsToBranches(user, groupId));
|
||
if (!allowed) {
|
||
console.warn(`[ACCESS DENIED] entryAccessible: user=${user.id} (${user.username}) branch_ids=${JSON.stringify(user.branch_ids)} entry=${entryId} group_id=${groupId}`);
|
||
}
|
||
return { found: true, group_id: groupId, allowed };
|
||
}
|
||
|
||
function fixFilename(str) {
|
||
try {
|
||
return Buffer.from(str, 'latin1').toString('utf8');
|
||
} catch {
|
||
return str;
|
||
}
|
||
}
|
||
|
||
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||
const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.ico', '.heic', '.heif', '.jfif']);
|
||
const MAX_TOTAL_UPLOAD_BYTES = 30 * 1024 * 1024;
|
||
|
||
const upload = multer({
|
||
storage: multer.diskStorage({
|
||
destination: (_, __, cb) => {
|
||
fs.mkdirSync('uploads', { recursive: true });
|
||
cb(null, 'uploads');
|
||
},
|
||
filename: (_, file, cb) => {
|
||
const original = fixFilename(file.originalname);
|
||
const ext = path.extname(original) || '.jpg';
|
||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||
},
|
||
}),
|
||
limits: { fileSize: 10 * 1024 * 1024 },
|
||
fileFilter: (req, file, cb) => {
|
||
file.originalname = fixFilename(file.originalname);
|
||
const ext = path.extname(file.originalname).toLowerCase();
|
||
const isImageExt = ALLOWED_IMAGE_EXT.has(ext);
|
||
if (file.fieldname === 'photo') {
|
||
if (!isImageExt) {
|
||
return cb(new Error('Only images'));
|
||
}
|
||
}
|
||
if (ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
|
||
cb(null, true);
|
||
},
|
||
});
|
||
|
||
const ADMIN_ALLOWED_EXT = new Set(['.pdf', '.doc', '.docx', '.txt', '.md', '.html', '.htm', '.zip', '.rar', '.7z', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.heif', '.jfif']);
|
||
const adminUpload = multer({
|
||
storage: multer.diskStorage({
|
||
destination: (_, __, cb) => {
|
||
fs.mkdirSync('uploads', { recursive: true });
|
||
cb(null, 'uploads');
|
||
},
|
||
filename: (_, file, cb) => {
|
||
const original = fixFilename(file.originalname);
|
||
const ext = path.extname(original) || '.bin';
|
||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||
},
|
||
}),
|
||
limits: { fileSize: 10 * 1024 * 1024 },
|
||
fileFilter: (req, file, cb) => {
|
||
file.originalname = fixFilename(file.originalname);
|
||
const ext = path.extname(file.originalname).toLowerCase();
|
||
if (ext && BLOCKED_EXT.test(ext) && !ADMIN_ALLOWED_EXT.has(ext)) {
|
||
return cb(new Error('Not allowed extension'));
|
||
}
|
||
cb(null, true);
|
||
},
|
||
});
|
||
|
||
async function getSetting(key, def) {
|
||
const cached = await cacheGet('setting:' + key);
|
||
if (cached !== undefined) return cached;
|
||
const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
||
const value = rows.length ? rows[0].value : def;
|
||
await cacheSet('setting:' + key, value, SETTINGS_TTL_MS);
|
||
return value;
|
||
}
|
||
|
||
async function trashPurgeDays() {
|
||
const v = parseInt(await getSetting('trash_purge_days', '30'), 10);
|
||
return Number.isFinite(v) && v >= 1 && v <= 3650 ? v : 30;
|
||
}
|
||
|
||
async function purgeScheduledDeletions() {
|
||
const erefs = await pool.query('SELECT id FROM entries WHERE purge_at IS NOT NULL AND purge_at <= now()');
|
||
for (const r of erefs.rows) {
|
||
await removeEntryFiles(r.id);
|
||
await pool.query('DELETE FROM entries WHERE id = $1', [r.id]);
|
||
}
|
||
const grefs = await pool.query('SELECT id FROM groups WHERE purge_at IS NOT NULL AND purge_at <= now()');
|
||
let gcount = 0;
|
||
for (const g of grefs.rows) {
|
||
if (await hardDeleteGroup(g.id)) gcount++;
|
||
}
|
||
if (erefs.rowCount || gcount) {
|
||
invalidateEntries();
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
}
|
||
return { entries: erefs.rowCount, groups: gcount };
|
||
}
|
||
|
||
function safeUnlink(relPath) {
|
||
const key = storage.keyFromPath(relPath);
|
||
if (!key) return;
|
||
storage.unlinkLocalOnly(key);
|
||
if (!storage.isRemote()) return;
|
||
storage.del(key).catch(err => console.error('Upload delete failed:', key, err.message));
|
||
}
|
||
|
||
function removeUpload(file) {
|
||
safeUnlink(file && file.path);
|
||
}
|
||
|
||
async function convertPhoto(file) {
|
||
if (!file || !file.path) return;
|
||
const ext = (path.extname(file.originalname || '') || '').toLowerCase();
|
||
if (ext !== '.heic' && ext !== '.heif' && ext !== '.jfif') return;
|
||
try {
|
||
if (ext === '.jfif') {
|
||
// JFIF is already JPEG, just rename to .jpg for consistency
|
||
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
|
||
const outPath = path.join(path.dirname(file.path), outName);
|
||
fs.renameSync(file.path, outPath);
|
||
file.path = outPath;
|
||
file.filename = outName;
|
||
file.originalname = outName;
|
||
return;
|
||
}
|
||
// HEIC/HEIF conversion
|
||
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
|
||
const outPath = path.join(path.dirname(file.path), outName);
|
||
const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.92 });
|
||
fs.writeFileSync(outPath, jpeg);
|
||
safeUnlink(file.path);
|
||
file.path = outPath;
|
||
file.filename = outName;
|
||
file.originalname = outName;
|
||
} catch (e) {
|
||
console.error('Photo convert failed:', e);
|
||
}
|
||
}
|
||
|
||
async function removeEntryFiles(entryId) {
|
||
const { rows } = await pool.query(
|
||
`SELECT photo_path AS p FROM entries WHERE id = $1
|
||
UNION ALL
|
||
SELECT path AS p FROM project_files WHERE entry_id = $1
|
||
UNION ALL
|
||
SELECT photo_path AS p FROM entry_photos WHERE entry_id = $1`,
|
||
[entryId]
|
||
);
|
||
rows.forEach(r => safeUnlink(r.p));
|
||
}
|
||
|
||
async function sweepOrphanedUploads() {
|
||
const dir = UPLOADS_DIR;
|
||
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
||
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }, { rows: pendingJobs }, { rows: mphotos }, { rows: sphotos }, { rows: logos }] = await Promise.all([
|
||
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
|
||
pool.query('SELECT path AS p FROM project_files'),
|
||
pool.query('SELECT photo_path AS p FROM group_photos'),
|
||
pool.query('SELECT photo_path AS p FROM entry_photos'),
|
||
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
|
||
pool.query('SELECT photo_path AS p FROM modules WHERE photo_path IS NOT NULL'),
|
||
pool.query('SELECT photo_path AS p FROM student_photos'),
|
||
pool.query(`SELECT value AS p FROM settings WHERE key = 'system_logo' AND value IS NOT NULL AND value <> ''`),
|
||
]);
|
||
const refs = new Set();
|
||
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs, ...mphotos, ...sphotos, ...logos].forEach(r => {
|
||
const key = storage.keyFromPath(r.p);
|
||
if (key) refs.add(key);
|
||
});
|
||
const remoteObjects = await storage.listAll('').catch(err => {
|
||
console.error('Storage list failed:', err.message);
|
||
return [];
|
||
});
|
||
for (const obj of remoteObjects) {
|
||
if (!storage.normalizeKey(obj.key) || obj.key.includes('/')) continue;
|
||
if (!refs.has(obj.key)) {
|
||
try { await storage.del(obj.key); } catch (err) { console.error('Upload delete failed:', obj.key, err.message); }
|
||
}
|
||
}
|
||
for (const f of fs.readdirSync(dir)) {
|
||
const fp = path.join(dir, f);
|
||
if (!fs.statSync(fp).isFile()) continue;
|
||
if (!refs.has(f)) {
|
||
try { fs.unlinkSync(fp); } catch {}
|
||
}
|
||
}
|
||
storage.pruneCache();
|
||
}
|
||
|
||
async function ensureAuditTable() {
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS audit_log (
|
||
id SERIAL PRIMARY KEY,
|
||
action VARCHAR(100) NOT NULL,
|
||
target JSONB,
|
||
ip VARCHAR(45),
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
)`);
|
||
await pool.query('CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC)');
|
||
await pool.query('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL');
|
||
}
|
||
|
||
async function ensureBranchesTable() {
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS branches (
|
||
id SERIAL PRIMARY KEY,
|
||
name VARCHAR(200) NOT NULL UNIQUE,
|
||
address TEXT,
|
||
phone VARCHAR(50),
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
)`);
|
||
await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`);
|
||
await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ`);
|
||
await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ`);
|
||
}
|
||
|
||
async function ensureBannedIpsTable() {
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS banned_ips (
|
||
ip VARCHAR(64) PRIMARY KEY,
|
||
reason VARCHAR(100) NOT NULL,
|
||
banned_until TIMESTAMPTZ NOT NULL,
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
)`);
|
||
}
|
||
|
||
async function ensureModulesTable() {
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS modules (
|
||
id SERIAL PRIMARY KEY,
|
||
name VARCHAR(200) NOT NULL UNIQUE,
|
||
lessons_count INT NOT NULL DEFAULT 0,
|
||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
)`);
|
||
await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true');
|
||
await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255)');
|
||
await pool.query('ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL');
|
||
await pool.query('CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id)');
|
||
}
|
||
|
||
async function ensureEntryPhotosTable() {
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
||
id SERIAL PRIMARY KEY,
|
||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||
photo_path VARCHAR(255) NOT NULL,
|
||
caption TEXT,
|
||
sort_order INT DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
)`);
|
||
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
|
||
}
|
||
|
||
async function ensureStudentPhotosTable() {
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS student_photos (
|
||
id SERIAL PRIMARY KEY,
|
||
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||
photo_path VARCHAR(255) NOT NULL,
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
)`);
|
||
await pool.query('CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id)');
|
||
}
|
||
|
||
async function ensurePhotoOriginalColumn() {
|
||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
|
||
}
|
||
|
||
async function ensureEntryAiColumns() {
|
||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT`);
|
||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT`);
|
||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending'`);
|
||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ`);
|
||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
|
||
await pool.query(`
|
||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||
BEGIN
|
||
IF (TG_OP = 'INSERT') THEN
|
||
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||
END IF;
|
||
RETURN NULL;
|
||
END;
|
||
$$ LANGUAGE plpgsql
|
||
`);
|
||
await pool.query(`DROP TRIGGER IF EXISTS trg_entries_notify ON entries`);
|
||
await pool.query(`CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries FOR EACH ROW EXECUTE FUNCTION notify_entries_changed()`);
|
||
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
||
const marker = await getSetting('ai_autocheck_migrated', '');
|
||
if (marker !== '1') {
|
||
await pool.query(`UPDATE entries SET description_original = description WHERE description_original IS NULL`);
|
||
await pool.query(`UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending'`);
|
||
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1') ON CONFLICT (key) DO UPDATE SET value = '1'`);
|
||
}
|
||
}
|
||
|
||
async function ensurePhotoJobsTable() {
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS photo_jobs (
|
||
id SERIAL PRIMARY KEY,
|
||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||
action VARCHAR(20) NOT NULL DEFAULT 'ai',
|
||
params JSONB,
|
||
before_path VARCHAR(255),
|
||
after_path VARCHAR(255),
|
||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||
applied BOOLEAN NOT NULL DEFAULT false,
|
||
attempts INT NOT NULL DEFAULT 0,
|
||
error TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT now(),
|
||
finished_at TIMESTAMPTZ
|
||
)`);
|
||
await pool.query(`ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false`);
|
||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending'`);
|
||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id)`);
|
||
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
||
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off') ON CONFLICT (key) DO NOTHING`);
|
||
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', $1) ON CONFLICT (key) DO NOTHING`, [PHOTO_AI_FACE_MODEL]);
|
||
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto') ON CONFLICT (key) DO NOTHING`);
|
||
}
|
||
|
||
async function ensureUserTables() {
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS users (
|
||
id SERIAL PRIMARY KEY,
|
||
username VARCHAR(100) NOT NULL UNIQUE,
|
||
password_hash VARCHAR(255) NOT NULL,
|
||
name VARCHAR(150),
|
||
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
|
||
is_active BOOLEAN DEFAULT true,
|
||
created_at TIMESTAMPTZ DEFAULT now()
|
||
)`);
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS user_branches (
|
||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (user_id, branch_id)
|
||
)`);
|
||
await pool.query(`CREATE TABLE IF NOT EXISTS sessions (
|
||
id SERIAL PRIMARY KEY,
|
||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
token VARCHAR(64) NOT NULL UNIQUE,
|
||
created_at TIMESTAMPTZ DEFAULT now(),
|
||
expires_at TIMESTAMPTZ NOT NULL
|
||
)`);
|
||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)`);
|
||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`);
|
||
await pool.query('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL');
|
||
await pool.query('ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL');
|
||
}
|
||
|
||
async function ensureFirstAdmin() {
|
||
if (!ADMIN_PASSWORD) return;
|
||
const { rows } = await pool.query('SELECT id FROM users WHERE role = \'admin\' LIMIT 1');
|
||
if (rows.length) return;
|
||
const exists = await pool.query('SELECT id FROM users WHERE username = $1', [ADMIN_USERNAME]);
|
||
const username = exists.rows.length ? (ADMIN_USERNAME + '-' + Date.now()) : ADMIN_USERNAME;
|
||
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
|
||
await pool.query(
|
||
'INSERT INTO users (username, password_hash, name, role) VALUES ($1, $2, $3, $4)',
|
||
[username, hash, 'Администратор', 'admin']
|
||
);
|
||
console.log(`Создан первый администратор: ${username}`);
|
||
}
|
||
|
||
async function ensureUsersAndFirstAdmin() {
|
||
await ensureUserTables();
|
||
await ensureFirstAdmin();
|
||
}
|
||
|
||
async function logAudit(req, action, target) {
|
||
try {
|
||
await pool.query(
|
||
'INSERT INTO audit_log (user_id, action, target, ip) VALUES ($1, $2, $3, $4)',
|
||
[req?.user?.id || null, action, target ?? null, req?.ip?.slice(0, 45) || null]
|
||
);
|
||
} catch (e) {
|
||
console.error('audit log failed:', e);
|
||
}
|
||
}
|
||
|
||
// --- Auth ---
|
||
app.post('/api/auth/login', apiLimiter, async (req, res) => {
|
||
const rawUsername = typeof req.body?.username === 'string' ? req.body.username.trim().toLowerCase() : '';
|
||
const password = String(req.body?.password || '');
|
||
if (typeof req.body?.website === 'string' && req.body.website) {
|
||
await recordFailure(req, 'honeypot', 1, BAN_TTL_MS);
|
||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||
}
|
||
if (!rawUsername || rawUsername.length > 100 || !password) {
|
||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||
}
|
||
const username = rawUsername;
|
||
const { rows } = await pool.query('SELECT * FROM users WHERE username = $1', [username]);
|
||
const user = rows[0];
|
||
if (!user || !user.is_active) {
|
||
await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS);
|
||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||
}
|
||
const valid = await bcrypt.compare(password, user.password_hash || '');
|
||
if (!valid) {
|
||
await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS);
|
||
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
||
}
|
||
const token = crypto.randomBytes(32).toString('hex');
|
||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||
await pool.query('INSERT INTO sessions (user_id, token, expires_at) VALUES ($1, $2, $3)', [user.id, token, expiresAt.toISOString()]);
|
||
await logAudit({ ip: req.ip, user: { id: user.id } }, 'auth.login', { username: user.username });
|
||
res.json({ token, expires_at: expiresAt.toISOString() });
|
||
});
|
||
|
||
app.post('/api/auth/logout', requireAuth, async (req, res) => {
|
||
await pool.query('DELETE FROM sessions WHERE token = $1', [req.authToken]);
|
||
await cache.del('session:' + req.authToken);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
||
res.json(safeUser(req.user));
|
||
});
|
||
|
||
app.get('/api/bans', requireAuth, requireAdmin, async (_, res) => {
|
||
const { rows } = await pool.query(
|
||
'SELECT ip, reason, banned_until, created_at FROM banned_ips WHERE banned_until > now() ORDER BY banned_until DESC'
|
||
);
|
||
res.json(rows);
|
||
});
|
||
|
||
app.post('/api/bans', requireAuth, requireAdmin, async (req, res) => {
|
||
const ip = String(req.body?.ip || '').trim();
|
||
if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) {
|
||
return res.status(400).json({ error: 'Некорректный IP' });
|
||
}
|
||
const reason = (typeof req.body?.reason === 'string' && req.body.reason.trim())
|
||
? req.body.reason.trim().slice(0, 100)
|
||
: 'manual';
|
||
const hours = Math.min(Math.max(parseInt(req.body?.hours, 10) || 24, 1), 24 * 30);
|
||
const bannedUntil = new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||
await banIpAddr(ip, reason, hours * 60 * 60 * 1000, req);
|
||
res.json({ ok: true, ip, reason, banned_until: bannedUntil });
|
||
});
|
||
|
||
app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
||
const ip = String(req.params.ip || '').trim();
|
||
if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) {
|
||
return res.status(400).json({ error: 'Некорректный IP' });
|
||
}
|
||
await pool.query('DELETE FROM banned_ips WHERE ip = $1', [ip]);
|
||
await unbanIpAddr(ip);
|
||
await logAudit(req, 'ip.unban', { ip });
|
||
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(
|
||
`SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at,
|
||
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
|
||
FROM users u
|
||
LEFT JOIN user_branches ub ON ub.user_id = u.id
|
||
WHERE u.role = 'tutor'
|
||
GROUP BY u.id
|
||
ORDER BY u.id`
|
||
);
|
||
res.json(rows.map(r => ({ ...r, branch_ids: r.branch_ids || [] })));
|
||
});
|
||
|
||
app.get('/api/users/tutors', requireAuth, async (_, res) => {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, name, username FROM users WHERE role = 'tutor' AND is_active = true ORDER BY COALESCE(NULLIF(name, ''), username)`
|
||
);
|
||
res.json(rows);
|
||
});
|
||
|
||
app.get('/api/users/branches', requireAuth, async (req, res) => {
|
||
const s = branchScope(req.user);
|
||
const params = [];
|
||
let where = '';
|
||
if (!s.admin) {
|
||
if (!s.ids.length) return res.json([]);
|
||
where = `WHERE id = ANY($1::int[])`;
|
||
params.push(s.ids);
|
||
}
|
||
const { rows } = await pool.query(`SELECT * FROM branches ${where} ORDER BY id`, params);
|
||
res.json(rows);
|
||
});
|
||
|
||
app.post('/api/users', requireAuth, requireAdmin, async (req, res) => {
|
||
const username = reqStr(req.body?.username, 100).toLowerCase();
|
||
const password = String(req.body?.password || '');
|
||
const name = optStr(req.body?.name, 150);
|
||
const role = req.body?.role === 'admin' ? 'admin' : 'tutor';
|
||
const isActive = req.body?.is_active !== false;
|
||
let branchIds = Array.isArray(req.body?.branch_ids) ?
|
||
[...new Set(req.body.branch_ids.map(Number).filter(Boolean))] : [];
|
||
if (role === 'admin') branchIds = [];
|
||
if (password.length < 6) return res.status(400).json({ error: 'Пароль должен быть не короче 6 символов' });
|
||
const hash = await bcrypt.hash(password, 10);
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
const { rows } = await client.query(
|
||
'INSERT INTO users (username, password_hash, name, role, is_active) VALUES ($1, $2, $3, $4, $5) RETURNING *',
|
||
[username, hash, name, role, isActive]
|
||
);
|
||
const user = rows[0];
|
||
for (const b of branchIds) {
|
||
await client.query('INSERT INTO user_branches (user_id, branch_id) VALUES ($1, $2)', [user.id, b]);
|
||
}
|
||
await client.query('COMMIT');
|
||
await logAudit(req, 'user.create', { id: user.id, username: user.username, role });
|
||
res.status(201).json(safeUser({ ...user, branch_ids: branchIds }));
|
||
} catch (e) {
|
||
await client.query('ROLLBACK').catch(() => {});
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Логин уже занят' });
|
||
throw e;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
||
const id = req.params.id;
|
||
const current = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
|
||
if (!current.rows.length) return res.status(404).json({ error: 'Пользователь не найден' });
|
||
const target = current.rows[0];
|
||
if (target.id === req.user.id && req.body?.is_active === false) {
|
||
return res.status(400).json({ error: 'Нельзя деактивировать самого себя' });
|
||
}
|
||
if (target.id === req.user.id && req.body?.role && req.body.role !== 'admin') {
|
||
return res.status(400).json({ error: 'Нельзя снять роль администратора с самого себя' });
|
||
}
|
||
const name = req.body?.name !== undefined ? optStr(req.body.name, 150) : target.name;
|
||
const newRole = req.body?.role ? (req.body.role === 'admin' ? 'admin' : 'tutor') : target.role;
|
||
const isActive = req.body?.is_active !== undefined ? req.body.is_active !== false : target.is_active;
|
||
let branchIds = null;
|
||
if (Array.isArray(req.body?.branch_ids)) {
|
||
branchIds = [...new Set(req.body.branch_ids.map(Number).filter(Boolean))];
|
||
}
|
||
let hash = null;
|
||
if (req.body?.password) {
|
||
if (String(req.body.password).length < 6) return res.status(400).json({ error: 'Пароль должен быть не короче 6 символов' });
|
||
hash = await bcrypt.hash(String(req.body.password), 10);
|
||
}
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
if (hash) {
|
||
await client.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
|
||
}
|
||
await client.query(
|
||
'UPDATE users SET name = $1, role = $2, is_active = $3 WHERE id = $4',
|
||
[name, newRole, isActive, id]
|
||
);
|
||
if (branchIds !== null) {
|
||
await client.query('DELETE FROM user_branches WHERE user_id = $1', [id]);
|
||
if (newRole === 'tutor') {
|
||
for (const b of branchIds) {
|
||
await client.query('INSERT INTO user_branches (user_id, branch_id) VALUES ($1, $2)', [id, b]);
|
||
}
|
||
}
|
||
}
|
||
if (!isActive) {
|
||
await client.query('DELETE FROM sessions WHERE user_id = $1', [id]);
|
||
}
|
||
await client.query('COMMIT');
|
||
invalidateSessions();
|
||
await logAudit(req, 'user.update', { id, role: newRole, is_active: isActive });
|
||
const fresh = await pool.query(
|
||
`SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at,
|
||
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
|
||
FROM users u LEFT JOIN user_branches ub ON ub.user_id = u.id WHERE u.id = $1 GROUP BY u.id`,
|
||
[id]
|
||
);
|
||
res.json(safeUser({ ...fresh.rows[0], branch_ids: fresh.rows[0].branch_ids || [] }));
|
||
} catch (e) {
|
||
await client.query('ROLLBACK').catch(() => {});
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Логин уже занят' });
|
||
throw e;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
||
if (req.params.id === String(req.user.id)) {
|
||
return res.status(400).json({ error: 'Нельзя удалить самого себя' });
|
||
}
|
||
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||
invalidateSessions();
|
||
await logAudit(req, 'user.delete', { id: req.params.id });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// --- Settings ---
|
||
app.get('/api/settings', requireAdmin, async (_, res) => {
|
||
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
||
const out = {};
|
||
rows.forEach(r => { out[r.key] = r.value; });
|
||
res.json(out);
|
||
});
|
||
|
||
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
||
const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled', 'photo_ai_face_mode', 'photo_ai_face_model', 'photo_ai_device_pref'];
|
||
const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_face_mode: PHOTO_AI_DEFAULT_FACE_MODE, photo_ai_face_model: PHOTO_AI_FACE_MODEL, photo_ai_device_pref: 'auto' };
|
||
const result = {};
|
||
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
||
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
|
||
if (rm) {
|
||
result.photo_capture_width = rm[1];
|
||
result.photo_capture_height = rm[2];
|
||
} else {
|
||
result.photo_capture_width = '640';
|
||
result.photo_capture_height = '480';
|
||
}
|
||
const q = parseFloat(result.photo_capture_quality);
|
||
result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
|
||
return result;
|
||
});
|
||
out.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
|
||
res.json(out);
|
||
});
|
||
|
||
app.put('/api/settings', requireAdmin, async (req, res) => {
|
||
const { settings } = req.body;
|
||
if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' });
|
||
for (const [key, value] of Object.entries(settings)) {
|
||
if (key === 'spam_interval_min') {
|
||
const n = parseInt(String(value), 10);
|
||
if (!Number.isFinite(n) || n < 1 || n > 10080) {
|
||
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' });
|
||
}
|
||
}
|
||
if (key === 'trash_purge_days') {
|
||
const n = parseInt(String(value), 10);
|
||
if (!Number.isFinite(n) || n < 1 || n > 3650) {
|
||
return res.status(400).json({ error: 'trash_purge_days должен быть целым числом от 1 до 3650' });
|
||
}
|
||
}
|
||
if (key === 'photo_capture_resolution') {
|
||
if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) {
|
||
return res.status(400).json({ error: 'photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480' });
|
||
}
|
||
}
|
||
if (key === 'photo_capture_quality') {
|
||
const q = parseFloat(String(value));
|
||
if (!Number.isFinite(q) || q < 0.5 || q > 1) {
|
||
return res.status(400).json({ error: 'photo_capture_quality должен быть числом от 0.5 до 1' });
|
||
}
|
||
}
|
||
if (key === 'photo_enhance_engine' && !['auto', 'server', 'client'].includes(String(value))) {
|
||
return res.status(400).json({ error: 'photo_enhance_engine должен быть auto, server или client' });
|
||
}
|
||
if (key === 'photo_ai_face_mode' && !PHOTO_AI_FACE_MODES.includes(String(value))) {
|
||
return res.status(400).json({ error: `photo_ai_face_mode должен быть одним из: ${PHOTO_AI_FACE_MODES.join(', ')}` });
|
||
}
|
||
if (key === 'photo_ai_face_model' && !PHOTO_AI_FACE_MODELS.includes(String(value))) {
|
||
return res.status(400).json({ error: `photo_ai_face_model должен быть одним из: ${PHOTO_AI_FACE_MODELS.join(', ')}` });
|
||
}
|
||
if (key === 'photo_ai_device_pref' && !PHOTO_AI_DEVICE_PREFS.includes(String(value))) {
|
||
return res.status(400).json({ error: `photo_ai_device_pref должен быть одним из: ${PHOTO_AI_DEVICE_PREFS.join(', ')}` });
|
||
}
|
||
if (key === 'system_name' && String(value).trim().length > 60) {
|
||
return res.status(400).json({ error: 'system_name не может быть длиннее 60 символов' });
|
||
}
|
||
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 {
|
||
await client.query('BEGIN');
|
||
for (const [key, value] of Object.entries(settings)) {
|
||
await client.query(
|
||
`INSERT INTO settings (key, value) VALUES ($1, $2)
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||
[key, String(value ?? '')]
|
||
);
|
||
}
|
||
await client.query('COMMIT');
|
||
await logAudit(req, 'settings.update', { settings });
|
||
invalidateSettings();
|
||
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
||
const out = {};
|
||
rows.forEach(r => { out[r.key] = r.value; });
|
||
res.json(out);
|
||
} catch (e) {
|
||
await client.query('ROLLBACK');
|
||
throw e;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
const logoUpload = upload.single('logo');
|
||
app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
|
||
logoUpload(req, res, (err) => {
|
||
if (err) {
|
||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
||
if (err.message === 'Only images') return res.status(400).json({ error: 'Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)' });
|
||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||
}
|
||
next();
|
||
});
|
||
}, async (req, res) => {
|
||
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
|
||
const ext = (path.extname(req.file.originalname || '') || '').toLowerCase();
|
||
if (!ALLOWED_IMAGE_EXT.has(ext)) {
|
||
removeUpload(req.file);
|
||
return res.status(400).json({ error: 'Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||
}
|
||
try {
|
||
await convertPhoto(req.file);
|
||
const finalPath = `/uploads/${req.file.filename}`;
|
||
const old = await getSetting('system_logo', '');
|
||
if (old && isSafeUploadPath(old) && old !== finalPath) safeUnlink(old);
|
||
await pool.query(
|
||
`INSERT INTO settings (key, value) VALUES ('system_logo', $1)
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||
[finalPath]
|
||
);
|
||
await logAudit(req, 'settings.logo.upload', { path: finalPath });
|
||
invalidateSettings();
|
||
res.json({ system_logo: finalPath });
|
||
} catch (e) {
|
||
removeUpload(req.file);
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
app.delete('/api/settings/logo', requireAdmin, async (req, res) => {
|
||
const old = await getSetting('system_logo', '');
|
||
if (old) {
|
||
await pool.query(`UPDATE settings SET value = '' WHERE key = 'system_logo'`);
|
||
if (isSafeUploadPath(old)) safeUnlink(old);
|
||
}
|
||
await logAudit(req, 'settings.logo.remove', {});
|
||
invalidateSettings();
|
||
res.json({ ok: true, system_logo: '' });
|
||
});
|
||
|
||
app.get('/api/audit', requireAdmin, async (req, res) => {
|
||
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000);
|
||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||
let where = '';
|
||
const params = [];
|
||
const action = typeof req.query.action === 'string' && req.query.action.trim() ? req.query.action.trim() : null;
|
||
if (action) {
|
||
where = 'WHERE a.action = $1';
|
||
params.push(action);
|
||
}
|
||
params.push(limit, offset);
|
||
const { rows } = await pool.query(
|
||
`SELECT a.id, a.action, a.target, a.ip, a.created_at, a.user_id, u.username AS user_name
|
||
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
|
||
${where} ORDER BY a.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||
params
|
||
);
|
||
res.json(rows.map(r => ({ ...r, target: stripDiffs(r.target) })));
|
||
});
|
||
|
||
app.get('/api/audit/: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 { rows } = await pool.query(
|
||
`SELECT a.id, a.action, a.target, a.ip, a.created_at, a.user_id, u.username AS user_name
|
||
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
|
||
WHERE a.id = $1`,
|
||
[id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||
res.json(rows[0]);
|
||
});
|
||
|
||
// --- Backup / Restore ---
|
||
const gunzipAsync = require('util').promisify(require('zlib').gunzip);
|
||
const zlib = require('zlib');
|
||
const tar = require('tar');
|
||
const os = require('os');
|
||
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
||
const PHOTO_AI_URL = process.env.PHOTO_AI_URL || '';
|
||
const PHOTO_AI_FACE_MODEL = process.env.PHOTO_AI_FACE_MODEL || 'gfpgan';
|
||
const PHOTO_AI_FACE_TIMEOUT_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_FACE_TIMEOUT_MS || '600000', 10) || 600000);
|
||
const PHOTO_AI_DEFAULT_MODEL = 'x2plus';
|
||
const PHOTO_AI_DEFAULT_FACE_MODE = process.env.PHOTO_AI_FACE_MODE || 'off';
|
||
const PHOTO_AI_MODELS = ['x2plus', 'general-x4v3', 'animevideo-v3'];
|
||
const PHOTO_AI_FACE_MODELS = ['gfpgan', 'codeformer'];
|
||
const PHOTO_AI_FACE_MODES = ['off', 'face', 'all'];
|
||
const PHOTO_AI_DEVICE_PREFS = ['auto', 'cuda', 'cpu'];
|
||
const PHOTO_AI_DEFAULT_STRENGTH = 0.7;
|
||
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
|
||
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||
let entryAutoChecker = null;
|
||
let photoWorker = null;
|
||
|
||
async function getAiPrompt() {
|
||
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT);
|
||
return prompt;
|
||
}
|
||
|
||
async function getAiProfiles() {
|
||
try {
|
||
const raw = await getSetting('ai_profiles', '[]');
|
||
const list = JSON.parse(raw || '[]');
|
||
return Array.isArray(list) ? list.filter(p => p && p.id && p.base_url && p.model) : [];
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function getActiveAiProfile() {
|
||
const active = await getSetting('ai_active_profile', 'native');
|
||
if (!active || active === 'native') return null;
|
||
const profiles = await getAiProfiles();
|
||
return profiles.find(p => p.id === active) || null;
|
||
}
|
||
|
||
function normalizeOpenAiBase(base) {
|
||
let b = String(base || '').trim().replace(/\/+$/, '');
|
||
if (!/^https?:\/\//i.test(b)) return null;
|
||
if (!/\/v1$/i.test(b)) b += '/v1';
|
||
return b;
|
||
}
|
||
|
||
async function aiCorrectText(text) {
|
||
const profile = await getActiveAiProfile();
|
||
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||
let model = AI_MODEL;
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
let maxTokens = Math.min(256, Math.max(128, text.length + 64));
|
||
if (profile) {
|
||
const base = normalizeOpenAiBase(profile.base_url);
|
||
if (!base) throw new Error('Некорректный base_url профиля ИИ');
|
||
url = `${base}/chat/completions`;
|
||
model = profile.model;
|
||
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
|
||
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
|
||
}
|
||
try {
|
||
const systemPrompt = await getAiPrompt();
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({
|
||
model,
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: text }
|
||
],
|
||
temperature: 0.1,
|
||
max_tokens: maxTokens
|
||
})
|
||
});
|
||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||
const data = await res.json();
|
||
return data.choices?.[0]?.message?.content?.trim() || text;
|
||
} catch (e) {
|
||
console.error('AI correct error:', e);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
const BACKUP_UPLOAD_LIMIT_MB = parseInt(process.env.BACKUP_UPLOAD_LIMIT_MB || '500', 10);
|
||
|
||
const uploadBackup = multer({
|
||
storage: multer.diskStorage({
|
||
destination: (_, __, cb) => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-up-'));
|
||
cb(null, dir);
|
||
},
|
||
filename: (_, file, cb) => {
|
||
const ext = path.extname(file.originalname) || '.bin';
|
||
cb(null, `backup-${Date.now()}${ext}`);
|
||
},
|
||
}),
|
||
limits: { fileSize: BACKUP_UPLOAD_LIMIT_MB * 1024 * 1024 },
|
||
});
|
||
|
||
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 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 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);
|
||
}
|
||
|
||
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),
|
||
}));
|
||
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),
|
||
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 settings = {};
|
||
for (const [k, v] of Object.entries(data.settings || {})) {
|
||
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
||
}
|
||
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: (x.params === null || x.params === undefined) ? null : (typeof x.params === 'object' ? JSON.stringify(x.params) : String(x.params)),
|
||
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 };
|
||
}
|
||
|
||
const BACKUP_TTL_MS = 30 * 60 * 1000;
|
||
const BACKUP_DIR = path.join(os.tmpdir(), 'wido-backups');
|
||
const backupTickets = new Map();
|
||
|
||
function pruneBackupTickets() {
|
||
const now = Date.now();
|
||
for (const [token, t] of backupTickets) {
|
||
if (t.expiresAt <= now) {
|
||
backupTickets.delete(token);
|
||
fs.rmSync(t.file, { force: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
function sweepBackupStorage() {
|
||
try {
|
||
if (!fs.existsSync(BACKUP_DIR)) return;
|
||
const cutoff = Date.now() - BACKUP_TTL_MS;
|
||
for (const f of fs.readdirSync(BACKUP_DIR)) {
|
||
const fp = path.join(BACKUP_DIR, f);
|
||
const st = fs.statSync(fp);
|
||
if (st.isFile() && st.mtimeMs < cutoff) fs.rmSync(fp, { force: true });
|
||
}
|
||
} catch (e) {
|
||
console.error('backup sweep failed:', e.message);
|
||
}
|
||
}
|
||
|
||
async function buildBackupArchive() {
|
||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||
try {
|
||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj] = 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'),
|
||
pool.query('SELECT key, value FROM settings'),
|
||
pool.query('SELECT * FROM project_files ORDER BY id'),
|
||
pool.query('SELECT * FROM branches ORDER BY id'),
|
||
pool.query('SELECT * FROM users ORDER BY id'),
|
||
pool.query('SELECT * FROM user_branches ORDER BY user_id, branch_id'),
|
||
pool.query('SELECT * FROM group_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 student_photos ORDER BY id'),
|
||
pool.query('SELECT * FROM share_links ORDER BY id'),
|
||
pool.query('SELECT * FROM photo_jobs 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, share_links: sl.rows, photo_jobs: pj.rows };
|
||
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||
await storage.downloadAll(path.join(staging, 'uploads'));
|
||
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||
const outPath = path.join(BACKUP_DIR, `whatido-backup-${stamp}-${crypto.randomBytes(4).toString('hex')}.tar.gz`);
|
||
await tar.c({ gzip: { level: 1 }, file: outPath, cwd: staging }, ['data.json', 'uploads']);
|
||
const { size } = fs.statSync(outPath);
|
||
return { file: outPath, name: `whatido-backup-${stamp}.tar.gz`, size };
|
||
} finally {
|
||
fs.rmSync(staging, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function sendBackupArchive(res, archive) {
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.download(archive.file, archive.name, (err) => {
|
||
if (err && !res.headersSent) res.status(500).json({ error: 'Не удалось отправить бэкап' });
|
||
});
|
||
}
|
||
|
||
app.post('/api/backup', requireAdmin, async (req, res) => {
|
||
try {
|
||
pruneBackupTickets();
|
||
const archive = await buildBackupArchive();
|
||
const token = crypto.randomBytes(24).toString('hex');
|
||
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,
|
||
size: archive.size,
|
||
expires_at: new Date(expiresAt).toISOString(),
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/backup/:token', apiLimiter, (req, res) => {
|
||
const token = typeof req.params.token === 'string' ? req.params.token : '';
|
||
const ticket = backupTickets.get(token);
|
||
if (!ticket || ticket.expiresAt <= Date.now()) {
|
||
if (ticket) {
|
||
backupTickets.delete(token);
|
||
fs.rmSync(ticket.file, { force: true });
|
||
}
|
||
return res.status(404).json({ error: 'Ссылка на бэкап устарела. Сформируйте архив заново.' });
|
||
}
|
||
if (!fs.existsSync(ticket.file)) {
|
||
backupTickets.delete(token);
|
||
return res.status(410).json({ error: 'Файл бэкапа больше недоступен. Сформируйте архив заново.' });
|
||
}
|
||
sendBackupArchive(res, ticket);
|
||
});
|
||
|
||
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);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
sweepBackupStorage();
|
||
setInterval(() => { pruneBackupTickets(); sweepBackupStorage(); }, 60 * 1000).unref();
|
||
|
||
function cleanupUpload(req) {
|
||
try {
|
||
if (req?.file?.destination) fs.rmSync(req.file.destination, { recursive: true, force: true });
|
||
} catch {}
|
||
}
|
||
|
||
function peekGunzip(filePath, n) {
|
||
return new Promise((resolve) => {
|
||
const gunz = zlib.createGunzip();
|
||
const bufs = [];
|
||
let total = 0;
|
||
let done = false;
|
||
gunz.on('data', (c) => {
|
||
if (done) return;
|
||
const need = n - total;
|
||
bufs.push(c.length > need ? c.subarray(0, need) : c);
|
||
total += Math.min(c.length, need);
|
||
if (total >= n) {
|
||
done = true;
|
||
gunz.destroy();
|
||
}
|
||
});
|
||
gunz.on('error', () => resolve(null));
|
||
gunz.on('close', () => resolve(Buffer.concat(bufs)));
|
||
fs.createReadStream(filePath).pipe(gunz);
|
||
});
|
||
}
|
||
|
||
app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req, res) => {
|
||
if (!req.file) return res.status(400).json({ error: 'backup file required' });
|
||
let data;
|
||
let legacyPhotos = [];
|
||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-rst-'));
|
||
try {
|
||
const head = await peekGunzip(req.file.path, 8);
|
||
if (head && head[0] === 0x7b) {
|
||
const buf = await fs.promises.readFile(req.file.path);
|
||
const gunz = await gunzipAsync(buf);
|
||
data = JSON.parse(gunz.toString('utf8'));
|
||
legacyPhotos = data.photos || [];
|
||
} else {
|
||
await tar.x({ file: req.file.path, cwd: staging });
|
||
if (!fs.existsSync(path.join(staging, 'data.json')) && fs.existsSync(path.join(staging, 'db.sql.gz'))) {
|
||
fs.rmSync(staging, { recursive: true, force: true });
|
||
cleanupUpload(req);
|
||
return res.status(400).json({ error: 'Это архив скрипта scripts/backup.sh (db.sql.gz + _uploads) — восстанавливайте его через scripts/restore.sh. Для веб-восстановления скачайте архив в Настройках админки.' });
|
||
}
|
||
data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8'));
|
||
}
|
||
} catch {
|
||
fs.rmSync(staging, { recursive: true, force: true });
|
||
cleanupUpload(req);
|
||
return res.status(400).json({ error: 'Неверный файл бэкапа' });
|
||
}
|
||
if (!data || data.version !== 1 || !Array.isArray(data.groups)) {
|
||
fs.rmSync(staging, { recursive: true, force: true });
|
||
cleanupUpload(req);
|
||
return res.status(400).json({ error: 'Неверный формат бэкапа' });
|
||
}
|
||
let ndata;
|
||
try {
|
||
ndata = normalizeRestoreData(data);
|
||
} catch (e) {
|
||
fs.rmSync(staging, { recursive: true, force: true });
|
||
cleanupUpload(req);
|
||
return res.status(400).json({ error: 'Неверный формат бэкапа: ' + e.message });
|
||
}
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
await client.query('DELETE FROM project_files');
|
||
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');
|
||
await client.query('DELETE FROM users');
|
||
await client.query('DELETE FROM branches');
|
||
for (const x of ndata.branches) {
|
||
await client.query(
|
||
'INSERT INTO branches (id, name, address, phone, created_at) VALUES ($1,$2,$3,$4,$5)',
|
||
[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, cover_path, deleted_at, purge_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)',
|
||
[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, x.deleted_at || null, x.purge_at || null]
|
||
);
|
||
}
|
||
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) {
|
||
await client.query(
|
||
'INSERT INTO students (id, name, created_at, group_id, photo_path, profile) VALUES ($1,$2,$3,$4,$5,$6)',
|
||
[x.id, x.name, x.created_at, x.group_id, x.photo_path, x.profile ? JSON.stringify(x.profile) : null]
|
||
);
|
||
}
|
||
for (const x of ndata.modules) {
|
||
await client.query(
|
||
'INSERT INTO modules (id, name, lessons_count, is_active, photo_path, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
|
||
[x.id, x.name, x.lessons_count, x.is_active, x.photo_path, x.created_at]
|
||
);
|
||
}
|
||
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, photo_original_path, deleted_at, purge_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)',
|
||
[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.purge_at || null, x.created_at]
|
||
);
|
||
}
|
||
for (const x of ndata.photo_jobs) {
|
||
const ex = await client.query('SELECT 1 FROM entries WHERE id = $1', [x.entry_id]);
|
||
if (!ex.rowCount) continue;
|
||
await client.query(
|
||
'INSERT INTO photo_jobs (id, entry_id, action, params, before_path, after_path, status, applied, attempts, error, created_at, finished_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
|
||
[x.id, x.entry_id, x.action, x.params, x.before_path, x.after_path, x.status, x.applied, x.attempts, x.error, x.created_at, x.finished_at]
|
||
);
|
||
}
|
||
for (const x of ndata.project_files) {
|
||
await client.query(
|
||
'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) {
|
||
await client.query(
|
||
'INSERT INTO group_photos (id, group_id, photo_path, caption, taken_at, sort_order, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||
[x.id, x.group_id, x.photo_path, x.caption, x.taken_at, x.sort_order, x.created_at]
|
||
);
|
||
}
|
||
for (const x of ndata.entry_photos) {
|
||
await client.query(
|
||
'INSERT INTO entry_photos (id, entry_id, photo_path, caption, sort_order, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
|
||
[x.id, x.entry_id, x.photo_path, x.caption, x.sort_order, x.created_at]
|
||
);
|
||
}
|
||
for (const x of ndata.student_photos) {
|
||
const ex = await client.query('SELECT 1 FROM students WHERE id = $1', [x.student_id]);
|
||
if (!ex.rowCount) continue;
|
||
await client.query(
|
||
'INSERT INTO student_photos (id, student_id, photo_path, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO NOTHING',
|
||
[x.id, x.student_id, x.photo_path, x.created_at]
|
||
);
|
||
}
|
||
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', 'share_links', 'photo_jobs']) {
|
||
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').catch(() => {});
|
||
fs.rmSync(staging, { recursive: true, force: true });
|
||
cleanupUpload(req);
|
||
console.error('Restore failed:', e.message);
|
||
return res.status(500).json({ error: 'Ошибка восстановления: ' + e.message });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||
if (legacyPhotos.length) {
|
||
for (const p of legacyPhotos) {
|
||
if (!p.path || !SAFE_NAME.test(p.path)) continue;
|
||
await storage.put(p.path, Buffer.from(p.data, 'base64'));
|
||
}
|
||
} else {
|
||
await storage.uploadTree(path.join(staging, 'uploads'));
|
||
}
|
||
fs.rmSync(staging, { recursive: true, force: true });
|
||
cleanupUpload(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 });
|
||
});
|
||
|
||
// --- Share links ---
|
||
function normDates(o) {
|
||
if (o && o.date_from) o.date_from = o.date_from instanceof Date ? o.date_from.toISOString().slice(0, 10) : String(o.date_from).slice(0, 10);
|
||
if (o && o.date_to) o.date_to = o.date_to instanceof Date ? o.date_to.toISOString().slice(0, 10) : String(o.date_to).slice(0, 10);
|
||
return o;
|
||
}
|
||
|
||
function parseShareMessage(v) {
|
||
const s = typeof v === 'string' ? v.trim() : '';
|
||
if (s.length > 2000) throw new Error('Сообщение слишком длинное (макс. 2000 символов)');
|
||
return s || null;
|
||
}
|
||
|
||
function parseShareLinkUrl(v) {
|
||
const s = typeof v === 'string' ? v.trim() : '';
|
||
if (!s) return null;
|
||
if (s.length > 500) throw new Error('Ссылка слишком длинная (макс. 500 символов)');
|
||
let u;
|
||
try { u = new URL(s); } catch { throw new Error('Некорректная ссылка'); }
|
||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Ссылка должна начинаться с http:// или https://');
|
||
return s;
|
||
}
|
||
|
||
app.get('/api/links', requireAuth, async (req, res) => {
|
||
const s = branchScope(req.user);
|
||
let where = '';
|
||
const params = [];
|
||
if (!s.admin) {
|
||
if (s.ids.length) {
|
||
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
|
||
where = `WHERE l.group_id IN (${ph})`;
|
||
} else {
|
||
where = `WHERE l.group_id IS NULL AND 1 = 0`;
|
||
}
|
||
}
|
||
const limit = optInt(req.query.limit, 1, 200);
|
||
const offset = optInt(req.query.offset, 0, Infinity) || 0;
|
||
if (limit != null) {
|
||
params.push(limit);
|
||
params.push(offset);
|
||
const { rows: totalRows } = await pool.query(
|
||
`SELECT COUNT(*)::int AS total FROM share_links l
|
||
LEFT JOIN groups g ON g.id = l.group_id ${where}`,
|
||
params.slice(0, params.length - 2)
|
||
);
|
||
const { rows } = await pool.query(
|
||
`SELECT l.*, g.name AS group_name FROM share_links l
|
||
LEFT JOIN groups g ON g.id = l.group_id ${where} ORDER BY l.created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||
params
|
||
);
|
||
rows.forEach(normDates);
|
||
return res.json({ items: rows, total: totalRows[0].total });
|
||
}
|
||
const { rows } = await pool.query(
|
||
`SELECT l.*, g.name AS group_name FROM share_links l
|
||
LEFT JOIN groups g ON g.id = l.group_id ${where} ORDER BY l.created_at DESC`,
|
||
params
|
||
);
|
||
rows.forEach(normDates);
|
||
res.json(rows);
|
||
});
|
||
|
||
app.post('/api/links', requireAuth, async (req, res) => {
|
||
const { name, group_id, student_name, date_from, date_to, expires_at, access_password } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||
let message, linkUrl;
|
||
try {
|
||
message = parseShareMessage(req.body.message);
|
||
linkUrl = parseShareLinkUrl(req.body.link_url);
|
||
} catch (e) {
|
||
return res.status(400).json({ error: e.message });
|
||
}
|
||
if (req.user.role !== 'admin' && group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const token = crypto.randomBytes(20).toString('hex');
|
||
let passwordHash = null;
|
||
if (access_password && access_password.trim()) {
|
||
passwordHash = await bcrypt.hash(access_password.trim(), 10);
|
||
}
|
||
let expiresAt = null;
|
||
if (expires_at) {
|
||
const parsed = new Date(expires_at);
|
||
if (isNaN(parsed.getTime())) {
|
||
return res.status(400).json({ error: 'Неверный формат даты истечения' });
|
||
}
|
||
expiresAt = parsed.toISOString();
|
||
} else {
|
||
// Default 7 days from now
|
||
const defaultExp = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||
expiresAt = defaultExp.toISOString();
|
||
}
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO share_links (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)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`,
|
||
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, expiresAt, passwordHash, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos]
|
||
);
|
||
await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() });
|
||
invalidateShare();
|
||
res.status(201).json(normDates(rows[0]));
|
||
});
|
||
|
||
app.put('/api/links/:id', requireAuth, async (req, res) => {
|
||
const { name, group_id, student_name, date_from, date_to, expires_at, access_password } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||
let message, linkUrl;
|
||
try {
|
||
message = parseShareMessage(req.body.message);
|
||
linkUrl = parseShareLinkUrl(req.body.link_url);
|
||
} catch (e) {
|
||
return res.status(400).json({ error: e.message });
|
||
}
|
||
if (req.user.role !== 'admin') {
|
||
const { rows: lr } = await pool.query('SELECT group_id FROM share_links WHERE id = $1', [req.params.id]);
|
||
if (!lr.length) return res.status(404).json({ error: 'Не найдено' });
|
||
const curGid = lr[0].group_id;
|
||
if (curGid && !(await groupBelongsToBranches(req.user, curGid))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой ссылке' });
|
||
}
|
||
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
}
|
||
let passwordHash = undefined;
|
||
if (access_password !== undefined) {
|
||
if (access_password && access_password.trim()) {
|
||
passwordHash = await bcrypt.hash(access_password.trim(), 10);
|
||
} else {
|
||
passwordHash = null; // Clear password if empty string sent
|
||
}
|
||
}
|
||
let expiresAt = undefined;
|
||
if (expires_at !== undefined) {
|
||
if (expires_at) {
|
||
const parsed = new Date(expires_at);
|
||
if (isNaN(parsed.getTime())) {
|
||
return res.status(400).json({ error: 'Неверный формат даты истечения' });
|
||
}
|
||
expiresAt = parsed.toISOString();
|
||
} else {
|
||
expiresAt = null; // Clear expiry if null sent
|
||
}
|
||
}
|
||
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'show_student_names = $6', 'message = $7', 'link_url = $8', 'show_student_message = $9', 'show_entry_date = $10', 'show_group_photos = $11'];
|
||
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos];
|
||
let paramIdx = 12;
|
||
if (passwordHash !== undefined) {
|
||
fields.push(`access_password_hash = $${paramIdx++}`);
|
||
values.push(passwordHash);
|
||
}
|
||
if (expiresAt !== undefined) {
|
||
fields.push(`expires_at = $${paramIdx++}`);
|
||
values.push(expiresAt);
|
||
}
|
||
values.push(req.params.id);
|
||
const { rows } = await pool.query(
|
||
`UPDATE share_links SET ${fields.join(', ')} WHERE id = $${paramIdx} RETURNING *`,
|
||
values
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
await logAudit(req, 'link.update', { id: req.params.id, name: name.trim() });
|
||
invalidateShare();
|
||
res.json(normDates(rows[0]));
|
||
});
|
||
|
||
app.delete('/api/links/:id', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const { rows: lr } = await pool.query('SELECT group_id FROM share_links WHERE id = $1', [req.params.id]);
|
||
if (!lr.length) return res.status(404).json({ error: 'Не найдено' });
|
||
if (lr[0].group_id && !(await groupBelongsToBranches(req.user, lr[0].group_id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой ссылке' });
|
||
}
|
||
}
|
||
await pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]);
|
||
await logAudit(req, 'link.delete', { id: req.params.id });
|
||
invalidateShare();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
||
const { rows } = await pool.query(
|
||
`SELECT l.*, g.name AS group_name FROM share_links l
|
||
LEFT JOIN groups g ON g.id = l.group_id WHERE l.token = $1`,
|
||
[req.params.token]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
|
||
normDates(rows[0]);
|
||
const l = rows[0];
|
||
|
||
// Check expiry
|
||
if (l.expires_at && new Date(l.expires_at) < new Date()) {
|
||
return res.status(410).json({ error: 'Срок действия ссылки истёк' });
|
||
}
|
||
|
||
// Check password
|
||
if (l.access_password_hash) {
|
||
const providedPassword = req.headers['x-share-password'] || req.query.password;
|
||
if (!providedPassword) {
|
||
return res.status(401).json({ error: 'Требуется пароль', passwordRequired: true });
|
||
}
|
||
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
||
if (!valid) {
|
||
await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS);
|
||
return res.status(401).json({ error: 'Неверный пароль' });
|
||
}
|
||
}
|
||
|
||
const payload = await cacheWrap('share:payload:' + req.params.token, SHARE_TTL_MS, async () => {
|
||
const conditions = [];
|
||
const params = [];
|
||
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||
conditions.push('e.deleted_at IS NULL');
|
||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||
const { rows: entries } = await pool.query(
|
||
`SELECT e.*, g.name AS group_name FROM entries e
|
||
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`,
|
||
params
|
||
);
|
||
let files = {};
|
||
if (entries.length) {
|
||
const fRes = await pool.query(
|
||
'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
||
[entries.map(r => r.id)]
|
||
);
|
||
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push({ token: f.token, name: f.name }); });
|
||
}
|
||
entries.forEach(r => { r.files = files[r.id] || []; });
|
||
|
||
const showStudentNames = l.show_student_names != null ? l.show_student_names : (await getSetting('share_show_student_names', 'true')) !== 'false';
|
||
|
||
if (!showStudentNames) {
|
||
const nameMap = new Map();
|
||
let counter = 1;
|
||
entries.forEach(e => {
|
||
if (!nameMap.has(e.student_name)) {
|
||
nameMap.set(e.student_name, `Ученик ${counter++}`);
|
||
}
|
||
e.student_name = nameMap.get(e.student_name);
|
||
});
|
||
}
|
||
|
||
let photos = [];
|
||
if (l.group_id) {
|
||
const pRes = await pool.query(
|
||
`SELECT id, photo_path, caption, taken_at, created_at FROM group_photos
|
||
WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
|
||
[l.group_id]
|
||
);
|
||
photos = pRes.rows.map(r => ({ id: r.id, photo_path: r.photo_path, caption: r.caption, taken_at: r.taken_at, created_at: r.created_at }));
|
||
}
|
||
return {
|
||
name: l.name,
|
||
group_name: l.group_name,
|
||
student_name: l.student_name,
|
||
group_id: l.group_id,
|
||
date_from: l.date_from,
|
||
date_to: l.date_to,
|
||
message: l.message,
|
||
link_url: l.link_url,
|
||
created_at: l.created_at,
|
||
expires_at: l.expires_at,
|
||
show_student_names: showStudentNames,
|
||
show_student_message: l.show_student_message != null ? l.show_student_message : (await getSetting('share_show_student_message', 'false')) === 'true',
|
||
show_entry_date: l.show_entry_date != null ? l.show_entry_date : (await getSetting('share_show_entry_date', 'false')) === 'true',
|
||
show_group_photos: l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false',
|
||
entries,
|
||
photos: (l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false') ? photos : [],
|
||
};
|
||
});
|
||
res.json(payload);
|
||
});
|
||
|
||
app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res) => {
|
||
const { shareToken, fileToken } = req.params;
|
||
const { rows: shareRows } = await pool.query(
|
||
`SELECT l.* FROM share_links l WHERE l.token = $1`, [shareToken]
|
||
);
|
||
if (!shareRows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
|
||
const l = shareRows[0];
|
||
if (l.expires_at && new Date(l.expires_at) < new Date()) {
|
||
return res.status(410).json({ error: 'Срок действия ссылки истёк' });
|
||
}
|
||
if (l.access_password_hash) {
|
||
const providedPassword = req.headers['x-share-password'] || req.query.password;
|
||
if (!providedPassword) return res.status(401).json({ error: 'Требуется пароль' });
|
||
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
||
if (!valid) {
|
||
await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS);
|
||
return res.status(401).json({ error: 'Неверный пароль' });
|
||
}
|
||
}
|
||
const conditions = ['e.deleted_at IS NULL'];
|
||
const params = [];
|
||
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||
params.push(fileToken);
|
||
const { rows } = await pool.query(
|
||
`SELECT pf.path, pf.name FROM project_files pf
|
||
JOIN entries e ON e.id = pf.entry_id
|
||
WHERE pf.token = $${params.length} AND ${conditions.join(' AND ')}`,
|
||
params
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||
const r = rows[0];
|
||
const key = storage.keyFromPath(r.path);
|
||
if (!key) return res.status(404).json({ error: 'File missing' });
|
||
if (isImageName(r.name)) {
|
||
if (req.query.thumb) return sendImageThumb(res, key);
|
||
const ok = await storage.streamTo(res, key, { cacheControl: 'public, max-age=31536000, immutable' });
|
||
if (!ok && !res.headersSent) return res.status(404).json({ error: 'File missing' });
|
||
return;
|
||
}
|
||
if (!(await storage.streamTo(res, key, { download: true, name: r.name })) && !res.headersSent) {
|
||
return res.status(404).json({ error: 'File missing' });
|
||
}
|
||
});
|
||
|
||
app.get('/s/:token', (req, res) => {
|
||
res.sendFile(path.join(__dirname, 'public', 'share.html'));
|
||
});
|
||
|
||
app.get('/r/:token', (req, res) => {
|
||
res.sendFile(path.join(__dirname, 'public', 'report.html'));
|
||
});
|
||
|
||
// --- Groups CRUD ---
|
||
app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
|
||
const rows = await cacheWrap('groups:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
|
||
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
||
const { rows } = await pool.query(
|
||
`SELECT g.*,
|
||
COALESCE(g.cover_path,
|
||
(SELECT photo_path FROM group_photos
|
||
WHERE group_id = g.id
|
||
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
|
||
LIMIT 1)) AS cover_path,
|
||
b.name AS branch_name
|
||
FROM groups g
|
||
LEFT JOIN branches b ON b.id = g.branch_id
|
||
WHERE 1=1 AND g.deleted_at IS NULL${bw.where}
|
||
ORDER BY g.id`,
|
||
bw.params
|
||
);
|
||
return rows;
|
||
});
|
||
res.json(rows);
|
||
});
|
||
|
||
app.get('/api/groups/active', apiLimiter, async (_, res) => {
|
||
const rows = await cacheWrap('groups:active', PUBLIC_TTL_MS, async () => {
|
||
const { rows } = await pool.query(`
|
||
SELECT * FROM groups
|
||
WHERE deleted_at IS NULL
|
||
AND day_of_week IS NOT NULL
|
||
AND time_start IS NOT NULL
|
||
AND time_end IS NOT NULL
|
||
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
||
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end
|
||
ORDER BY id
|
||
`);
|
||
return rows;
|
||
});
|
||
res.json(rows);
|
||
});
|
||
|
||
app.put('/api/groups/:id', requireAuth, async (req, res) => {
|
||
const { name, day_of_week, time_start, time_end, branch_id, tutor_id } = req.body;
|
||
if (!(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const isAdmin = req.user.role === 'admin';
|
||
if (!isAdmin && branch_id !== undefined) {
|
||
return res.status(403).json({ error: 'Назначение филиала — только для администратора' });
|
||
}
|
||
const tutorProvided = tutor_id !== undefined;
|
||
let effectiveTutor = null;
|
||
if (tutor_id !== undefined && tutor_id !== null && tutor_id !== '') {
|
||
const t = await pool.query(`SELECT id FROM users WHERE id = $1 AND role = 'tutor'`, [tutor_id]);
|
||
if (!t.rows.length) return res.status(400).json({ error: 'Пользователь не найден или не является тутором' });
|
||
effectiveTutor = t.rows[0].id;
|
||
}
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`UPDATE groups SET
|
||
name = COALESCE($1, name),
|
||
day_of_week = $2,
|
||
time_start = $3,
|
||
time_end = $4,
|
||
branch_id = $5,
|
||
tutor_id = CASE WHEN $6::boolean THEN $7::int ELSE tutor_id END
|
||
WHERE id = $8 RETURNING *`,
|
||
[name,
|
||
day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week,
|
||
time_start || null, time_end || null,
|
||
branch_id === undefined || branch_id === null || branch_id === '' ? null : branch_id,
|
||
tutorProvided,
|
||
tutorProvided ? effectiveTutor : null,
|
||
req.params.id]
|
||
);
|
||
await logAudit(req, 'group.update', { id: req.params.id, ...req.body });
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
res.json(rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' });
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
app.post('/api/groups', requireAuth, async (req, res) => {
|
||
const { name, branch_id, tutor_id } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||
const isAdmin = req.user.role === 'admin';
|
||
const effectiveBranch = branch_id === undefined || branch_id === null || branch_id === '' ? null : Number(branch_id);
|
||
if (!isAdmin && branch_id !== undefined && branch_id !== null && branch_id !== '') {
|
||
return res.status(403).json({ error: 'Назначение филиала — только для администратора' });
|
||
}
|
||
let effectiveTutor = null;
|
||
if (tutor_id !== undefined && tutor_id !== null && tutor_id !== '') {
|
||
const t = await pool.query(`SELECT id FROM users WHERE id = $1 AND role = 'tutor'`, [tutor_id]);
|
||
if (!t.rows.length) return res.status(400).json({ error: 'Пользователь не найден или не является тутором' });
|
||
effectiveTutor = t.rows[0].id;
|
||
}
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO groups (name, branch_id, tutor_id) VALUES ($1, $2, $3) RETURNING *',
|
||
[name.trim(), isAdmin ? effectiveBranch : null, effectiveTutor]
|
||
);
|
||
await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id });
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
res.status(201).json(rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
async function hardDeleteGroup(groupId) {
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
const g = await client.query('SELECT id FROM groups WHERE id = $1', [groupId]);
|
||
if (!g.rowCount) { await client.query('ROLLBACK'); return null; }
|
||
const gres = await client.query('SELECT photo_path AS p FROM group_photos WHERE group_id = $1', [groupId]);
|
||
const eres = await client.query(
|
||
`SELECT photo_path AS p FROM entries WHERE group_id = $1
|
||
UNION ALL
|
||
SELECT pf.path AS p FROM project_files pf JOIN entries e ON e.id = pf.entry_id WHERE e.group_id = $1
|
||
UNION ALL
|
||
SELECT ep.photo_path AS p FROM entry_photos ep JOIN entries e ON e.id = ep.entry_id WHERE e.group_id = $1`,
|
||
[groupId]
|
||
);
|
||
const files = eres.rows.concat(gres.rows);
|
||
await client.query('DELETE FROM group_photos WHERE group_id = $1', [groupId]);
|
||
await client.query('DELETE FROM entries WHERE group_id = $1', [groupId]);
|
||
await client.query('UPDATE students SET group_id = NULL WHERE group_id = $1', [groupId]);
|
||
await client.query('DELETE FROM share_links WHERE group_id = $1', [groupId]);
|
||
const d = await client.query('DELETE FROM groups WHERE id = $1', [groupId]);
|
||
await client.query('COMMIT');
|
||
files.forEach(r => safeUnlink(r.p));
|
||
return { entries: eres.rowCount, photos: gres.rowCount };
|
||
} catch (e) {
|
||
await client.query('ROLLBACK');
|
||
throw e;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
|
||
app.delete('/api/groups/:id', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const result = await pool.query(
|
||
'UPDATE groups SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL',
|
||
[req.params.id]
|
||
);
|
||
if (result.rowCount === 0) {
|
||
return res.status(404).json({ error: 'Группа не найдена или уже в корзине' });
|
||
}
|
||
await logAudit(req, 'group.soft-delete', { id: req.params.id });
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.put('/api/groups/:id/restore', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const result = await pool.query(
|
||
'UPDATE groups SET deleted_at = NULL, purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL',
|
||
[req.params.id]
|
||
);
|
||
if (result.rowCount === 0) {
|
||
return res.status(404).json({ error: 'Группа не найдена или не в корзине' });
|
||
}
|
||
await logAudit(req, 'group.restore', { id: req.params.id });
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.delete('/api/groups/:id/permanent', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const days = await trashPurgeDays();
|
||
const result = await pool.query(
|
||
`UPDATE groups SET purge_at = now() + ($2 || ' days')::interval WHERE id = $1 AND deleted_at IS NOT NULL AND purge_at IS NULL`,
|
||
[req.params.id, days]
|
||
);
|
||
if (result.rowCount === 0) {
|
||
return res.status(404).json({ error: 'Группа не найдена, не в корзине или уже помечена на удаление' });
|
||
}
|
||
await logAudit(req, 'group.schedule-delete', { id: req.params.id, days });
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
res.json({ ok: true, purge_at: (await pool.query('SELECT purge_at FROM groups WHERE id = $1', [req.params.id])).rows[0].purge_at });
|
||
});
|
||
|
||
app.put('/api/groups/:id/unschedule', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const result = await pool.query(
|
||
'UPDATE groups SET purge_at = NULL WHERE id = $1 AND purge_at IS NOT NULL',
|
||
[req.params.id]
|
||
);
|
||
if (result.rowCount === 0) {
|
||
return res.status(404).json({ error: 'Группа не найдена или не помечена на удаление' });
|
||
}
|
||
await logAudit(req, 'group.unschedule', { id: req.params.id });
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// --- Branches CRUD ---
|
||
app.get('/api/branches', requireAuth, async (req, res) => {
|
||
const bw = branchScope(req.user);
|
||
let where = '';
|
||
const params = [];
|
||
if (!bw.admin) {
|
||
if (bw.ids.length) {
|
||
where = ` WHERE b.id IN (${bw.ids.map(id => `$${params.push(id)}`).join(',')})`;
|
||
} else {
|
||
where = ' WHERE 1 = 0';
|
||
}
|
||
}
|
||
const { rows } = await pool.query(
|
||
`SELECT b.*, count(g.id)::int AS groups_count
|
||
FROM branches b
|
||
LEFT JOIN groups g ON g.branch_id = b.id
|
||
${where}
|
||
GROUP BY b.id
|
||
ORDER BY b.id`,
|
||
params
|
||
);
|
||
res.json(rows);
|
||
});
|
||
|
||
app.post('/api/branches', requireAdmin, async (req, res) => {
|
||
const { name, address, phone } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO branches (name, address, phone) VALUES ($1, $2, $3) RETURNING *',
|
||
[name.trim(), address?.trim() || null, phone?.trim() || null]
|
||
);
|
||
await logAudit(req, 'branch.create', { id: rows[0].id, name: name.trim() });
|
||
invalidateGroups();
|
||
res.status(201).json(rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' });
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
app.put('/api/branches/:id', requireAdmin, async (req, res) => {
|
||
const { name, address, phone } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`UPDATE branches SET
|
||
name = $1,
|
||
address = $2,
|
||
phone = $3
|
||
WHERE id = $4 RETURNING *`,
|
||
[name.trim(), address?.trim() || null, phone?.trim() || null, req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
await logAudit(req, 'branch.update', { id: req.params.id, ...req.body });
|
||
invalidateGroups();
|
||
res.json(rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' });
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
app.delete('/api/branches/:id', requireAdmin, async (req, res) => {
|
||
const { rows } = await pool.query('SELECT id FROM groups WHERE branch_id = $1', [req.params.id]);
|
||
if (rows.length) return res.status(400).json({ error: 'Нельзя удалить филиал: есть привязанные группы' });
|
||
await pool.query('DELETE FROM branches WHERE id = $1', [req.params.id]);
|
||
await logAudit(req, 'branch.delete', { id: req.params.id });
|
||
invalidateGroups();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.get('/api/groups/:id/photos', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const { limit, offset } = req.query;
|
||
const { rows: crows } = await pool.query(
|
||
'SELECT count(*)::int AS n FROM group_photos WHERE group_id = $1',
|
||
[req.params.id]
|
||
);
|
||
const total = crows[0].n;
|
||
let q = `SELECT * FROM group_photos WHERE group_id = $1
|
||
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC`;
|
||
const qparams = [req.params.id];
|
||
const lim = parseInt(limit, 10);
|
||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||
const off = parseInt(offset, 10);
|
||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||
const { rows } = await pool.query(q, qparams);
|
||
res.json({ photos: rows, total });
|
||
});
|
||
|
||
const groupPhotoUpload = upload.single('photo');
|
||
app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
|
||
groupPhotoUpload(req, res, async (err) => {
|
||
if (err) {
|
||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
||
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||
}
|
||
try {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
removeUpload(req.file);
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
next();
|
||
} catch (e) {
|
||
removeUpload(req.file);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
}, async (req, res) => {
|
||
const { caption, taken_at } = req.body;
|
||
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
|
||
try {
|
||
await convertPhoto(req.file);
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at, sort_order)
|
||
VALUES ($1, $2, $3, $4,
|
||
COALESCE((SELECT MIN(sort_order) - 1 FROM group_photos WHERE group_id = $1), 0))
|
||
RETURNING *`,
|
||
[req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null]
|
||
);
|
||
await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path });
|
||
invalidateShare();
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
res.status(201).json(rows[0]);
|
||
} catch (e) {
|
||
safeUnlink(`uploads/${req.file.filename}`);
|
||
console.error('POST /api/groups/:id/photos:', e);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
app.put('/api/groups/:id/photos/reorder', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const { order } = req.body;
|
||
if (!Array.isArray(order) || order.some(id => !Number.isInteger(Number(id)))) {
|
||
return res.status(400).json({ error: 'Некорректный порядок фото' });
|
||
}
|
||
const ids = order.map(id => Number(id));
|
||
if (new Set(ids).size !== ids.length) {
|
||
return res.status(400).json({ error: 'Порядок фото содержит дубликаты' });
|
||
}
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
const { rows: owned } = await client.query(
|
||
'SELECT id FROM group_photos WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC',
|
||
[req.params.id]
|
||
);
|
||
const ownedIds = owned.map(r => r.id);
|
||
if (ids.some(id => !ownedIds.includes(id))) {
|
||
throw { http: 404, message: 'Фото не найдено' };
|
||
}
|
||
const rest = ownedIds.filter(id => !ids.includes(id));
|
||
const allIds = [...ids, ...rest];
|
||
for (let i = 0; i < allIds.length; i++) {
|
||
await client.query(
|
||
'UPDATE group_photos SET sort_order = $1 WHERE id = $2',
|
||
[i + 1, allIds[i]]
|
||
);
|
||
}
|
||
await client.query('COMMIT');
|
||
await logAudit(req, 'group.photo.reorder', { group_id: req.params.id, order: ids });
|
||
invalidateShare();
|
||
invalidateGroups();
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
await client.query('ROLLBACK');
|
||
if (e.http) return res.status(e.http).json({ error: e.message });
|
||
throw e;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
app.put('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const { caption, taken_at } = req.body;
|
||
const { rows } = await pool.query(
|
||
`UPDATE group_photos SET
|
||
caption = $1,
|
||
taken_at = $2
|
||
WHERE id = $3 AND group_id = $4 RETURNING *`,
|
||
[caption?.trim() || null, taken_at || null, req.params.photoId, req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
await logAudit(req, 'group.photo.update', { group_id: req.params.id, photo_id: req.params.photoId });
|
||
invalidateShare();
|
||
res.json(rows[0]);
|
||
});
|
||
|
||
app.delete('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
'SELECT photo_path FROM group_photos WHERE id = $1 AND group_id = $2',
|
||
[req.params.photoId, req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
safeUnlink(rows[0].photo_path);
|
||
await pool.query('UPDATE groups SET cover_path = NULL WHERE id = $1 AND cover_path = $2', [req.params.id, rows[0].photo_path]);
|
||
await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]);
|
||
await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId });
|
||
invalidateShare();
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
'SELECT photo_path FROM group_photos WHERE id = $1 AND group_id = $2',
|
||
[req.params.photoId, req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
await pool.query('UPDATE groups SET cover_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
||
await logAudit(req, 'group.photo.set_cover', { group_id: req.params.id, photo_id: req.params.photoId });
|
||
invalidateShare();
|
||
invalidateGroups();
|
||
const { rows: gRows } = await pool.query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
|
||
res.json(gRows[0]);
|
||
});
|
||
|
||
// --- Modules (темы модулей) ---
|
||
app.get('/api/modules', apiLimiter, async (req, res) => {
|
||
const { limit, offset, search, active } = req.query;
|
||
const conditions = [];
|
||
const params = [];
|
||
if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); }
|
||
if (active === '1' || active === 'true') conditions.push('m.is_active = true');
|
||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params);
|
||
const total = crows[0].n;
|
||
let q = `SELECT m.*, count(e.id)::int AS entries_count
|
||
FROM modules m
|
||
LEFT JOIN entries e ON e.module_id = m.id${where}
|
||
GROUP BY m.id ORDER BY m.is_active DESC, m.id`;
|
||
const qparams = params.slice();
|
||
const lim = parseInt(limit, 10);
|
||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||
const off = parseInt(offset, 10);
|
||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||
const { rows } = await pool.query(q, qparams);
|
||
res.json({ modules: rows, total });
|
||
});
|
||
|
||
function parseLessonsCount(v) {
|
||
if (v === undefined || v === null || v === '') return 0;
|
||
const n = Number(v);
|
||
if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error('Количество занятий — целое число от 0 до 10000');
|
||
return n;
|
||
}
|
||
|
||
app.post('/api/modules', requireAdmin, async (req, res) => {
|
||
const { name, lessons_count } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||
let lessons;
|
||
try { lessons = parseLessonsCount(lessons_count); }
|
||
catch (e) { return res.status(400).json({ error: e.message }); }
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO modules (name, lessons_count) VALUES ($1, $2) RETURNING *',
|
||
[name.trim(), lessons]
|
||
);
|
||
await logAudit(req, 'module.create', { id: rows[0].id, name: name.trim(), lessons_count: lessons });
|
||
res.status(201).json(rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
app.put('/api/modules/:id', requireAdmin, async (req, res) => {
|
||
const { name, lessons_count } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||
let lessons;
|
||
try { lessons = parseLessonsCount(lessons_count); }
|
||
catch (e) { return res.status(400).json({ error: e.message }); }
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'UPDATE modules SET name = $1, lessons_count = $2 WHERE id = $3 RETURNING *',
|
||
[name.trim(), lessons, req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
await logAudit(req, 'module.update', { id: req.params.id, name: name.trim(), lessons_count: lessons });
|
||
res.json(rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
app.delete('/api/modules/:id', requireAdmin, async (req, res) => {
|
||
const { rows } = await pool.query('UPDATE modules SET is_active = false WHERE id = $1 RETURNING id', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
await logAudit(req, 'module.delete', { id: req.params.id });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.put('/api/modules/:id/restore', requireAdmin, async (req, res) => {
|
||
const { rows } = await pool.query('UPDATE modules SET is_active = true WHERE id = $1 RETURNING *', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
await logAudit(req, 'module.restore', { id: req.params.id });
|
||
res.json(rows[0]);
|
||
});
|
||
|
||
const modulePhotoUpload = upload.single('photo');
|
||
app.post('/api/modules/:id/photo', requireAdmin, (req, res) => {
|
||
modulePhotoUpload(req, res, async (err) => {
|
||
if (err) {
|
||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
||
if (err.message === 'Only images') return res.status(400).json({ error: 'Картинка: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||
}
|
||
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
|
||
try {
|
||
const mod = await pool.query('SELECT photo_path FROM modules WHERE id = $1', [req.params.id]);
|
||
if (!mod.rows.length) {
|
||
removeUpload(req.file);
|
||
return res.status(404).json({ error: 'Не найдено' });
|
||
}
|
||
await convertPhoto(req.file);
|
||
const oldPath = mod.rows[0].photo_path;
|
||
const { rows } = await pool.query(
|
||
'UPDATE modules SET photo_path = $1 WHERE id = $2 RETURNING *',
|
||
[`/uploads/${req.file.filename}`, req.params.id]
|
||
);
|
||
if (oldPath) safeUnlink(oldPath);
|
||
await logAudit(req, 'module.photo.create', { id: req.params.id, photo_path: rows[0].photo_path });
|
||
res.json(rows[0]);
|
||
} catch (e) {
|
||
removeUpload(req.file);
|
||
console.error('POST /api/modules/:id/photo:', e);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
});
|
||
|
||
app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
|
||
const { rows } = await pool.query('SELECT id, photo_path FROM modules WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
if (rows[0].photo_path) safeUnlink(rows[0].photo_path);
|
||
await pool.query('UPDATE modules SET photo_path = NULL WHERE id = $1', [req.params.id]);
|
||
await logAudit(req, 'module.photo.delete', { id: req.params.id, photo_path: rows[0].photo_path });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// --- Students CRUD ---
|
||
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
|
||
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
|
||
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
||
const { rows } = await pool.query(
|
||
`SELECT s.*, g.name AS group_name FROM students s
|
||
LEFT JOIN groups g ON g.id = s.group_id
|
||
WHERE 1=1${bw.where} ORDER BY s.name`,
|
||
bw.params
|
||
);
|
||
return rows;
|
||
});
|
||
res.json(rows);
|
||
});
|
||
|
||
app.get('/api/students/names', requireAuth, async (req, res) => {
|
||
const bw = branchWhere(req.user, 'g');
|
||
const { rows } = await pool.query(
|
||
`SELECT DISTINCT e.student_name AS name FROM entries e
|
||
JOIN groups g ON g.id = e.group_id
|
||
WHERE e.student_name IS NOT NULL AND e.student_name <> ''${bw.where}
|
||
ORDER BY name`,
|
||
bw.params
|
||
);
|
||
res.json(rows.map(r => r.name));
|
||
});
|
||
|
||
app.post('/api/students', requireAuth, async (req, res) => {
|
||
const { name, group_id } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||
const gid = group_id ? Number(group_id) : null;
|
||
if (req.user.role !== 'admin' && gid && !(await groupBelongsToBranches(req.user, gid))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *',
|
||
[name.trim(), gid]
|
||
);
|
||
await logAudit(req, 'student.create', { id: rows[0].id, name: name.trim() });
|
||
invalidateStudents();
|
||
invalidateStats();
|
||
res.status(201).json(rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
app.put('/api/students/:id', requireAuth, async (req, res) => {
|
||
const { name, group_id } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||
const newGid = group_id === undefined || group_id === null || group_id === '' ? null : Number(group_id);
|
||
if (req.user.role !== 'admin') {
|
||
const { rows: cur } = await pool.query(
|
||
`SELECT s.group_id FROM students s LEFT JOIN groups g ON g.id = s.group_id WHERE s.id = $1`,
|
||
[req.params.id]
|
||
);
|
||
if (!cur.length) return res.status(404).json({ error: 'Not found' });
|
||
const curGid = cur[0].group_id;
|
||
if (curGid && !(await groupBelongsToBranches(req.user, curGid))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этому ученику' });
|
||
}
|
||
if (newGid && !(await groupBelongsToBranches(req.user, newGid))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
}
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`UPDATE students SET
|
||
name = $1,
|
||
group_id = $2
|
||
WHERE id = $3 RETURNING *`,
|
||
[name.trim(), newGid, req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||
await logAudit(req, 'student.update', { id: req.params.id, name: name.trim() });
|
||
invalidateStudents();
|
||
res.json(rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
||
throw e;
|
||
}
|
||
});
|
||
|
||
app.post('/api/students/batch-group', requireAuth, async (req, res) => {
|
||
const { group_id, student_ids } = req.body;
|
||
if (!group_id || !Array.isArray(student_ids) || !student_ids.length) {
|
||
return res.status(400).json({ error: 'group_id and student_ids required' });
|
||
}
|
||
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, group_id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const ids = [...new Set(student_ids.map(Number).filter(Boolean))];
|
||
if (!ids.length) return res.status(400).json({ error: 'No valid students' });
|
||
const params = [group_id, ...ids];
|
||
const placeholders = ids.map((_, i) => `$${i + 2}`).join(',');
|
||
const { rows } = await pool.query(
|
||
`UPDATE students SET group_id = $1 WHERE id IN (${placeholders}) RETURNING id`,
|
||
params
|
||
);
|
||
await logAudit(req, 'student.batch-group', { group_id, count: rows.length });
|
||
invalidateStudents();
|
||
res.json({ ok: true, updated: rows.length });
|
||
});
|
||
|
||
app.delete('/api/students/:id', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const { rows: cur } = await pool.query(
|
||
'SELECT s.group_id FROM students s WHERE s.id = $1', [req.params.id]
|
||
);
|
||
if (!cur.length) return res.status(404).json({ error: 'Not found' });
|
||
const gid = cur[0].group_id;
|
||
if (gid && !(await groupBelongsToBranches(req.user, gid))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этому ученику' });
|
||
}
|
||
}
|
||
await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
|
||
const { rows: spRows } = await pool.query('SELECT photo_path AS p FROM student_photos WHERE student_id = $1', [req.params.id]);
|
||
spRows.forEach(r => safeUnlink(r.p));
|
||
await logAudit(req, 'student.delete', { id: req.params.id });
|
||
invalidateStudents();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
async function studentProfileAccess(user, id) {
|
||
const { rows } = await pool.query(
|
||
'SELECT id, name, group_id, photo_path, profile FROM students WHERE id = $1',
|
||
[id]
|
||
);
|
||
if (!rows.length) return { found: false };
|
||
const gid = rows[0].group_id;
|
||
if (user.role !== 'admin' && gid && !(await groupBelongsToBranches(user, gid))) {
|
||
return { found: false, forbidden: true };
|
||
}
|
||
return { found: true, student: rows[0] };
|
||
}
|
||
|
||
app.get('/api/students/:id/profile', requireAuth, async (req, res) => {
|
||
let id;
|
||
try {
|
||
id = reqInt(req.params.id);
|
||
} catch {
|
||
return res.status(400).json({ error: 'Неверный id ученика' });
|
||
}
|
||
const acc = await studentProfileAccess(req.user, id);
|
||
if (!acc.found) {
|
||
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||
}
|
||
res.json({ ...acc.student, profile: acc.student.profile || null });
|
||
});
|
||
|
||
app.put('/api/students/:id/profile', requireAuth, async (req, res) => {
|
||
let id;
|
||
try {
|
||
id = reqInt(req.params.id);
|
||
} catch {
|
||
return res.status(400).json({ error: 'Неверный id ученика' });
|
||
}
|
||
let profile;
|
||
let photoPath;
|
||
try {
|
||
profile = sanitizeStudentProfile(req.body?.profile);
|
||
photoPath = req.body?.photo_path === undefined ? undefined : optUploadPath(req.body.photo_path, 255);
|
||
} catch (e) {
|
||
return res.status(400).json({ error: 'Неверные данные профиля: ' + e.message });
|
||
}
|
||
const acc = await studentProfileAccess(req.user, id);
|
||
if (!acc.found) {
|
||
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||
}
|
||
const sets = ['profile = $1'];
|
||
const params = [profile ? JSON.stringify(profile) : null];
|
||
if (photoPath !== undefined) {
|
||
params.push(photoPath);
|
||
sets.push(`photo_path = $${params.length}`);
|
||
}
|
||
params.push(id);
|
||
const { rows } = await pool.query(
|
||
`UPDATE students SET ${sets.join(', ')} WHERE id = $${params.length}
|
||
RETURNING id, name, group_id, photo_path, profile`,
|
||
params
|
||
);
|
||
await logAudit(req, 'student.profile.update', { id, name: acc.student.name, blocks: profile ? Object.keys(profile) : [] });
|
||
invalidateStudents();
|
||
res.json(rows[0]);
|
||
});
|
||
|
||
const studentPhotoUpload = upload.single('photo');
|
||
|
||
app.get('/api/students/:id/photos', requireAuth, async (req, res) => {
|
||
let id;
|
||
try {
|
||
id = reqInt(req.params.id);
|
||
} catch {
|
||
return res.status(400).json({ error: 'Неверный id ученика' });
|
||
}
|
||
const acc = await studentProfileAccess(req.user, id);
|
||
if (!acc.found) {
|
||
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
'SELECT id, photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC',
|
||
[id]
|
||
);
|
||
res.json({ photos: rows, photo_path: acc.student.photo_path || null });
|
||
});
|
||
|
||
app.post('/api/students/:id/photos', requireAuth, (req, res, next) => {
|
||
studentPhotoUpload(req, res, (err) => {
|
||
if (err) {
|
||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
||
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||
}
|
||
next();
|
||
});
|
||
}, async (req, res) => {
|
||
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
|
||
let id;
|
||
try {
|
||
id = reqInt(req.params.id);
|
||
} catch {
|
||
removeUpload(req.file);
|
||
return res.status(400).json({ error: 'Неверный id ученика' });
|
||
}
|
||
const acc = await studentProfileAccess(req.user, id);
|
||
if (!acc.found) {
|
||
removeUpload(req.file);
|
||
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||
}
|
||
try {
|
||
await convertPhoto(req.file);
|
||
const photoPath = `/uploads/${req.file.filename}`;
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO student_photos (student_id, photo_path) VALUES ($1, $2) RETURNING id, photo_path, created_at',
|
||
[id, photoPath]
|
||
);
|
||
if (!acc.student.photo_path) {
|
||
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [photoPath, id]);
|
||
}
|
||
await logAudit(req, 'student.photo.create', { id, photo_path: photoPath });
|
||
invalidateStudents();
|
||
invalidateShare();
|
||
res.status(201).json(rows[0]);
|
||
} catch (e) {
|
||
safeUnlink(`uploads/${req.file.filename}`);
|
||
console.error('POST /api/students/:id/photos:', e);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/students/:id/photos/:pid', requireAuth, async (req, res) => {
|
||
let id, pid;
|
||
try {
|
||
id = reqInt(req.params.id);
|
||
pid = reqInt(req.params.pid);
|
||
} catch {
|
||
return res.status(400).json({ error: 'Неверный id' });
|
||
}
|
||
const acc = await studentProfileAccess(req.user, id);
|
||
if (!acc.found) {
|
||
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
'SELECT id, photo_path FROM student_photos WHERE id = $1 AND student_id = $2',
|
||
[pid, id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||
const { rows: rest } = await pool.query(
|
||
'SELECT photo_path FROM student_photos WHERE student_id = $1 AND id <> $2 ORDER BY id DESC LIMIT 1',
|
||
[id, pid]
|
||
);
|
||
try {
|
||
await pool.query('DELETE FROM student_photos WHERE id = $1', [pid]);
|
||
if (acc.student.photo_path === rows[0].photo_path) {
|
||
const next = rest.length ? rest[0].photo_path : null;
|
||
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [next, id]);
|
||
}
|
||
safeUnlink(rows[0].photo_path);
|
||
await logAudit(req, 'student.photo.delete', { id, photo_path: rows[0].photo_path });
|
||
invalidateStudents();
|
||
invalidateShare();
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
console.error('DELETE /api/students/:id/photos/:pid:', e);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
app.put('/api/students/:id/photos/:pid/main', requireAuth, async (req, res) => {
|
||
let id, pid;
|
||
try {
|
||
id = reqInt(req.params.id);
|
||
pid = reqInt(req.params.pid);
|
||
} catch {
|
||
return res.status(400).json({ error: 'Неверный id' });
|
||
}
|
||
const acc = await studentProfileAccess(req.user, id);
|
||
if (!acc.found) {
|
||
return res.status(acc.forbidden ? 403 : 404).json({ error: acc.forbidden ? 'Нет доступа к этому ученику' : 'Ученик не найден' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
'SELECT photo_path FROM student_photos WHERE id = $1 AND student_id = $2',
|
||
[pid, id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||
await pool.query('UPDATE students SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, id]);
|
||
await logAudit(req, 'student.photo.main', { id, photo_path: rows[0].photo_path });
|
||
invalidateStudents();
|
||
invalidateShare();
|
||
res.json({ ok: true, photo_path: rows[0].photo_path });
|
||
});
|
||
|
||
function fmtLongDate(iso) {
|
||
if (!iso) return '';
|
||
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
||
}
|
||
|
||
app.get('/api/export/student', requireAuth, async (req, res) => {
|
||
let name;
|
||
try {
|
||
name = reqStr(req.query.name, 150);
|
||
} catch {
|
||
return res.status(400).json({ error: 'Укажите имя ученика' });
|
||
}
|
||
const opts = {
|
||
includeEntries: req.query.include_entries !== '0',
|
||
includePhotos: req.query.include_photos !== '0',
|
||
includeFiles: req.query.include_files !== '0',
|
||
includeCaptions: req.query.include_captions !== '0',
|
||
showDates: req.query.show_dates !== '0',
|
||
};
|
||
if (!opts.includeEntries && !opts.includePhotos && !opts.includeFiles) {
|
||
return res.status(400).json({ error: 'Выберите, что включать в отчёт' });
|
||
}
|
||
let dateFrom = null;
|
||
let dateTo = null;
|
||
try {
|
||
if (req.query.date_from) dateFrom = optDate(req.query.date_from);
|
||
if (req.query.date_to) dateTo = optDate(req.query.date_to);
|
||
} catch {
|
||
return res.status(400).json({ error: 'Неверный период' });
|
||
}
|
||
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||
return res.status(400).json({ error: 'Дата «С» позже даты «По»' });
|
||
}
|
||
const hasPeriod = !!(dateFrom || dateTo);
|
||
try {
|
||
const conds = ['e.student_name = $1', 'e.deleted_at IS NULL'];
|
||
const params = [name];
|
||
const bw = branchWhere(req.user, 'g');
|
||
if (dateFrom) {
|
||
params.push(dateFrom);
|
||
conds.push(`e.created_at >= $${params.length}::date`);
|
||
}
|
||
if (dateTo) {
|
||
params.push(dateTo);
|
||
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
|
||
}
|
||
if (bw.params.length) {
|
||
const start = params.length + 1;
|
||
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
|
||
params.push(...bw.params);
|
||
}
|
||
const condStr = conds.join(' AND ');
|
||
const whereStr = ' WHERE ' + condStr;
|
||
const [studRes, entriesRes, photosRes, mainsRes, filesRes, modulesRes, groupPhotosRes] = await Promise.all([
|
||
pool.query(
|
||
`SELECT s.id, s.name, s.created_at, s.group_id, s.photo_path, s.profile,
|
||
g.name AS group_name, b.name AS branch_name
|
||
FROM students s
|
||
LEFT JOIN groups g ON g.id = s.group_id
|
||
LEFT JOIN branches b ON b.id = g.branch_id
|
||
WHERE s.name = $1`,
|
||
[name]
|
||
),
|
||
pool.query(`SELECT e.id, e.description, e.created_at, e.module_id, e.ai_status, g.name AS group_name, m.name AS module_name
|
||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||
LEFT JOIN modules m ON m.id = e.module_id${whereStr}
|
||
ORDER BY e.created_at DESC`, params),
|
||
pool.query(`SELECT ep.photo_path, ep.caption, ep.entry_id, e.description, e.created_at, g.name AS group_name
|
||
FROM entry_photos ep
|
||
JOIN entries e ON e.id = ep.entry_id
|
||
JOIN groups g ON g.id = e.group_id${whereStr}
|
||
ORDER BY e.created_at DESC, ep.sort_order ASC, ep.id ASC`, params),
|
||
pool.query(`SELECT e.photo_path, e.description, e.created_at, e.id AS entry_id, g.name AS group_name
|
||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||
WHERE e.photo_path IS NOT NULL AND ${condStr}
|
||
ORDER BY e.created_at DESC`, params),
|
||
pool.query(`SELECT pf.path, pf.name, pf.entry_id, e.created_at, e.description, g.name AS group_name, m.name AS module_name
|
||
FROM project_files pf
|
||
JOIN entries e ON e.id = pf.entry_id
|
||
JOIN groups g ON g.id = e.group_id
|
||
LEFT JOIN modules m ON m.id = e.module_id
|
||
WHERE ${condStr} AND pf.detached_at IS NULL
|
||
ORDER BY e.created_at DESC, pf.id DESC`, params),
|
||
pool.query(`SELECT m.id, m.name, m.lessons_count,
|
||
count(e.id)::int AS entries_count,
|
||
min(e.created_at) AS first_at, max(e.created_at) AS last_at
|
||
FROM entries e
|
||
JOIN groups g ON g.id = e.group_id
|
||
JOIN modules m ON m.id = e.module_id
|
||
WHERE ${condStr}
|
||
GROUP BY m.id ORDER BY min(e.created_at)`, params),
|
||
pool.query(`SELECT gp.photo_path, gp.caption, gp.taken_at, gp.created_at, g.name AS group_name
|
||
FROM group_photos gp
|
||
JOIN groups g ON g.id = gp.group_id
|
||
WHERE gp.group_id = (SELECT group_id FROM students WHERE name = $1)
|
||
ORDER BY gp.sort_order ASC, gp.created_at DESC`, [name]),
|
||
]);
|
||
const entryRows = entriesRes.rows;
|
||
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
|
||
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
|
||
}
|
||
const student = studRes.rows[0] || { name };
|
||
const zip = createZipWriter();
|
||
if (opts.includePhotos) zip.addDir('photos');
|
||
if (opts.includeFiles) zip.addDir('files');
|
||
|
||
const seenPhotos = new Set();
|
||
const photosBuilt = [];
|
||
async function addPhoto(p) {
|
||
if (!isSafeUploadPath(p.photo_path)) return;
|
||
const stored = p.photo_path.slice('/uploads/'.length);
|
||
if (seenPhotos.has(stored)) return;
|
||
seenPhotos.add(stored);
|
||
const data = await storage.getBuffer(stored);
|
||
if (!data) return;
|
||
const ts = p.created_at ? new Date(p.created_at) : new Date();
|
||
zip.addFile('photos/' + stored, data, ts);
|
||
photosBuilt.push({
|
||
stored,
|
||
caption: p.caption || null,
|
||
createdAt: p.created_at || null,
|
||
desc: p.description || null,
|
||
entryId: p.entry_id || null,
|
||
groupName: p.group_name || null,
|
||
});
|
||
}
|
||
let avatarStored = null;
|
||
if (opts.includePhotos) {
|
||
const profilePhoto = (student.profile && student.profile.photo_path) || student.photo_path;
|
||
if (profilePhoto && isSafeUploadPath(profilePhoto)) {
|
||
const stored = profilePhoto.slice('/uploads/'.length);
|
||
if (await storage.exists(stored)) avatarStored = stored;
|
||
}
|
||
for (const p of photosRes.rows) await addPhoto(p);
|
||
for (const m of mainsRes.rows) await addPhoto(m);
|
||
photosBuilt.sort((a, b) => new Date(b.createdAt || Date.now()) - new Date(a.createdAt || Date.now()));
|
||
if (avatarStored) {
|
||
if (!seenPhotos.has(avatarStored)) await addPhoto({ photo_path: '/uploads/' + avatarStored, caption: 'Фото резидента', created_at: student.created_at });
|
||
const idx = photosBuilt.findIndex(p => p.stored === avatarStored);
|
||
if (idx > 0) photosBuilt.unshift(photosBuilt.splice(idx, 1)[0]);
|
||
}
|
||
}
|
||
|
||
const groupPhotosBuilt = [];
|
||
if (opts.includePhotos) {
|
||
for (const gp of groupPhotosRes.rows) {
|
||
if (!isSafeUploadPath(gp.photo_path)) continue;
|
||
const stored = gp.photo_path.slice('/uploads/'.length);
|
||
if (seenPhotos.has(stored)) continue;
|
||
const data = await storage.getBuffer(stored);
|
||
if (!data) continue;
|
||
const ts = gp.taken_at || gp.created_at || new Date();
|
||
zip.addFile('photos/' + stored, data, new Date(ts));
|
||
seenPhotos.add(stored);
|
||
groupPhotosBuilt.push({
|
||
stored,
|
||
caption: gp.caption || null,
|
||
takenAt: gp.taken_at || null,
|
||
createdAt: gp.created_at || null,
|
||
groupName: gp.group_name || null,
|
||
});
|
||
}
|
||
}
|
||
|
||
const seenFiles = new Map();
|
||
const filesBuilt = [];
|
||
if (opts.includeFiles) {
|
||
for (const f of filesRes.rows) {
|
||
if (!isSafeUploadPath(f.path)) continue;
|
||
const stored = f.path.slice('/uploads/'.length);
|
||
const data = await storage.getBuffer(stored);
|
||
if (!data) continue;
|
||
let base = String(f.name || 'file').replace(/[\\/:*?"<>|]/g, '_').replace(/^[.\s]+/, '').slice(0, 120) || 'file';
|
||
const ext = path.extname(base);
|
||
const stem = ext ? base.slice(0, -ext.length) : base;
|
||
let saved = base;
|
||
let n = 1;
|
||
while (seenFiles.has(saved)) {
|
||
n++;
|
||
saved = `${stem}(${n})${ext}`;
|
||
}
|
||
seenFiles.set(saved, true);
|
||
zip.addFile('files/' + saved, data, new Date(f.created_at));
|
||
filesBuilt.push({
|
||
saved,
|
||
original: f.name,
|
||
ext: ext ? ext.slice(1).toUpperCase() : '',
|
||
size: data.length,
|
||
createdAt: f.created_at,
|
||
entryId: f.entry_id,
|
||
desc: f.description || null,
|
||
moduleName: f.module_name || null,
|
||
groupName: f.group_name || null,
|
||
});
|
||
}
|
||
}
|
||
|
||
let period = null;
|
||
if (hasPeriod) {
|
||
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
|
||
}
|
||
|
||
const reportData = {
|
||
name,
|
||
student: {
|
||
id: student.id || null,
|
||
name: student.name || name,
|
||
created_at: student.created_at || null,
|
||
group_name: student.group_name || null,
|
||
branch_name: student.branch_name || null,
|
||
},
|
||
profile: student.profile || null,
|
||
entries: entryRows,
|
||
modules: modulesRes.rows,
|
||
photos: photosBuilt,
|
||
groupPhotos: groupPhotosBuilt,
|
||
files: filesBuilt,
|
||
generatedAt: new Date(),
|
||
period,
|
||
};
|
||
const html = renderStudentReport(reportData, opts);
|
||
zip.addFile('index.html', Buffer.from(html, 'utf8'));
|
||
const meta = {
|
||
exported_at: new Date().toISOString(),
|
||
student: reportData.student,
|
||
profile: student.profile || null,
|
||
period: period || null,
|
||
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length },
|
||
options: opts,
|
||
entries: entryRows.map(e => ({ id: e.id, group: e.group_name, module: e.module_name || null, created_at: e.created_at, ai_status: e.ai_status, description: e.description })),
|
||
photos: photosBuilt.map(p => ({ file: 'photos/' + p.stored, caption: p.caption, created_at: p.createdAt, entry_id: p.entryId })),
|
||
files: filesBuilt.map(f => ({ file: 'files/' + f.saved, name: f.original, size: f.size, created_at: f.createdAt, entry_id: f.entryId })),
|
||
};
|
||
zip.addFile('data.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8'));
|
||
|
||
const buf = zip.toBuffer();
|
||
await logAudit(req, 'export.student', {
|
||
student: name,
|
||
entries: entryRows.length,
|
||
photos: photosBuilt.length,
|
||
files: filesBuilt.length,
|
||
opts,
|
||
date_from: dateFrom,
|
||
date_to: dateTo,
|
||
});
|
||
const safeName = name.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'student';
|
||
const zipDate = new Date().toISOString().slice(0, 10);
|
||
const zipFname = `student_${safeName}_${zipDate}.zip`;
|
||
res.setHeader('Content-Type', 'application/zip');
|
||
res.setHeader('Content-Disposition', `attachment; filename="${zipFname}"; filename*=UTF-8''${encodeURIComponent(`student_${name}_${zipDate}.zip`)}`);
|
||
res.send(buf);
|
||
} catch (err) {
|
||
console.error('export student:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// --- Entries ---
|
||
app.get('/api/entries', requireAuth, async (req, res) => {
|
||
const { group_id, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
|
||
const conditions = [];
|
||
const params = [];
|
||
if (deleted === '1') conditions.push('e.deleted_at IS NOT NULL');
|
||
else conditions.push('e.deleted_at IS NULL');
|
||
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||
if (module_id) { params.push(module_id); conditions.push(`e.module_id = $${params.length}`); }
|
||
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||
if (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); }
|
||
if (req.user.role !== 'admin') {
|
||
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const s = branchScope(req.user);
|
||
if (!s.ids.length) {
|
||
conditions.push('1 = 0');
|
||
} else {
|
||
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
|
||
conditions.push(`g.branch_id IN (${ph})`);
|
||
}
|
||
}
|
||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||
const { rows: crows } = await pool.query(
|
||
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`,
|
||
params
|
||
);
|
||
const total = crows[0].n;
|
||
let q = `SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
|
||
JOIN groups g ON g.id = e.group_id
|
||
LEFT JOIN modules m ON m.id = e.module_id${where} ORDER BY e.created_at DESC`;
|
||
const qparams = params.slice();
|
||
const lim = parseInt(limit, 10);
|
||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||
const off = parseInt(offset, 10);
|
||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||
const { rows } = await pool.query(q, qparams);
|
||
let files;
|
||
if (rows.length) {
|
||
const ids = rows.map(r => r.id);
|
||
const fRes = await pool.query(
|
||
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
||
[ids]
|
||
);
|
||
files = {};
|
||
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
|
||
} else {
|
||
files = {};
|
||
}
|
||
rows.forEach(r => { r.files = files[r.id] || []; });
|
||
|
||
if (rows.length) {
|
||
const ids = rows.map(r => r.id);
|
||
const pRes = await pool.query(
|
||
'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = ANY($1) ORDER BY sort_order, id',
|
||
[ids]
|
||
);
|
||
const photos = {};
|
||
pRes.rows.forEach(p => { (photos[p.entry_id] = photos[p.entry_id] || []).push(p); });
|
||
rows.forEach(r => { r.photos = photos[r.id] || []; });
|
||
} else {
|
||
rows.forEach(r => { r.photos = []; });
|
||
}
|
||
|
||
res.json({ entries: rows, total });
|
||
});
|
||
|
||
app.get('/api/entries/:id', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
`SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
|
||
JOIN groups g ON g.id = e.group_id
|
||
LEFT JOIN modules m ON m.id = e.module_id
|
||
WHERE e.id = $1`,
|
||
[req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||
const entry = rows[0];
|
||
const fRes = await pool.query(
|
||
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
|
||
[entry.id]
|
||
);
|
||
entry.files = fRes.rows;
|
||
const pRes = await pool.query(
|
||
'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id',
|
||
[entry.id]
|
||
);
|
||
entry.photos = pRes.rows;
|
||
res.json(entry);
|
||
});
|
||
|
||
app.get('/api/entries/:id/files', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const { rows } = await pool.query(`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`, [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||
if (rows[0].group_id && !(await groupBelongsToBranches(req.user, rows[0].group_id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
}
|
||
const { rows } = await pool.query(
|
||
'SELECT id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
|
||
[req.params.id]
|
||
);
|
||
res.json(rows);
|
||
});
|
||
|
||
const entryFilesUpload = adminUpload.array('files', 10);
|
||
app.post('/api/entries/:id/files', requireAuth, (req, res, next) => {
|
||
entryFilesUpload(req, res, (err) => {
|
||
if (!err) return next();
|
||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||
});
|
||
}, async (req, res) => {
|
||
const entryId = req.params.id;
|
||
const files = req.files || [];
|
||
if (!files.length) return res.status(400).json({ error: 'Файлы не выбраны' });
|
||
|
||
if (req.user.role !== 'admin') {
|
||
const { rows } = await pool.query(`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`, [entryId]);
|
||
if (!rows.length) {
|
||
files.forEach(removeUpload);
|
||
return res.status(404).json({ error: 'Запись не найдена' });
|
||
}
|
||
if (rows[0].group_id && !(await groupBelongsToBranches(req.user, rows[0].group_id))) {
|
||
files.forEach(removeUpload);
|
||
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
}
|
||
|
||
const entryCheck = await pool.query('SELECT id FROM entries WHERE id = $1', [entryId]);
|
||
if (!entryCheck.rows.length) {
|
||
files.forEach(removeUpload);
|
||
return res.status(404).json({ error: 'Запись не найдена' });
|
||
}
|
||
|
||
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||
files.forEach(removeUpload);
|
||
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
|
||
}
|
||
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
for (const f of files) {
|
||
const token = crypto.randomBytes(16).toString('hex');
|
||
await client.query(
|
||
'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)',
|
||
[entryId, token, `/uploads/${f.filename}`, f.originalname]
|
||
);
|
||
}
|
||
await client.query('COMMIT');
|
||
invalidateShare();
|
||
invalidateEntries();
|
||
res.status(201).json({ ok: true, count: files.length });
|
||
} catch (e) {
|
||
await client.query('ROLLBACK').catch(() => {});
|
||
files.forEach(removeUpload);
|
||
console.error('POST /api/entries/:id/files:', e);
|
||
res.status(500).json({ error: e.message });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
function isImageName(name) {
|
||
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i.test(name || '');
|
||
}
|
||
|
||
app.get('/api/files', requireAuth, async (req, res) => {
|
||
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
|
||
const conditions = [];
|
||
const params = [];
|
||
conditions.push('e.deleted_at IS NULL');
|
||
if (search) { params.push(`%${search}%`); conditions.push(`pf.name ILIKE $${params.length}`); }
|
||
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||
if (req.user.role !== 'admin') {
|
||
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const s = branchScope(req.user);
|
||
if (!s.ids.length) {
|
||
conditions.push('1 = 0');
|
||
} else {
|
||
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
|
||
conditions.push(`g.branch_id IN (${ph})`);
|
||
}
|
||
}
|
||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||
const { rows: crows } = await pool.query(
|
||
`SELECT count(*)::int AS n FROM project_files pf JOIN entries e ON e.id = pf.entry_id JOIN groups g ON g.id = e.group_id${where}`,
|
||
params
|
||
);
|
||
const total = crows[0].n;
|
||
let q = `SELECT pf.id, pf.token, pf.name, pf.path, e.student_name, e.created_at, g.name AS group_name
|
||
FROM project_files pf
|
||
JOIN entries e ON e.id = pf.entry_id
|
||
JOIN groups g ON g.id = e.group_id${where}
|
||
ORDER BY pf.id DESC`;
|
||
const qparams = params.slice();
|
||
const lim = parseInt(limit, 10);
|
||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||
const off = parseInt(offset, 10);
|
||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||
const { rows } = await pool.query(q, qparams);
|
||
const files = await Promise.all(rows.map(async r => {
|
||
const size = await storage.sizeOf(r.path);
|
||
return { id: r.id, token: r.token, name: r.name, student_name: r.student_name, group_name: r.group_name, created_at: r.created_at, size };
|
||
}));
|
||
res.json({ files, total });
|
||
});
|
||
|
||
app.get('/api/files/detached', requireAdmin, async (req, res) => {
|
||
const { search, limit, offset } = req.query;
|
||
const conditions = [];
|
||
const params = [];
|
||
conditions.push('entry_id IS NULL');
|
||
if (search) { params.push(`%${search}%`); conditions.push(`name ILIKE $${params.length}`); }
|
||
const where = conditions.join(' AND ');
|
||
const { rows: crows } = await pool.query(
|
||
`SELECT count(*)::int AS n FROM project_files WHERE ${where}`,
|
||
params
|
||
);
|
||
const total = crows[0].n;
|
||
let q = `SELECT id, token, name, path, created_at FROM project_files
|
||
WHERE ${where} ORDER BY created_at DESC`;
|
||
const qparams = params.slice();
|
||
const lim = parseInt(limit, 10);
|
||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||
const off = parseInt(offset, 10);
|
||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||
const { rows } = await pool.query(q, qparams);
|
||
const files = await Promise.all(rows.map(async r => {
|
||
const size = await storage.sizeOf(r.path);
|
||
return { id: r.id, token: r.token, name: r.name, created_at: r.created_at, size };
|
||
}));
|
||
res.json({ files, total });
|
||
});
|
||
|
||
app.post('/api/files/:id/detach', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const { rows } = await pool.query(
|
||
`SELECT pf.entry_id, e.group_id FROM project_files pf LEFT JOIN entries e ON e.id = pf.entry_id WHERE pf.id = $1`,
|
||
[req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
const { entry_id, group_id } = rows[0];
|
||
if (!entry_id || (group_id && !(await groupBelongsToBranches(req.user, group_id)))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
|
||
}
|
||
}
|
||
const { rows } = await pool.query(
|
||
'UPDATE project_files SET entry_id = NULL, detached_at = now() WHERE id = $1 RETURNING *',
|
||
[req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
invalidateShare();
|
||
invalidateEntries();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.delete('/api/files/:id', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const { rows } = await pool.query(
|
||
`SELECT pf.entry_id, e.group_id FROM project_files pf LEFT JOIN entries e ON e.id = pf.entry_id WHERE pf.id = $1`,
|
||
[req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
const { entry_id, group_id } = rows[0];
|
||
if (!entry_id || (group_id && !(await groupBelongsToBranches(req.user, group_id)))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
|
||
}
|
||
}
|
||
const { rows } = await pool.query('SELECT path FROM project_files WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||
safeUnlink(rows[0].path);
|
||
await pool.query('DELETE FROM project_files WHERE id = $1', [req.params.id]);
|
||
invalidateShare();
|
||
invalidateEntries();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.get('/api/files/:token', fileLimiter, async (req, res) => {
|
||
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||
const r = rows[0];
|
||
const key = storage.keyFromPath(r.path);
|
||
if (!key) return res.status(404).json({ error: 'File missing' });
|
||
if (isImageName(r.name)) {
|
||
if (req.query.thumb) return sendImageThumb(res, key);
|
||
const ok = await storage.streamTo(res, key, { cacheControl: 'public, max-age=31536000, immutable' });
|
||
if (!ok && !res.headersSent) return res.status(404).json({ error: 'File missing' });
|
||
return;
|
||
}
|
||
if (!(await storage.streamTo(res, key, { download: true, name: r.name })) && !res.headersSent) {
|
||
return res.status(404).json({ error: 'File missing' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/photos', requireAuth, async (req, res) => {
|
||
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
|
||
const conditions = [];
|
||
const params = [];
|
||
if (search) { params.push(`%${search}%`); conditions.push(`t.title ILIKE $${params.length}`); }
|
||
if (student_name) { params.push(student_name); conditions.push(`t.student_name = $${params.length}`); }
|
||
if (group_id) { params.push(group_id); conditions.push(`t.group_id = $${params.length}`); }
|
||
if (date_from) { params.push(date_from); conditions.push(`t.created_at >= $${params.length}::date`); }
|
||
if (date_to) { params.push(date_to); conditions.push(`t.created_at < ($${params.length}::date + interval '1 day')`); }
|
||
if (req.user.role !== 'admin') {
|
||
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||
}
|
||
const s = branchScope(req.user);
|
||
if (!s.ids.length) {
|
||
conditions.push('1 = 0');
|
||
} else {
|
||
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
|
||
conditions.push(`t.branch_id IN (${ph})`);
|
||
}
|
||
}
|
||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||
const from = `FROM (
|
||
SELECT 'entry'::text AS source_type, 'Главное фото записи'::text AS source_label,
|
||
e.photo_path AS path, e.id AS source_id, e.student_name, e.group_id,
|
||
g.branch_id, g.name AS group_name, e.created_at,
|
||
e.description AS title
|
||
FROM entries e
|
||
JOIN groups g ON g.id = e.group_id
|
||
WHERE e.deleted_at IS NULL AND e.photo_path IS NOT NULL
|
||
UNION ALL
|
||
SELECT 'entry_photo', 'Фото записи', ep.photo_path, ep.entry_id, e.student_name, e.group_id,
|
||
g.branch_id, g.name, ep.created_at,
|
||
COALESCE(NULLIF(ep.caption, ''), e.description)
|
||
FROM entry_photos ep
|
||
JOIN entries e ON e.id = ep.entry_id
|
||
JOIN groups g ON g.id = e.group_id
|
||
WHERE e.deleted_at IS NULL
|
||
UNION ALL
|
||
SELECT 'group_photo', 'Фотохроника группы', gp.photo_path, gp.group_id, NULL::varchar, gp.group_id,
|
||
g.branch_id, g.name, gp.created_at,
|
||
COALESCE(NULLIF(gp.caption, ''), g.name)
|
||
FROM group_photos gp
|
||
JOIN groups g ON g.id = gp.group_id
|
||
UNION ALL
|
||
SELECT 'student_photo', 'Фото ученика', sp.photo_path, sp.student_id, s.name, s.group_id,
|
||
g.branch_id, g.name, sp.created_at,
|
||
s.name
|
||
FROM student_photos sp
|
||
JOIN students s ON s.id = sp.student_id
|
||
LEFT JOIN groups g ON g.id = s.group_id
|
||
UNION ALL
|
||
SELECT 'module_photo', 'Тема модуля', m.photo_path, m.id, NULL::varchar, NULL::int,
|
||
NULL::int, NULL::varchar, m.created_at,
|
||
m.name
|
||
FROM modules m
|
||
WHERE m.photo_path IS NOT NULL
|
||
) t`;
|
||
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n ${from}${where}`, params);
|
||
const total = crows[0].n;
|
||
let q = `SELECT t.source_type, t.source_label, t.path, t.source_id, t.student_name,
|
||
t.group_id, t.group_name, t.created_at, t.title ${from}${where}
|
||
ORDER BY t.created_at DESC`;
|
||
const qparams = params.slice();
|
||
const lim = parseInt(limit, 10);
|
||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||
const off = parseInt(offset, 10);
|
||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||
const { rows } = await pool.query(q, qparams);
|
||
const photos = rows.map(r => ({
|
||
source_type: r.source_type,
|
||
source_label: r.source_label,
|
||
source_id: r.source_id,
|
||
path: r.path,
|
||
title: r.title || '',
|
||
student_name: r.student_name || null,
|
||
group_id: r.group_id || null,
|
||
group_name: r.group_name || null,
|
||
created_at: r.created_at
|
||
}));
|
||
res.json({ photos, total });
|
||
});
|
||
|
||
app.get('/api/stats', requireAuth, async (req, res) => {
|
||
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
||
const isAdmin = req.user.role === 'admin';
|
||
const s = branchScope(req.user);
|
||
let groupFilter;
|
||
if (isAdmin) {
|
||
groupFilter = { where: '', params: [] };
|
||
} else if (!s.ids.length) {
|
||
groupFilter = { where: ' AND 1 = 0', params: [] };
|
||
} else {
|
||
const ph = s.ids.map(id => `$${s.ids.indexOf(id) + 1}`).join(',');
|
||
groupFilter = { where: ` AND g.branch_id IN (${ph})`, params: s.ids };
|
||
}
|
||
const groupsParams = isAdmin ? [] : (s.ids.length ? s.ids : [0]);
|
||
const groupsWhere = isAdmin ? '' : (s.ids.length ? ` WHERE g.branch_id IN (${groupsParams.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0');
|
||
const gTrash = (bin) => {
|
||
const base = bin === 'pending' ? ' AND g.purge_at IS NOT NULL' : ' AND g.deleted_at IS NOT NULL AND g.purge_at IS NULL';
|
||
return isAdmin ? ` WHERE g.deleted_at IS NOT NULL${base}` : (s.ids.length ? ` WHERE g.deleted_at IS NOT NULL${base} AND g.branch_id IN (${groupsParams.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0');
|
||
};
|
||
const [entries, eTrash, gTrashR, groups, students, today, ePend, gPendR] = await Promise.all([
|
||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params),
|
||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL AND e.purge_at IS NULL${groupFilter.where}`, groupFilter.params),
|
||
pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('visible')}`, groupsParams),
|
||
pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams),
|
||
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params),
|
||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${groupFilter.where}`, groupFilter.params),
|
||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.purge_at IS NOT NULL${groupFilter.where}`, groupFilter.params),
|
||
pool.query(`SELECT count(*)::int AS n FROM groups g${gTrash('pending')}`, groupsParams),
|
||
]);
|
||
return {
|
||
entries: entries.rows[0].n,
|
||
trash: eTrash.rows[0].n + gTrashR.rows[0].n,
|
||
trash_pending: ePend.rows[0].n + gPendR.rows[0].n,
|
||
groups: groups.rows[0].n,
|
||
students: students.rows[0].n,
|
||
today: today.rows[0].n,
|
||
};
|
||
});
|
||
res.json(payload);
|
||
});
|
||
|
||
app.get('/api/system-info', requireAdmin, async (_, res) => {
|
||
try {
|
||
const payload = await cacheWrap('system-info', SYSTEM_TTL_MS, async () => {
|
||
const db = await pool.query(`
|
||
SELECT
|
||
pg_size_pretty(pg_database_size(current_database())) AS db_size,
|
||
pg_database_size(current_database()) AS db_size_bytes
|
||
`);
|
||
|
||
const tables = await pool.query(`
|
||
SELECT
|
||
schemaname,
|
||
relname,
|
||
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS size,
|
||
pg_total_relation_size(schemaname || '.' || relname) AS size_bytes
|
||
FROM pg_stat_user_tables
|
||
ORDER BY pg_total_relation_size(schemaname || '.' || relname) DESC
|
||
`);
|
||
|
||
const photoStats = await pool.query(`
|
||
SELECT count(*)::int AS count,
|
||
sum(pg_column_size(photo_path))::bigint AS path_size_bytes
|
||
FROM group_photos
|
||
`);
|
||
|
||
const fileStats = await pool.query(`
|
||
SELECT count(*)::int AS count,
|
||
sum(pg_column_size(path))::bigint AS path_size_bytes
|
||
FROM project_files
|
||
`);
|
||
|
||
const entryPhotoStats = await pool.query(`
|
||
SELECT count(*)::int AS count
|
||
FROM entries
|
||
WHERE photo_path IS NOT NULL AND deleted_at IS NULL
|
||
`);
|
||
|
||
async function sumPhotoSizes(paths) {
|
||
let bytes = 0;
|
||
for (const p of paths) {
|
||
if (!p) continue;
|
||
bytes += await storage.sizeOf(p);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
const groupPhotoSizes = await pool.query('SELECT photo_path FROM group_photos');
|
||
const entryPhotoSizes = await pool.query('SELECT photo_path FROM entries WHERE photo_path IS NOT NULL AND deleted_at IS NULL');
|
||
const groupPhotoBytes = await sumPhotoSizes(groupPhotoSizes.rows.map(r => r.photo_path));
|
||
const entryPhotoBytes = await sumPhotoSizes(entryPhotoSizes.rows.map(r => r.photo_path));
|
||
|
||
const usage = await storage.usage();
|
||
const uploadsSize = usage.size_bytes;
|
||
const uploadsCount = usage.count;
|
||
|
||
const diskInfo = getDiskInfo();
|
||
const cacheStats = await cache.info();
|
||
|
||
return {
|
||
database: {
|
||
size: db.rows[0].db_size,
|
||
size_bytes: parseInt(db.rows[0].db_size_bytes, 10),
|
||
tables: tables.rows.map(t => ({
|
||
name: t.relname,
|
||
size: t.size,
|
||
size_bytes: parseInt(t.size_bytes, 10),
|
||
})),
|
||
},
|
||
photos: {
|
||
group_photos: { count: photoStats.rows[0].count || 0, size: formatBytes(groupPhotoBytes), size_bytes: groupPhotoBytes },
|
||
entry_photos: { count: entryPhotoStats.rows[0].count || 0, size: formatBytes(entryPhotoBytes), size_bytes: entryPhotoBytes },
|
||
total_count: (photoStats.rows[0].count || 0) + (entryPhotoStats.rows[0].count || 0),
|
||
total_size: formatBytes(groupPhotoBytes + entryPhotoBytes),
|
||
total_size_bytes: groupPhotoBytes + entryPhotoBytes,
|
||
},
|
||
files: {
|
||
project_files: { count: fileStats.rows[0].count || 0 },
|
||
},
|
||
uploads: {
|
||
count: uploadsCount,
|
||
size: formatBytes(uploadsSize),
|
||
size_bytes: uploadsSize,
|
||
},
|
||
storage: {
|
||
driver: storage.isRemote() ? 's3' : 'local',
|
||
bucket: usage.bucket,
|
||
prefix: usage.prefix,
|
||
count: usage.count,
|
||
size: formatBytes(usage.size_bytes),
|
||
size_bytes: usage.size_bytes,
|
||
},
|
||
disk: diskInfo,
|
||
cache: cacheStats,
|
||
};
|
||
});
|
||
res.json({ ...payload, stack: await getStackInfo(payload.cache) });
|
||
} catch (e) {
|
||
console.error('System info error:', e);
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||
const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||
const isAdmin = req.user.role === 'admin';
|
||
const s = branchScope(req.user);
|
||
const branchIds = isAdmin ? [] : s.ids;
|
||
const whereGroup = isAdmin ? '' : (branchIds.length ? ` AND g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' AND 1 = 0');
|
||
const whereGroupParams = isAdmin ? [] : branchIds;
|
||
const whereEntry = isAdmin ? '' : (branchIds.length ? ` AND g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' AND 1 = 0');
|
||
|
||
const [stats, activity, active, recent, top, photos, latestPhotos] = await Promise.all([
|
||
(async () => {
|
||
const ew = !!whereEntry;
|
||
const entriesP = `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`;
|
||
const [entries, trash, groups, students, today] = await Promise.all([
|
||
pool.query(entriesP, ew ? whereGroupParams : []),
|
||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
||
pool.query(`SELECT count(*)::int AS n FROM groups g${isAdmin ? '' : (branchIds.length ? ` WHERE g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0')}`, isAdmin ? [] : branchIds),
|
||
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
||
]);
|
||
return { entries: entries.rows[0].n, trash: trash.rows[0].n, groups: groups.rows[0].n, students: students.rows[0].n, today: today.rows[0].n };
|
||
})(),
|
||
pool.query(
|
||
`SELECT to_char(e.created_at, 'YYYY-MM-DD') AS d, count(*)::int AS n
|
||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||
WHERE e.deleted_at IS NULL AND e.created_at >= (now() - interval '13 days')::date${whereEntry}
|
||
GROUP BY 1 ORDER BY 1`,
|
||
whereEntry ? whereGroupParams : []
|
||
),
|
||
pool.query(
|
||
`SELECT g.* FROM groups g
|
||
WHERE g.day_of_week IS NOT NULL AND g.time_start IS NOT NULL AND g.time_end IS NOT NULL
|
||
AND g.day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
||
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN g.time_start AND g.time_end${whereGroup}
|
||
ORDER BY g.id`,
|
||
whereGroup ? whereGroupParams : []
|
||
),
|
||
pool.query(
|
||
`SELECT e.id, e.student_name, e.photo_path, e.created_at, g.name AS group_name
|
||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||
WHERE e.deleted_at IS NULL${whereEntry}
|
||
ORDER BY e.created_at DESC LIMIT 7`,
|
||
whereEntry ? whereGroupParams : []
|
||
),
|
||
pool.query(
|
||
`SELECT e.student_name, count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id
|
||
WHERE e.deleted_at IS NULL${whereEntry} GROUP BY e.student_name ORDER BY n DESC, e.student_name LIMIT 5`,
|
||
whereEntry ? whereGroupParams : []
|
||
),
|
||
pool.query(
|
||
`SELECT gp.id, gp.photo_path, gp.caption, gp.taken_at, g.name AS group_name
|
||
FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
|
||
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`,
|
||
whereGroup ? whereGroupParams : []
|
||
),
|
||
pool.query(
|
||
`SELECT gp.photo_path, gp.taken_at FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
|
||
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
|
||
whereGroup ? whereGroupParams : []
|
||
),
|
||
]);
|
||
const activeGroups = active.rows.map(g => ({
|
||
id: g.id,
|
||
name: g.name,
|
||
day_label: DAYS[g.day_of_week],
|
||
time_start: (g.time_start || '').slice(0, 5),
|
||
time_end: (g.time_end || '').slice(0, 5),
|
||
}));
|
||
return {
|
||
stats: stats,
|
||
activity: activity.rows,
|
||
active_groups: activeGroups,
|
||
recent_entries: recent.rows,
|
||
top_students: top.rows,
|
||
photos: photos.rows,
|
||
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
|
||
disk: getDiskInfo(),
|
||
};
|
||
});
|
||
res.json(payload);
|
||
});
|
||
|
||
const entryFields = upload.fields([{ name: 'photo', maxCount: 10 }, { name: 'files', maxCount: 10 }]);
|
||
app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||
entryFields(req, res, (err) => {
|
||
if (!err) return next();
|
||
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
||
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
||
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
||
return res.status(400).json({ error: 'Недопустимый файл' });
|
||
});
|
||
}, async (req, res) => {
|
||
const { student_name, group_id, description, module_id, website } = req.body;
|
||
const photos = req.files?.photo || [];
|
||
const projectFiles = req.files?.files || [];
|
||
if (website) {
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
await recordFailure(req, 'honeypot', 1, BAN_TTL_MS);
|
||
return res.status(400).json({ error: 'Spam detected' });
|
||
}
|
||
if (!student_name?.trim() || !group_id || !description?.trim()) {
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
return res.status(400).json({ error: 'All fields required' });
|
||
}
|
||
if (!photos.length) {
|
||
projectFiles.forEach(removeUpload);
|
||
return res.status(400).json({ error: 'Фото обязательно' });
|
||
}
|
||
const totalBytes = photos.reduce((s, f) => s + (f.size || 0), 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0);
|
||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
|
||
}
|
||
const gid = Number.parseInt(group_id, 10);
|
||
if (!Number.isInteger(gid)) {
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
return res.status(400).json({ error: 'Группа не найдена' });
|
||
}
|
||
const grpCheck = await pool.query('SELECT id FROM groups WHERE id = $1', [gid]);
|
||
if (!grpCheck.rows.length) {
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
return res.status(400).json({ error: 'Группа не найдена' });
|
||
}
|
||
const mid = module_id === undefined || module_id === null || module_id === '' ? null : Number.parseInt(module_id, 10);
|
||
if (mid !== null && !Number.isInteger(mid)) {
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
return res.status(400).json({ error: 'Модуль не найден' });
|
||
}
|
||
if (mid !== null) {
|
||
const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
|
||
if (!modCheck.rows.length) {
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
return res.status(400).json({ error: 'Модуль не найден' });
|
||
}
|
||
}
|
||
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
|
||
if (intervalMin > 0) {
|
||
const dup = await pool.query(
|
||
'SELECT count(*)::int AS n FROM entries WHERE student_name = $1 AND created_at >= now() - ($2 || \' minutes\')::interval',
|
||
[student_name.trim(), intervalMin]
|
||
);
|
||
if (dup.rows[0].n > 0) {
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
|
||
}
|
||
}
|
||
for (const p of photos) {
|
||
await convertPhoto(p);
|
||
}
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
await client.query(
|
||
'INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
||
[student_name.trim()]
|
||
);
|
||
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
|
||
const { rows } = await client.query(
|
||
`INSERT INTO entries (student_name, group_id, module_id, description, description_original, photo_path)
|
||
VALUES ($1, $2, $3, $4, $4, $5) RETURNING *`,
|
||
[student_name.trim(), gid, mid, description.trim(), mainPhotoPath]
|
||
);
|
||
for (let i = 0; i < photos.length; i++) {
|
||
const p = photos[i];
|
||
await client.query(
|
||
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
|
||
[rows[0].id, `/uploads/${p.filename}`, i]
|
||
);
|
||
}
|
||
for (const f of projectFiles) {
|
||
const token = crypto.randomBytes(16).toString('hex');
|
||
await client.query(
|
||
'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)',
|
||
[rows[0].id, token, `/uploads/${f.filename}`, f.originalname]
|
||
);
|
||
}
|
||
await client.query('COMMIT');
|
||
if (entryAutoChecker) entryAutoChecker.notify();
|
||
invalidateEntries();
|
||
invalidateStats();
|
||
broadcastEntryChanged();
|
||
res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length });
|
||
} catch (e) {
|
||
await client.query('ROLLBACK').catch(() => {});
|
||
photos.forEach(removeUpload);
|
||
projectFiles.forEach(removeUpload);
|
||
console.error('POST /api/entries:', e);
|
||
res.status(500).json({ error: e.message });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
function entryStateWithNames(row, names) {
|
||
return {
|
||
student_name: row.student_name,
|
||
group_id: row.group_id,
|
||
group_name: names.group_name,
|
||
module_id: row.module_id,
|
||
module_name: names.module_name,
|
||
description: row.description,
|
||
description_ai: row.description_ai
|
||
};
|
||
}
|
||
|
||
async function buildEntryUpdateTarget(req, before, after, extra = {}) {
|
||
const { rows } = await pool.query(
|
||
`SELECT
|
||
(SELECT name FROM groups WHERE id = $1) AS group_name_before,
|
||
(SELECT name FROM modules WHERE id = $2) AS module_name_before,
|
||
(SELECT name FROM groups WHERE id = $3) AS group_name_after,
|
||
(SELECT name FROM modules WHERE id = $4) AS module_name_after`,
|
||
[before.group_id, before.module_id, after.group_id, after.module_id]
|
||
);
|
||
const n = rows[0] || {};
|
||
const beforeState = entryStateWithNames(before, { group_name: n.group_name_before, module_name: n.module_name_before });
|
||
const afterState = entryStateWithNames(after, { group_name: n.group_name_after, module_name: n.module_name_after });
|
||
const changes = buildEntryDiff(beforeState, afterState);
|
||
const source = normalizeEditSource(req.body?.edit_source, beforeState, afterState);
|
||
return {
|
||
id: before.id,
|
||
source,
|
||
changed: changes.length > 0 || (extra.photos || 0) > 0,
|
||
fields: changes.map(c => c.field),
|
||
changes,
|
||
photos_added: extra.photos || 0
|
||
};
|
||
}
|
||
|
||
app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const { student_name, group_id, description, module_id } = req.body;
|
||
const newPhotos = req.files || [];
|
||
for (const p of newPhotos) {
|
||
await convertPhoto(p);
|
||
}
|
||
|
||
const hasModule = Object.prototype.hasOwnProperty.call(req.body, 'module_id');
|
||
let mid = null;
|
||
if (hasModule && module_id !== undefined && module_id !== null && module_id !== '') {
|
||
mid = Number.parseInt(module_id, 10);
|
||
if (!Number.isInteger(mid)) {
|
||
newPhotos.forEach(p => safeUnlink(p.path));
|
||
return res.status(400).json({ error: 'Модуль не найден' });
|
||
}
|
||
const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
|
||
if (!modCheck.rows.length) {
|
||
newPhotos.forEach(p => safeUnlink(p.path));
|
||
return res.status(400).json({ error: 'Модуль не найден' });
|
||
}
|
||
}
|
||
|
||
const beforeRes = await pool.query(
|
||
`SELECT id, student_name, group_id, module_id, description, description_ai
|
||
FROM entries WHERE id = $1`,
|
||
[req.params.id]
|
||
);
|
||
if (!beforeRes.rows.length) {
|
||
newPhotos.forEach(p => safeUnlink(p.path));
|
||
return res.status(404).json({ error: 'Not found' });
|
||
}
|
||
const before = beforeRes.rows[0];
|
||
|
||
const { rows } = await pool.query(
|
||
`UPDATE entries SET
|
||
student_name = COALESCE($1, student_name),
|
||
group_id = COALESCE($2, group_id),
|
||
description = COALESCE($3, description),
|
||
description_original = COALESCE($3, description_original),
|
||
module_id = CASE WHEN $5 THEN $6 ELSE module_id END
|
||
WHERE id = $4 RETURNING *`,
|
||
[
|
||
student_name ? student_name.trim() : null,
|
||
group_id || null,
|
||
description ? description.trim() : null,
|
||
req.params.id,
|
||
hasModule,
|
||
mid,
|
||
]
|
||
);
|
||
if (!rows.length) {
|
||
newPhotos.forEach(p => safeUnlink(p.path));
|
||
return res.status(404).json({ error: 'Not found' });
|
||
}
|
||
|
||
const { rows: existingPhotos } = await pool.query('SELECT id, photo_path FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id', [req.params.id]);
|
||
const nextSortOrder = existingPhotos.length;
|
||
|
||
for (let i = 0; i < newPhotos.length; i++) {
|
||
const p = newPhotos[i];
|
||
await pool.query(
|
||
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
|
||
[req.params.id, `/uploads/${p.filename}`, nextSortOrder + i]
|
||
);
|
||
}
|
||
if (!rows[0].photo_path && newPhotos.length) {
|
||
rows[0].photo_path = `/uploads/${newPhotos[0].filename}`;
|
||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
||
}
|
||
|
||
await logAudit(req, 'entry.update', await buildEntryUpdateTarget(req, before, rows[0], { photos: newPhotos.length }));
|
||
invalidateEntries();
|
||
invalidateStats();
|
||
res.json(rows[0]);
|
||
});
|
||
|
||
app.get('/api/entries/:id/photos', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
'SELECT id, photo_path, caption, sort_order, created_at FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id',
|
||
[req.params.id]
|
||
);
|
||
res.json(rows);
|
||
});
|
||
|
||
app.delete('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const { rows } = await pool.query('SELECT photo_path, sort_order FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||
const { photo_path: photoPath, sort_order: photoSort } = rows[0];
|
||
await pool.query('DELETE FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
||
safeUnlink(photoPath);
|
||
await pool.query('UPDATE entry_photos SET sort_order = sort_order - 1 WHERE entry_id = $1 AND sort_order > $2', [req.params.id, photoSort]);
|
||
const { rows: mainRows } = await pool.query('SELECT id FROM entries WHERE id = $1 AND photo_path = $2', [req.params.id, photoPath]);
|
||
if (mainRows.length) {
|
||
const { rows: nextRows } = await pool.query('SELECT photo_path FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id LIMIT 1', [req.params.id]);
|
||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [nextRows.length ? nextRows[0].photo_path : null, req.params.id]);
|
||
}
|
||
await logAudit(req, 'entry.photo.delete', { entry_id: req.params.id, photo_id: req.params.photoId });
|
||
invalidateEntries();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.put('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const { caption, sort_order } = req.body;
|
||
const { rows } = await pool.query(
|
||
'UPDATE entry_photos SET caption = COALESCE($1, caption), sort_order = COALESCE($2, sort_order) WHERE id = $3 AND entry_id = $4 RETURNING *',
|
||
[caption ?? null, sort_order !== undefined ? sort_order : null, req.params.photoId, req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||
await logAudit(req, 'entry.photo.update', { entry_id: req.params.id, photo_id: req.params.photoId });
|
||
invalidateEntries();
|
||
res.json(rows[0]);
|
||
});
|
||
|
||
app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const { rows } = await pool.query('SELECT photo_path FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
||
await logAudit(req, 'entry.photo.set_main', { entry_id: req.params.id, photo_id: req.params.photoId });
|
||
invalidateEntries();
|
||
res.json({ ok: true, photo_path: rows[0].photo_path });
|
||
});
|
||
function clampEnhanceParam(v, min, max, def) {
|
||
const n = parseFloat(v);
|
||
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
|
||
}
|
||
|
||
async function swapEntryPhotoFiles(req, entryId, oldPath, newPath, { keepOriginal = true, action = 'enhance' } = {}) {
|
||
let originalPath = null;
|
||
const { rows: prevRows } = await pool.query('SELECT photo_original_path FROM entries WHERE id = $1', [entryId]);
|
||
const prevOriginal = prevRows.length ? prevRows[0].photo_original_path : null;
|
||
const oldKey = storage.keyFromPath(oldPath);
|
||
if (prevOriginal) {
|
||
originalPath = prevOriginal;
|
||
if (oldKey) safeUnlink(oldKey);
|
||
} else if (keepOriginal && oldKey && (await storage.exists(oldKey))) {
|
||
try {
|
||
const backupKey = `.originals/${crypto.randomBytes(12).toString('hex')}${path.extname(oldKey) || '.jpg'}`;
|
||
if (await storage.copyObject(oldKey, backupKey)) {
|
||
originalPath = `/uploads/${backupKey}`;
|
||
safeUnlink(oldKey);
|
||
}
|
||
} catch (e) {
|
||
console.error('photo original backup failed:', e);
|
||
}
|
||
} else if (oldKey) {
|
||
safeUnlink(oldKey);
|
||
}
|
||
await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = $2 WHERE id = $3', [newPath, originalPath, entryId]);
|
||
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, entryId, oldPath]);
|
||
thumbUnlinkFor(oldPath);
|
||
await pool.query(
|
||
'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, finished_at) VALUES ($1, $2, $3, $4, $5, now())',
|
||
[entryId, action, 'done', originalPath, newPath]
|
||
);
|
||
await logAudit(req, 'entry.photo.enhance', { entry_id: entryId, old_path: oldPath, new_path: newPath, original_path: originalPath });
|
||
invalidateEntries();
|
||
}
|
||
|
||
app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => {
|
||
if (req.is('json')) return next();
|
||
upload.single('photo')(req, res, next);
|
||
}, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
try {
|
||
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
|
||
if (!cur.length) {
|
||
if (req.file) safeUnlink(req.file.path);
|
||
return res.status(404).json({ error: 'Запись не найдена' });
|
||
}
|
||
const oldPath = cur[0].photo_path;
|
||
if (!oldPath) {
|
||
if (req.file) safeUnlink(req.file.path);
|
||
return res.status(400).json({ error: 'У записи нет фото' });
|
||
}
|
||
let newPath;
|
||
let engine = 'client';
|
||
if (req.is('json')) {
|
||
if (!sharp) return res.status(503).json({ error: 'sharp недоступен на сервере' });
|
||
const p = req.body.params || {};
|
||
const brightness = clampEnhanceParam(p.brightness, 10, 300, 100);
|
||
const contrast = clampEnhanceParam(p.contrast, 10, 300, 100);
|
||
const saturate = clampEnhanceParam(p.saturate, 0, 300, 100);
|
||
const sharpAmt = clampEnhanceParam(p.sharp, 0, 100, 0);
|
||
const denoise = clampEnhanceParam(p.denoise, 0, 100, 0);
|
||
const srcKey = storage.keyFromPath(oldPath);
|
||
const srcPath = srcKey ? await storage.localize(srcKey) : null;
|
||
if (!srcPath) return res.status(400).json({ error: 'Файл фото не найден' });
|
||
let pipeline = sharp(srcPath).rotate();
|
||
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
|
||
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
|
||
if (contrast !== 100) {
|
||
const a = contrast / 100;
|
||
pipeline = pipeline.linear(a, 128 * (1 - a));
|
||
}
|
||
if (sharpAmt > 0) {
|
||
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
|
||
}
|
||
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||
const outPath = path.join(UPLOADS_DIR, newName);
|
||
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
|
||
await storage.persist(newName, outPath);
|
||
newPath = `/uploads/${newName}`;
|
||
engine = 'sharp';
|
||
} else {
|
||
if (!req.file) return res.status(400).json({ error: 'Нет файла' });
|
||
await convertPhoto(req.file);
|
||
newPath = `/uploads/${req.file.filename}`;
|
||
}
|
||
await swapEntryPhotoFiles(req, req.params.id, oldPath, newPath, { action: 'enhance' });
|
||
res.json({ ok: true, photo_path: newPath, engine });
|
||
} catch (e) {
|
||
if (req.file) safeUnlink(req.file.path);
|
||
console.error('PUT /api/entries/:id/photo/enhance:', e);
|
||
res.status(500).json({ error: 'Ошибка замены фото' });
|
||
}
|
||
});
|
||
|
||
function parsePhotoAiRequest(body) {
|
||
const src = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
||
const pick = (v, def) => {
|
||
if (v === undefined || v === null || v === '') return def;
|
||
return String(v).trim().toLowerCase();
|
||
};
|
||
const model = pick(src.model, PHOTO_AI_DEFAULT_MODEL);
|
||
if (!PHOTO_AI_MODELS.includes(model)) {
|
||
return { error: `model должен быть одним из: ${PHOTO_AI_MODELS.join(', ')}` };
|
||
}
|
||
const face = pick(src.face, 'off');
|
||
if (!PHOTO_AI_FACE_MODES.includes(face)) {
|
||
return { error: `face должен быть одним из: ${PHOTO_AI_FACE_MODES.join(', ')}` };
|
||
}
|
||
const faceModel = pick(src.face_model, PHOTO_AI_FACE_MODEL);
|
||
if (face !== 'off' && !PHOTO_AI_FACE_MODELS.includes(faceModel)) {
|
||
return { error: `face_model должен быть одним из: ${PHOTO_AI_FACE_MODELS.join(', ')}` };
|
||
}
|
||
let strength = PHOTO_AI_DEFAULT_STRENGTH;
|
||
if (src.strength !== undefined && src.strength !== null && src.strength !== '') {
|
||
const n = Number(src.strength);
|
||
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
||
return { error: 'strength должен быть числом от 0 до 1' };
|
||
}
|
||
strength = n;
|
||
}
|
||
if (face !== 'off' && faceModel !== 'codeformer' && Math.abs(strength - PHOTO_AI_DEFAULT_STRENGTH) > 1e-9) {
|
||
return { error: `strength применяется только к codeformer, для ${faceModel} оставьте ${PHOTO_AI_DEFAULT_STRENGTH}` };
|
||
}
|
||
return {
|
||
action: face === 'off' ? 'ai' : 'ai_face',
|
||
params: { model, face, face_model: face === 'off' ? null : faceModel, strength },
|
||
};
|
||
}
|
||
|
||
app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => {
|
||
if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' });
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
||
const hasParams = Object.keys(body).some((k) => body[k] !== undefined && body[k] !== null && body[k] !== '');
|
||
const parsed = hasParams ? parsePhotoAiRequest(body) : { action: 'ai', params: null };
|
||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
|
||
if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||
if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1, $2, 'pending', $3) RETURNING id`,
|
||
[req.params.id, parsed.action, parsed.params ? JSON.stringify(parsed.params) : null]
|
||
);
|
||
if (photoWorker) photoWorker.notify();
|
||
await logAudit(req, 'entry.photo.enhance-ai.queue', {
|
||
entry_id: req.params.id,
|
||
job_id: rows[0].id,
|
||
action: parsed.action,
|
||
params: parsed.params,
|
||
});
|
||
res.json({ jobId: rows[0].id, action: parsed.action, params: parsed.params });
|
||
} catch (e) {
|
||
console.error('POST /api/entries/:id/photo/enhance-ai:', e);
|
||
res.status(500).json({ error: 'Ошибка постановки задания в очередь' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res) => {
|
||
const jobId = parseInt(req.params.jobId, 10);
|
||
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
|
||
const { rows } = await pool.query(
|
||
'SELECT id, status, error, after_path, applied FROM photo_jobs WHERE id = $1 AND entry_id = $2',
|
||
[jobId, req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' });
|
||
const job = rows[0];
|
||
const out = { status: job.status, applied: job.applied, job_id: job.id };
|
||
if (job.status === 'error') out.error = job.error;
|
||
if (job.status === 'done') out.photo_path = job.after_path;
|
||
res.json(out);
|
||
});
|
||
|
||
app.post('/api/entries/:id/photo/jobs/:jobId/apply', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const client = await pool.connect();
|
||
try {
|
||
const jobId = parseInt(req.params.jobId, 10);
|
||
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
|
||
await client.query('BEGIN');
|
||
const { rows: jobRows } = await client.query(
|
||
`SELECT id, entry_id, action, status, applied, after_path FROM photo_jobs
|
||
WHERE id = $1 AND entry_id = $2 FOR UPDATE`,
|
||
[jobId, req.params.id]
|
||
);
|
||
const job = jobRows[0];
|
||
if (!job) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Задание не найдено' }); }
|
||
if (job.status !== 'done') { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Результат ещё не готов' }); }
|
||
if (job.applied) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Результат уже применён' }); }
|
||
if (!job.after_path || !isSafeUploadPath(job.after_path)) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({ error: 'Некорректный путь результата' });
|
||
}
|
||
if (!(await storage.exists(job.after_path))) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({ error: 'Файл результата не найден' });
|
||
}
|
||
const { rows: entryRows } = await client.query('SELECT photo_path, photo_original_path FROM entries WHERE id = $1', [req.params.id]);
|
||
if (!entryRows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Запись не найдена' }); }
|
||
const oldPath = entryRows[0].photo_path;
|
||
if (oldPath === job.after_path) {
|
||
await client.query(`UPDATE photo_jobs SET applied = true WHERE id = $1`, [jobId]);
|
||
await client.query('COMMIT');
|
||
return res.json({ ok: true, photo_path: job.after_path });
|
||
}
|
||
let originalPath = entryRows[0].photo_original_path;
|
||
let beforePath = originalPath;
|
||
const oldKey = oldPath ? storage.keyFromPath(oldPath) : null;
|
||
if (oldKey && (await storage.exists(oldKey))) {
|
||
try {
|
||
const backupKey = `.originals/${crypto.randomBytes(12).toString('hex')}${path.extname(oldKey) || '.jpg'}`;
|
||
if (await storage.copyObject(oldKey, backupKey)) {
|
||
beforePath = `/uploads/${backupKey}`;
|
||
if (!originalPath) originalPath = beforePath;
|
||
safeUnlink(oldKey);
|
||
}
|
||
} catch (e) {
|
||
console.error('photo apply backup failed:', e);
|
||
}
|
||
}
|
||
await client.query('UPDATE entries SET photo_path = $1, photo_original_path = $2 WHERE id = $3', [job.after_path, originalPath, req.params.id]);
|
||
await client.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [job.after_path, req.params.id, oldPath]);
|
||
thumbUnlinkFor(oldPath);
|
||
await client.query(`UPDATE photo_jobs SET applied = true, before_path = COALESCE(before_path, $1) WHERE id = $2`, [beforePath, jobId]);
|
||
await client.query('COMMIT');
|
||
await logAudit(req, 'entry.photo.apply', { entry_id: req.params.id, job_id: jobId, before_path: beforePath, after_path: job.after_path });
|
||
invalidateEntries();
|
||
res.json({ ok: true, photo_path: job.after_path });
|
||
} catch (e) {
|
||
await client.query('ROLLBACK').catch(() => {});
|
||
console.error('POST /api/entries/:id/photo/jobs/:jobId/apply:', e);
|
||
res.status(500).json({ error: 'Ошибка применения фотографии' });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
app.post('/api/entries/:id/photo/jobs/:jobId/reject', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
try {
|
||
const jobId = parseInt(req.params.jobId, 10);
|
||
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
|
||
const { rows } = await pool.query(
|
||
`SELECT id, status, applied, after_path FROM photo_jobs WHERE id = $1 AND entry_id = $2`,
|
||
[jobId, req.params.id]
|
||
);
|
||
const job = rows[0];
|
||
if (!job) return res.status(404).json({ error: 'Задание не найдено' });
|
||
if (job.applied) return res.status(400).json({ error: 'Результат уже применён' });
|
||
if (job.status === 'done' && job.after_path && isSafeUploadPath(job.after_path)) safeUnlink(job.after_path);
|
||
await pool.query(
|
||
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now()
|
||
WHERE id = $1 AND applied = false`,
|
||
[jobId]
|
||
);
|
||
await logAudit(req, 'entry.photo.reject', { entry_id: req.params.id, job_id: jobId });
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
console.error('POST /api/entries/:id/photo/jobs/:jobId/reject:', e);
|
||
res.status(500).json({ error: 'Ошибка отклонения результата' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/entries/:id/photo/jobs', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
`SELECT id, action, status, applied, params, before_path, after_path, error, created_at, finished_at
|
||
FROM photo_jobs WHERE entry_id = $1 ORDER BY id DESC LIMIT 50`,
|
||
[req.params.id]
|
||
);
|
||
res.json(rows);
|
||
});
|
||
|
||
app.post('/api/entries/:id/photo/jobs/:jobId/rollback', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
try {
|
||
const jobId = parseInt(req.params.jobId, 10);
|
||
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
|
||
const { rows: jobRows } = await pool.query(
|
||
`SELECT id, before_path FROM photo_jobs WHERE id = $1 AND entry_id = $2 AND status = 'done'`,
|
||
[jobId, req.params.id]
|
||
);
|
||
if (!jobRows.length) return res.status(404).json({ error: 'Задание не найдено' });
|
||
const job = jobRows[0];
|
||
if (!job.before_path || !job.before_path.startsWith('/uploads/.originals/')) {
|
||
return res.status(400).json({ error: 'Нет сохранённой версии для отката' });
|
||
}
|
||
const beforeName = path.basename(job.before_path);
|
||
if (!/^[A-Za-z0-9._-]+$/.test(beforeName)) return res.status(400).json({ error: 'Некорректный путь' });
|
||
const beforeKey = `.originals/${beforeName}`;
|
||
if (!(await storage.exists(beforeKey))) return res.status(400).json({ error: 'Файл версии не найден' });
|
||
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
|
||
if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||
const oldPath = cur[0].photo_path;
|
||
if (!oldPath) return res.status(400).json({ error: 'У записи нет фото' });
|
||
const ext = path.extname(beforeName) || '.jpg';
|
||
const newKey = `${crypto.randomBytes(12).toString('hex')}${ext}`;
|
||
const newPath = `/uploads/${newKey}`;
|
||
if (!(await storage.copyObject(beforeKey, newKey))) {
|
||
return res.status(400).json({ error: 'Файл версии не найден' });
|
||
}
|
||
const oldKey = storage.keyFromPath(oldPath);
|
||
let rollbackBefore = null;
|
||
if (oldKey && (await storage.exists(oldKey))) {
|
||
try {
|
||
const backupKey = `.originals/${crypto.randomBytes(12).toString('hex')}${path.extname(oldKey) || '.jpg'}`;
|
||
if (await storage.copyObject(oldKey, backupKey)) {
|
||
rollbackBefore = `/uploads/${backupKey}`;
|
||
safeUnlink(oldKey);
|
||
}
|
||
} catch (e) {
|
||
console.error('photo rollback backup failed:', e);
|
||
}
|
||
}
|
||
thumbUnlinkFor(oldPath);
|
||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, req.params.id]);
|
||
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]);
|
||
await pool.query(
|
||
'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, error, finished_at) VALUES ($1, $2, $3, $4, $5, NULL, now())',
|
||
[req.params.id, 'rollback', 'done', rollbackBefore, newPath]
|
||
);
|
||
await logAudit(req, 'entry.photo.rollback', { entry_id: req.params.id, job_id: jobId, restored_path: newPath });
|
||
invalidateEntries();
|
||
res.json({ ok: true, photo_path: newPath });
|
||
} catch (e) {
|
||
console.error('POST /api/entries/:id/photo/jobs/:jobId/rollback:', e);
|
||
res.status(500).json({ error: 'Ошибка отката фотографии' });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req, res) => {
|
||
const { path: p } = req.body || {};
|
||
if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' });
|
||
safeUnlink(p);
|
||
await pool.query(
|
||
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now()
|
||
WHERE entry_id = $1 AND after_path = $2 AND status = 'done' AND applied = false`,
|
||
[req.params.id, p]
|
||
);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.post('/api/entries/:id/photo/restore-original', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
try {
|
||
const { rows } = await pool.query('SELECT photo_path, photo_original_path FROM entries WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||
const origPath = rows[0].photo_original_path;
|
||
if (!origPath) return res.status(400).json({ error: 'Оригинал не сохранён' });
|
||
const origName = path.basename(origPath);
|
||
if (!/^[A-Za-z0-9._-]+$/.test(origName)) return res.status(400).json({ error: 'Некорректный путь оригинала' });
|
||
const origKey = `.originals/${origName}`;
|
||
if (!(await storage.exists(origKey))) return res.status(400).json({ error: 'Файл оригинала не найден' });
|
||
const newKey = `${crypto.randomBytes(12).toString('hex')}${path.extname(origName) || '.jpg'}`;
|
||
const newPath = `/uploads/${newKey}`;
|
||
if (!(await storage.copyObject(origKey, newKey))) return res.status(400).json({ error: 'Файл оригинала не найден' });
|
||
await storage.del(origKey);
|
||
const oldPath = rows[0].photo_path;
|
||
await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = NULL WHERE id = $2', [newPath, req.params.id]);
|
||
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]);
|
||
const oldKey = storage.keyFromPath(oldPath);
|
||
let beforePath = null;
|
||
if (oldKey && (await storage.exists(oldKey))) {
|
||
try {
|
||
const backupKey = `.originals/${crypto.randomBytes(12).toString('hex')}${path.extname(oldKey) || '.jpg'}`;
|
||
if (await storage.copyObject(oldKey, backupKey)) {
|
||
beforePath = `/uploads/${backupKey}`;
|
||
safeUnlink(oldKey);
|
||
}
|
||
} catch (e) {
|
||
console.error('photo original backup failed:', e);
|
||
}
|
||
}
|
||
thumbUnlinkFor(oldPath);
|
||
await pool.query(
|
||
'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, error, finished_at) VALUES ($1, $2, $3, $4, $5, NULL, now())',
|
||
[req.params.id, 'restore', 'done', beforePath, newPath]
|
||
);
|
||
await logAudit(req, 'entry.photo.restore_original', { entry_id: req.params.id, restored_path: newPath });
|
||
invalidateEntries();
|
||
res.json({ ok: true, photo_path: newPath });
|
||
} catch (e) {
|
||
console.error('POST /api/entries/:id/photo/restore-original:', e);
|
||
res.status(500).json({ error: 'Ошибка восстановления оригинала' });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
await pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1', [req.params.id]);
|
||
await logAudit(req, 'entry.soft-delete', { id: req.params.id });
|
||
invalidateEntries();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.put('/api/entries/:id/restore', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) {
|
||
console.warn(`[RESTORE DENIED] User ${req.user.id} (${req.user.username}) role=${req.user.role} branches=${JSON.stringify(req.user.branch_ids)} tried to restore entry ${req.params.id} (group_id=${acc.group_id})`);
|
||
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
}
|
||
const result = await pool.query('UPDATE entries SET deleted_at = NULL, purge_at = NULL WHERE id = $1', [req.params.id]);
|
||
console.log(`[RESTORE] User ${req.user.id} (${req.user.username}) restored entry ${req.params.id}, rowCount=${result.rowCount}`);
|
||
if (result.rowCount === 0) {
|
||
return res.status(404).json({ error: 'Запись не найдена или уже восстановлена' });
|
||
}
|
||
await logAudit(req, 'entry.restore', { id: req.params.id });
|
||
invalidateEntries();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.delete('/api/entries/:id/permanent', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) {
|
||
console.warn(`[PERM DELETE DENIED] User ${req.user.id} (${req.user.username}) role=${req.user.role} branches=${JSON.stringify(req.user.branch_ids)} tried to perm delete entry ${req.params.id} (group_id=${acc.group_id})`);
|
||
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
}
|
||
const days = await trashPurgeDays();
|
||
const result = await pool.query(
|
||
`UPDATE entries SET purge_at = now() + ($2 || ' days')::interval WHERE id = $1 AND deleted_at IS NOT NULL AND purge_at IS NULL`,
|
||
[req.params.id, days]
|
||
);
|
||
if (result.rowCount === 0) {
|
||
return res.status(404).json({ error: 'Запись не найдена, не в корзине или уже помечена на удаление' });
|
||
}
|
||
await logAudit(req, 'entry.schedule-delete', { id: req.params.id, days });
|
||
invalidateEntries();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.put('/api/entries/:id/unschedule', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const result = await pool.query('UPDATE entries SET purge_at = NULL WHERE id = $1 AND purge_at IS NOT NULL', [req.params.id]);
|
||
if (result.rowCount === 0) {
|
||
return res.status(404).json({ error: 'Запись не найдена или не помечена на удаление' });
|
||
}
|
||
await logAudit(req, 'entry.unschedule', { id: req.params.id });
|
||
invalidateEntries();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.post('/api/ai/correct', requireAuth, async (req, res) => {
|
||
const text = reqStr(req.body?.text, 5000);
|
||
if (!text) return res.status(400).json({ error: 'Текст не указан' });
|
||
try {
|
||
const corrected = await aiCorrectText(text);
|
||
res.json({ suggestion: corrected });
|
||
} catch (e) {
|
||
res.status(502).json({ error: 'Сервис ИИ недоступен: ' + e.message });
|
||
}
|
||
});
|
||
|
||
// --- AI model profiles ---
|
||
function validateProfileBody(body) {
|
||
const name = String(body?.name || '').trim();
|
||
const base = String(body?.base_url || '').trim().replace(/\/+$/, '');
|
||
const model = String(body?.model || '').trim();
|
||
const apiKey = String(body?.api_key || '').trim();
|
||
let maxTokens = null;
|
||
if (body?.max_tokens !== null && body?.max_tokens !== undefined && String(body.max_tokens).trim() !== '') {
|
||
maxTokens = parseInt(String(body.max_tokens), 10);
|
||
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 32768) {
|
||
return { error: 'max_tokens должен быть целым числом от 16 до 32768' };
|
||
}
|
||
}
|
||
if (!name || name.length > 100) return { error: 'Укажите название профиля (до 100 символов)' };
|
||
if (!/^https?:\/\//i.test(base) || base.length > 300) return { error: 'Base URL должен быть корректным http(s)://… (до 300 символов)' };
|
||
if (!model || model.length > 150) return { error: 'Укажите название модели (до 150 символов)' };
|
||
if (apiKey.length > 300) return { error: 'API-ключ слишком длинный' };
|
||
return { name, base_url: base, model, api_key: apiKey, max_tokens: maxTokens };
|
||
}
|
||
|
||
app.get('/api/ai/profiles', requireAdmin, async (_, res) => {
|
||
const profiles = await getAiProfiles();
|
||
const active = await getSetting('ai_active_profile', 'native');
|
||
res.json({
|
||
active,
|
||
native: { id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: AI_URL, model: AI_MODEL },
|
||
profiles,
|
||
});
|
||
});
|
||
|
||
app.post('/api/ai/profiles', requireAdmin, async (req, res) => {
|
||
const v = validateProfileBody(req.body);
|
||
if (v.error) return res.status(400).json({ error: v.error });
|
||
const profiles = await getAiProfiles();
|
||
if (profiles.length >= 20) return res.status(400).json({ error: 'Слишком много профилей (макс. 20)' });
|
||
const profile = { id: crypto.randomBytes(8).toString('hex'), ...v };
|
||
profiles.push(profile);
|
||
await pool.query(
|
||
`INSERT INTO settings (key, value) VALUES ('ai_profiles', $1)
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||
[JSON.stringify(profiles)]
|
||
);
|
||
await logAudit(req, 'ai.profile.create', { id: profile.id, name: profile.name, model: profile.model });
|
||
invalidateSettings();
|
||
if (entryAutoChecker) entryAutoChecker.notify();
|
||
res.status(201).json(profile);
|
||
});
|
||
|
||
app.put('/api/ai/profiles/:id', requireAdmin, async (req, res) => {
|
||
const profiles = await getAiProfiles();
|
||
const idx = profiles.findIndex(p => p.id === req.params.id);
|
||
if (idx === -1) return res.status(404).json({ error: 'Профиль не найден' });
|
||
const v = validateProfileBody(req.body);
|
||
if (v.error) return res.status(400).json({ error: v.error });
|
||
profiles[idx] = { id: req.params.id, ...v };
|
||
await pool.query(
|
||
`INSERT INTO settings (key, value) VALUES ('ai_profiles', $1)
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||
[JSON.stringify(profiles)]
|
||
);
|
||
await logAudit(req, 'ai.profile.update', { id: req.params.id, name: v.name, model: v.model });
|
||
invalidateSettings();
|
||
if (entryAutoChecker) entryAutoChecker.notify();
|
||
res.json(profiles[idx]);
|
||
});
|
||
|
||
app.delete('/api/ai/profiles/:id', requireAdmin, async (req, res) => {
|
||
const profiles = await getAiProfiles();
|
||
const idx = profiles.findIndex(p => p.id === req.params.id);
|
||
if (idx === -1) return res.status(404).json({ error: 'Профиль не найден' });
|
||
const removed = profiles.splice(idx, 1)[0];
|
||
await pool.query(
|
||
`INSERT INTO settings (key, value) VALUES ('ai_profiles', $1)
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||
[JSON.stringify(profiles)]
|
||
);
|
||
const active = await getSetting('ai_active_profile', 'native');
|
||
if (active === req.params.id) {
|
||
await pool.query(
|
||
`INSERT INTO settings (key, value) VALUES ('ai_active_profile', 'native')
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
|
||
);
|
||
}
|
||
await logAudit(req, 'ai.profile.delete', { id: req.params.id, name: removed.name });
|
||
invalidateSettings();
|
||
if (entryAutoChecker) entryAutoChecker.notify();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.post('/api/ai/profiles/activate', requireAdmin, async (req, res) => {
|
||
const id = String(req.body?.id || '').trim();
|
||
if (id !== 'native') {
|
||
const profiles = await getAiProfiles();
|
||
if (!profiles.some(p => p.id === id)) return res.status(400).json({ error: 'Профиль не найден' });
|
||
}
|
||
await pool.query(
|
||
`INSERT INTO settings (key, value) VALUES ('ai_active_profile', $1)
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||
[id]
|
||
);
|
||
await logAudit(req, 'ai.profile.activate', { id });
|
||
invalidateSettings();
|
||
if (entryAutoChecker) entryAutoChecker.notify();
|
||
res.json({ ok: true, active: id });
|
||
});
|
||
|
||
app.post('/api/ai/profiles/test', requireAdmin, async (req, res) => {
|
||
const v = validateProfileBody(req.body);
|
||
if (v.error) return res.status(400).json({ error: v.error });
|
||
const base = normalizeOpenAiBase(v.base_url);
|
||
const startedAt = Date.now();
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), 15000);
|
||
try {
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (v.api_key) headers.Authorization = `Bearer ${v.api_key}`;
|
||
const r = await fetch(`${base}/chat/completions`, {
|
||
method: 'POST',
|
||
headers,
|
||
signal: controller.signal,
|
||
body: JSON.stringify({
|
||
model: v.model,
|
||
messages: [{ role: 'user', content: 'Ответь одним словом: ок' }],
|
||
max_tokens: 8,
|
||
temperature: 0,
|
||
}),
|
||
});
|
||
const latency_ms = Date.now() - startedAt;
|
||
if (!r.ok) {
|
||
const t = await r.text().catch(() => '');
|
||
return res.json({ ok: false, latency_ms, error: `HTTP ${r.status}${t ? ': ' + t.slice(0, 200) : ''}` });
|
||
}
|
||
const d = await r.json();
|
||
const sample = (d.choices?.[0]?.message?.content || '').trim();
|
||
res.json({ ok: true, latency_ms, sample: sample.slice(0, 120) });
|
||
} catch (e) {
|
||
const latency_ms = Date.now() - startedAt;
|
||
res.json({ ok: false, latency_ms, error: e.name === 'AbortError' ? 'Таймаут 15 с' : (e.message || 'unreachable') });
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
});
|
||
|
||
app.get('/api/ai/queue', requireAdmin, async (_, res) => {
|
||
const { rows } = await pool.query(
|
||
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
||
);
|
||
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
|
||
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
||
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
||
const activeProfile = await getActiveAiProfile();
|
||
res.json({
|
||
enabled,
|
||
counts,
|
||
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
|
||
model: activeProfile ? `${activeProfile.name} (${activeProfile.model})` : AI_MODEL,
|
||
});
|
||
});
|
||
|
||
async function aiHealthCheck() {
|
||
const startedAt = Date.now();
|
||
const profile = await getActiveAiProfile();
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), 5000);
|
||
try {
|
||
const headers = {};
|
||
let url;
|
||
if (profile) {
|
||
const base = normalizeOpenAiBase(profile.base_url);
|
||
if (!base) return { reachable: false, latency_ms: 0, error: 'Некорректный base_url профиля' };
|
||
url = `${base}/models`;
|
||
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
|
||
} else {
|
||
url = `${AI_URL}/health`;
|
||
}
|
||
const r = await fetch(url, { headers, signal: controller.signal });
|
||
const latency_ms = Date.now() - startedAt;
|
||
if (!r.ok) return { reachable: false, latency_ms, error: `HTTP ${r.status}` };
|
||
return { reachable: true, latency_ms, error: null };
|
||
} catch (e) {
|
||
const latency_ms = Date.now() - startedAt;
|
||
const error = e && e.name === 'AbortError' ? 'timeout' : (e && e.message ? e.message : 'unreachable');
|
||
return { reachable: false, latency_ms, error };
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function photoAiHealth(timeoutMs = 5000) {
|
||
if (!PHOTO_AI_URL) return { configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' };
|
||
const startedAt = Date.now();
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const r = await fetch(`${PHOTO_AI_URL}/health`, { signal: controller.signal });
|
||
const latency_ms = Date.now() - startedAt;
|
||
if (!r.ok) return { configured: true, reachable: false, latency_ms, error: `HTTP ${r.status}` };
|
||
let data = null;
|
||
try { data = await r.json(); } catch (e) { data = null; }
|
||
const payload = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
||
return { ...payload, configured: true, reachable: true, latency_ms, error: null };
|
||
} catch (e) {
|
||
const latency_ms = Date.now() - startedAt;
|
||
const error = e && e.name === 'AbortError' ? 'timeout' : (e && e.message ? e.message : 'unreachable');
|
||
return { configured: true, reachable: false, latency_ms, error };
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
app.get('/api/photo-ai/health', requireAdmin, async (_, res) => {
|
||
const health = await photoAiHealth(5000);
|
||
res.json(health);
|
||
});
|
||
|
||
app.get('/api/ai/status', requireAdmin, async (_, res) => {
|
||
const { rows } = await pool.query(
|
||
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
||
);
|
||
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
|
||
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
||
const [pending, recent, errors] = await Promise.all([
|
||
pool.query(
|
||
`SELECT e.id, e.student_name, e.created_at, g.name AS group_name
|
||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||
WHERE e.ai_status IN ('pending', 'processing') AND e.deleted_at IS NULL
|
||
ORDER BY e.id ASC LIMIT 20`
|
||
),
|
||
pool.query(
|
||
`SELECT e.id, e.student_name, e.ai_status, e.ai_checked_at, e.ai_error, g.name AS group_name,
|
||
e.description_original, e.description_ai,
|
||
(e.description_ai IS NOT NULL AND e.description_original IS NOT NULL AND e.description_ai <> e.description_original) AS changed
|
||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||
WHERE e.ai_checked_at IS NOT NULL AND e.deleted_at IS NULL
|
||
ORDER BY e.ai_checked_at DESC LIMIT 30`
|
||
),
|
||
pool.query(
|
||
`SELECT e.id, e.student_name, e.ai_error, e.ai_checked_at, g.name AS group_name
|
||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||
WHERE e.ai_status = 'error' AND e.deleted_at IS NULL
|
||
ORDER BY e.ai_checked_at DESC NULLS LAST, e.id DESC LIMIT 20`
|
||
),
|
||
]);
|
||
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
||
const service = await aiHealthCheck();
|
||
const activeProfile = await getActiveAiProfile();
|
||
res.json({
|
||
enabled,
|
||
model: activeProfile ? `${activeProfile.name} (${activeProfile.model})` : AI_MODEL,
|
||
ai_url: AI_URL,
|
||
service,
|
||
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
|
||
counts,
|
||
pending: pending.rows,
|
||
recent: recent.rows,
|
||
errors: errors.rows,
|
||
});
|
||
});
|
||
|
||
app.post('/api/ai/wake', requireAdmin, async (req, res) => {
|
||
if (entryAutoChecker) entryAutoChecker.notify();
|
||
await logAudit(req, 'ai.wake', {});
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.post('/api/ai/enabled', requireAdmin, async (req, res) => {
|
||
const enabled = !!req.body?.enabled;
|
||
await pool.query(
|
||
`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', $1)
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||
[enabled ? 'true' : 'false']
|
||
);
|
||
if (enabled && entryAutoChecker) entryAutoChecker.notify();
|
||
await logAudit(req, 'ai.enabled', { enabled });
|
||
res.json({ ok: true, enabled });
|
||
});
|
||
|
||
app.post('/api/ai/requeue-failed', requireAdmin, async (req, res) => {
|
||
const { rowCount } = await pool.query(
|
||
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL
|
||
WHERE ai_status = 'error' AND deleted_at IS NULL`
|
||
);
|
||
if (entryAutoChecker) entryAutoChecker.notify();
|
||
await logAudit(req, 'ai.requeue-failed', { count: rowCount });
|
||
invalidateEntries();
|
||
res.json({ ok: true, count: rowCount });
|
||
});
|
||
|
||
app.post('/api/entries/:id/ai/recheck', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const { rows } = await pool.query(
|
||
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE id = $1 RETURNING id`,
|
||
[req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||
if (entryAutoChecker) entryAutoChecker.notify();
|
||
await logAudit(req, 'entry.ai.recheck', { id: req.params.id });
|
||
invalidateEntries();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.post('/api/entries/:id/ai/revert', requireAuth, async (req, res) => {
|
||
if (req.user.role !== 'admin') {
|
||
const acc = await entryAccessible(req.user, req.params.id);
|
||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||
}
|
||
const beforeRes = await pool.query(
|
||
'SELECT id, description, description_ai FROM entries WHERE id = $1',
|
||
[req.params.id]
|
||
);
|
||
const { rows } = await pool.query(
|
||
`UPDATE entries SET description = description_original, description_ai = NULL,
|
||
ai_status = 'reverted', ai_error = NULL, ai_checked_at = now()
|
||
WHERE id = $1 AND description_original IS NOT NULL RETURNING id, description, description_ai`,
|
||
[req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(400).json({ error: 'Оригинал текста недоступен' });
|
||
const before = beforeRes.rows[0];
|
||
const revertDiff = textDiff(before ? before.description : '', rows[0].description);
|
||
await logAudit(req, 'entry.ai.revert', {
|
||
id: rows[0].id,
|
||
source: 'ai_revert',
|
||
changed: revertDiff.changed,
|
||
fields: revertDiff.changed ? ['description'] : [],
|
||
changes: revertDiff.changed
|
||
? [{ field: 'description', label: 'Текст работы', stats: revertDiff.stats, diff: revertDiff.segments, truncated: revertDiff.truncated }]
|
||
: []
|
||
});
|
||
invalidateEntries();
|
||
invalidateStats();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.get('/api/photo-jobs/status', requireAdmin, async (req, res) => {
|
||
const recentLimit = Math.min(Math.max(optInt(req.query.recent_limit, 1, 200) ?? 15, 1), 200);
|
||
const recentOffset = Math.max(optInt(req.query.recent_offset, 0, 2147483647) ?? 0, 0);
|
||
const { rows } = await pool.query(
|
||
`SELECT status, count(*)::int AS n FROM photo_jobs GROUP BY status`
|
||
);
|
||
const counts = { pending: 0, processing: 0, done: 0, error: 0 };
|
||
rows.forEach(r => { counts[r.status] = r.n; });
|
||
const [pending, recentTotal, recent, errors, service] = await Promise.all([
|
||
pool.query(
|
||
`SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name
|
||
FROM photo_jobs j
|
||
JOIN entries e ON e.id = j.entry_id
|
||
JOIN groups g ON g.id = e.group_id
|
||
WHERE j.status IN ('pending', 'processing')
|
||
ORDER BY j.id ASC LIMIT 20`
|
||
),
|
||
pool.query(
|
||
`SELECT count(*)::int AS n FROM photo_jobs j
|
||
JOIN entries e ON e.id = j.entry_id
|
||
JOIN groups g ON g.id = e.group_id
|
||
WHERE j.finished_at IS NOT NULL`
|
||
),
|
||
pool.query(
|
||
`SELECT j.id, j.entry_id, j.action, j.status, j.params, j.before_path, j.after_path, j.error, j.created_at, j.finished_at,
|
||
e.student_name, g.name AS group_name
|
||
FROM photo_jobs j
|
||
JOIN entries e ON e.id = j.entry_id
|
||
JOIN groups g ON g.id = e.group_id
|
||
WHERE j.finished_at IS NOT NULL
|
||
ORDER BY j.finished_at DESC LIMIT $1 OFFSET $2`,
|
||
[recentLimit, recentOffset]
|
||
),
|
||
pool.query(
|
||
`SELECT j.id, j.entry_id, j.action, j.error, j.finished_at, e.student_name, g.name AS group_name
|
||
FROM photo_jobs j
|
||
JOIN entries e ON e.id = j.entry_id
|
||
JOIN groups g ON g.id = e.group_id
|
||
WHERE j.status = 'error'
|
||
ORDER BY j.finished_at DESC NULLS LAST, j.id DESC LIMIT 20`
|
||
),
|
||
photoAiHealth(),
|
||
]);
|
||
const enabled = String(await getSetting('photo_worker_enabled', 'true')) !== 'false';
|
||
res.json({
|
||
enabled,
|
||
ai_configured: !!PHOTO_AI_URL,
|
||
ai_url: PHOTO_AI_URL,
|
||
service,
|
||
worker: photoWorker ? photoWorker.getInfo() : null,
|
||
counts,
|
||
pending: pending.rows,
|
||
recent: recent.rows,
|
||
recent_total: recentTotal.rows[0].n,
|
||
errors: errors.rows,
|
||
});
|
||
});
|
||
|
||
app.post('/api/photo-jobs/wake', requireAdmin, async (req, res) => {
|
||
if (photoWorker) photoWorker.notify();
|
||
await logAudit(req, 'photo-jobs.wake', {});
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.post('/api/photo-jobs/enabled', requireAdmin, async (req, res) => {
|
||
const enabled = !!req.body?.enabled;
|
||
await pool.query(
|
||
`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', $1)
|
||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||
[enabled ? 'true' : 'false']
|
||
);
|
||
if (enabled && photoWorker) photoWorker.notify();
|
||
await logAudit(req, 'photo-jobs.enabled', { enabled });
|
||
res.json({ ok: true, enabled });
|
||
});
|
||
|
||
app.post('/api/photo-jobs/requeue-failed', requireAdmin, async (req, res) => {
|
||
const { rowCount } = await pool.query(
|
||
`UPDATE photo_jobs SET status = 'pending', error = NULL, finished_at = NULL
|
||
WHERE status = 'error'`
|
||
);
|
||
if (photoWorker) photoWorker.notify();
|
||
await logAudit(req, 'photo-jobs.requeue-failed', { count: rowCount });
|
||
invalidateEntries();
|
||
res.json({ ok: true, count: rowCount });
|
||
});
|
||
|
||
async function trashData(req, mode, limit, offset) {
|
||
const bw = branchScope(req.user);
|
||
const scoped = req.user.role !== 'admin';
|
||
const isPending = mode === 'pending';
|
||
const eWhere = isPending
|
||
? ' WHERE e.purge_at IS NOT NULL'
|
||
: ' WHERE e.deleted_at IS NOT NULL AND e.purge_at IS NULL';
|
||
const gWhere = isPending
|
||
? ' WHERE g.purge_at IS NOT NULL'
|
||
: ' WHERE g.deleted_at IS NOT NULL AND g.purge_at IS NULL';
|
||
const eparams = [];
|
||
const gparams = [];
|
||
const esco = scoped
|
||
? (bw.ids.length ? ` AND g.branch_id IN (${bw.ids.map(id => `$${eparams.push(id)}`).join(',')})` : ' AND 1 = 0')
|
||
: '';
|
||
const gsco = scoped
|
||
? (bw.ids.length ? ` AND g.branch_id IN (${bw.ids.map(id => `$${gparams.push(id)}`).join(',')})` : ' AND 1 = 0')
|
||
: '';
|
||
const eOrder = isPending ? 'e.purge_at' : 'e.deleted_at';
|
||
const gOrder = isPending ? 'g.purge_at' : 'g.deleted_at';
|
||
const crows = await pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${eWhere}${esco}`, eparams);
|
||
let q = `SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id${eWhere}${esco} ORDER BY ${eOrder} DESC`;
|
||
const qparams = eparams.slice();
|
||
if (limit > 0) { qparams.push(limit); q += ` LIMIT $${qparams.length}`; }
|
||
if (offset > 0) { qparams.push(offset); q += ` OFFSET $${qparams.length}`; }
|
||
const { rows } = await pool.query(q, qparams);
|
||
let files = {};
|
||
if (rows.length) {
|
||
const fRes = await pool.query(
|
||
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
||
[rows.map(r => r.id)]
|
||
);
|
||
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
|
||
}
|
||
rows.forEach(r => { r.files = files[r.id] || []; });
|
||
const { rows: gcount } = await pool.query(`SELECT count(*)::int AS n FROM groups g${gWhere}${gsco}`, gparams);
|
||
const { rows: ggroups } = await pool.query(
|
||
`SELECT g.*, b.name AS branch_name FROM groups g
|
||
LEFT JOIN branches b ON b.id = g.branch_id
|
||
${gWhere}${gsco} ORDER BY ${gOrder} DESC`,
|
||
gparams
|
||
);
|
||
return { entries: rows, total: crows.rows[0].n, groups: ggroups, total_groups: gcount[0].n };
|
||
}
|
||
|
||
app.get('/api/trash', requireAuth, async (req, res) => {
|
||
const limit = parseInt(req.query.limit, 10);
|
||
const offset = parseInt(req.query.offset, 10);
|
||
res.json(await trashData(req, 'visible', limit, offset));
|
||
});
|
||
|
||
app.get('/api/trash/pending', requireAuth, async (req, res) => {
|
||
const data = await trashData(req, 'pending');
|
||
data.purge_days = await trashPurgeDays();
|
||
res.json(data);
|
||
});
|
||
|
||
app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => {
|
||
const days = await trashPurgeDays();
|
||
const e = await pool.query(
|
||
`UPDATE entries SET purge_at = now() + ($1 || ' days')::interval WHERE deleted_at IS NOT NULL AND purge_at IS NULL`,
|
||
[days]
|
||
);
|
||
const g = await pool.query(
|
||
`UPDATE groups SET purge_at = now() + ($1 || ' days')::interval WHERE deleted_at IS NOT NULL AND purge_at IS NULL`,
|
||
[days]
|
||
);
|
||
invalidateEntries();
|
||
invalidateGroups();
|
||
invalidateStats();
|
||
await logAudit(req, 'trash.clear', { entries: e.rowCount, groups: g.rowCount, days });
|
||
res.json({ ok: true, entries: e.rowCount, groups: g.rowCount, days });
|
||
});
|
||
|
||
// --- Error handlers ---
|
||
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
|
||
|
||
function isApiRoute(req) {
|
||
return req.path.startsWith('/api/') || req.path.startsWith('/s/') || req.path.startsWith('/r/');
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function renderErrorPage(code, title, message, details) {
|
||
return ERROR_HTML
|
||
.replace('id="errorCode">404', `id="errorCode">${code}`)
|
||
.replace('id="errorTitle">Страница не найдена', `id="errorTitle">${title}`)
|
||
.replace('id="errorMessage">Запрашиваемая страница не существует или была перемещена.', `id="errorMessage">${message}`)
|
||
.replace('style="display:none"', details ? '' : 'style="display:none"')
|
||
.replace('<pre id="errorStack"></pre>', details ? `<pre id="errorStack">${escapeHtml(details)}</pre>` : '<pre id="errorStack"></pre>');
|
||
}
|
||
|
||
app.use((req, res, next) => {
|
||
if (isApiRoute(req)) {
|
||
return res.status(404).json({ error: 'Not found' });
|
||
}
|
||
res.status(404).send(renderErrorPage(404, 'Страница не найдена', 'Запрашиваемая страница не существует или была перемещена.'));
|
||
});
|
||
|
||
app.use((err, req, res, next) => {
|
||
console.error('Error:', err);
|
||
if (isApiRoute(req)) {
|
||
return res.status(500).json({ error: 'Internal server error' });
|
||
}
|
||
const msg = process.env.NODE_ENV === 'production' ? 'Произошла ошибка на сервере.' : (err?.message || 'Internal server error');
|
||
const details = process.env.NODE_ENV === 'production' ? '' : (err?.stack || '');
|
||
res.status(500).send(renderErrorPage(500, 'Ошибка сервера', msg, details));
|
||
});
|
||
|
||
const PORT = process.env.PORT || 3003;
|
||
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
|
||
|
||
process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); });
|
||
process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); });
|
||
|
||
let shuttingDown = false;
|
||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||
process.on(signal, () => {
|
||
if (shuttingDown) return;
|
||
shuttingDown = true;
|
||
console.log(`${signal}: shutting down`);
|
||
cache.close()
|
||
.catch(() => {})
|
||
.finally(() => process.exit(0));
|
||
setTimeout(() => process.exit(0), 5000).unref();
|
||
});
|
||
}
|
||
|
||
const certPath = path.join(__dirname, 'certs', 'cert.pem');
|
||
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
||
|
||
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||
const httpsServer = https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app);
|
||
httpsServer.listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
||
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT}`));
|
||
} else {
|
||
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (no TLS certs)`));
|
||
}
|
||
|
||
(async () => {
|
||
try { await cache.connect(); } catch (err) { console.error('Redis connect:', err); }
|
||
try { await ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); }
|
||
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
|
||
try { await ensureAuditTable(); } catch (err) { console.error('Audit table:', err); }
|
||
try { await ensureBannedIpsTable(); } catch (err) { console.error('Banned IPs table:', err); }
|
||
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
|
||
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
||
try { await ensureModulesTable(); } catch (err) { console.error('Modules table:', err); }
|
||
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
||
try { await ensureStudentPhotosTable(); } catch (err) { console.error('Student photos table:', err); }
|
||
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); }
|
||
if (storage.isRemote()) {
|
||
try {
|
||
await storage.ensureBucket();
|
||
console.log('S3 bucket ready:', storage.bucket);
|
||
} catch (err) {
|
||
console.error('S3 bucket init:', err.message);
|
||
}
|
||
}
|
||
try { await purgeScheduledDeletions(); } catch (err) { console.error('Trash purge:', err); }
|
||
setInterval(() => { purgeScheduledDeletions().catch(err => console.error('Trash purge:', err)); }, 60 * 60 * 1000).unref();
|
||
setInterval(() => {
|
||
try { storage.pruneCache(); } catch (err) { console.error('Cache prune:', err); }
|
||
}, 60 * 60 * 1000).unref();
|
||
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT, bus: createWorkerBus(AI_WAKE_CHANNEL) });
|
||
entryAutoChecker.start();
|
||
console.log('AI auto-check worker started');
|
||
photoWorker = createPhotoEnhanceWorker({
|
||
pool,
|
||
getSetting,
|
||
logAudit,
|
||
invalidateEntries,
|
||
sharp,
|
||
photoAiUrl: PHOTO_AI_URL,
|
||
storage,
|
||
notifyEvent: notifyEntry,
|
||
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
|
||
faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS,
|
||
defaultModel: PHOTO_AI_DEFAULT_MODEL,
|
||
defaultFaceModel: PHOTO_AI_FACE_MODEL,
|
||
});
|
||
photoWorker.start();
|
||
console.log('Photo enhance worker started');
|
||
})();
|