Files
WhatIDo/server.js
T
dev 0e38a280d7 feat(redis): кэш, rate limit, баны IP и pub/sub через Redis
Добавлен сервис redis:7-alpine (AOF, requirepass, maxmemory + allkeys-lru,
healthcheck, том redis-data, порт только на 127.0.0.1) и абстракция redis.js
по образцу storage.js.

Переведено на Redis:
- кэш ответов API и настроек (было Map в памяти), инвалидация по префиксу
  через SCAN + DEL;
- rate limit для api/entry/file — общие счётчики вместо MemoryStore;
- баны IP и счётчики неудачных входа — с TTL, вместо опроса БД каждую минуту;
- кэш сессий (30 с) с invalidateSessions() на каждой мутации users/sessions/
  user_branches, иначе деактивированный пользователь сохранил бы доступ;
- pub/sub для SSE-событий и мгновенного пробуждения фоновых воркеров вместо
  ожидания цикла опроса БД.

Отказоустойчивость: при недоступном Redis все операции уходят в in-memory
backend с той же семантикой, приложение стартует и работает без Redis и
возвращается в Redis автоматически. Первое подключение ограничено по времени
(REDIS_CONNECT_TIMEOUT_MS, 5 с) — node-redis не отклоняет connect() при
недоступном сервере, а повторяет попытки бесконечно.

Добавлены тесты: redis.selftest.js (в т.ч. поведение при недоступном
сервере) и api.smoketest.js (сквозная проверка API, включая инвалидацию
кэша и мгновенную смерть сессии после logout).
2026-09-26 15:26:00 +03:00

5367 lines
229 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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);
} else {
broadcastEntryChanged();
}
});
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)); }
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);
}
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 });
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;
}
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`);
}
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(req, '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 });
});
// --- 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'];
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' };
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';
result.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
return result;
});
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 === '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 — некорректный путь' });
}
}
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);
});
// --- 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 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_ACTIONS = new Set(['ai', 'enhance', 'restore', 'rollback']);
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 });
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 });
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', {});
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/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);
} 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();
}
});
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 { 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', { id: req.params.id });
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: 'Ошибка замены фото' });
}
});
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 { 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, 'ai', 'pending', NULL) RETURNING id`,
[req.params.id]
);
if (photoWorker) photoWorker.notify();
await logAudit(req, 'entry.photo.enhance-ai.queue', { entry_id: req.params.id, job_id: rows[0].id });
res.json({ jobId: rows[0].id });
} 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);
}
}
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 { 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`,
[req.params.id]
);
if (!rows.length) return res.status(400).json({ error: 'Оригинал текста недоступен' });
await logAudit(req, 'entry.ai.revert', { id: req.params.id });
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] = 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`
),
]);
const enabled = String(await getSetting('photo_worker_enabled', 'true')) !== 'false';
const service = await aiHealthCheck();
res.json({
enabled,
ai_configured: !!PHOTO_AI_URL,
ai_url: PHOTO_AI_URL,
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
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 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,
uploadsDir: UPLOADS_DIR,
storage,
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
});
photoWorker.start();
console.log('Photo enhance worker started');
})();