Files
WhatIDo/server.js
T
dev 049df61e55 feat: photo quality improvements
- webcam capture resolution/quality configurable in admin settings (defaults 640x480 / 0.92)
- enhance photo modal in journal: original vs preview with sliders (brightness, contrast, saturation, sharpen) and auto-levels button
- new endpoint PUT /api/entries/:id/photo/enhance replaces photo, cleans old file and thumb
- sharper HEIC conversion (0.92) and webp thumbnails (85)
- worker: fail explicitly on empty AI response
2026-09-17 15:07:43 +03:00

4182 lines
177 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 } = require('./worker');
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 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 cacheStore = new Map();
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;
function cacheGet(key) {
const entry = cacheStore.get(key);
if (!entry) return undefined;
if (entry.exp && entry.exp <= Date.now()) {
cacheStore.delete(key);
return undefined;
}
return entry.value;
}
function cacheSet(key, value, ttlMs) {
cacheStore.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 });
}
function cacheDrop(prefix) {
for (const key of cacheStore.keys()) {
if (key.startsWith(prefix)) cacheStore.delete(key);
}
}
async function cacheWrap(key, ttlMs, fn) {
const hit = cacheGet(key);
if (hit !== undefined) return hit;
const value = await fn();
cacheSet(key, value, ttlMs);
return value;
}
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:'); }
const sseClients = new Set();
function broadcastEntryChanged() {
const frame = `event: entries_changed\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`;
for (const client of sseClients) {
try { client.write(frame); } catch (e) { sseClients.delete(client); }
}
}
function broadcastAiStatus(payload) {
const data = {
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()
};
const frame = `event: ai_status\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of sseClients) {
try { client.write(frame); } catch (e) { sseClients.delete(client); }
}
}
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() { cacheStore.clear(); }
const BAN_TTL_MS = 24 * 60 * 60 * 1000;
const FAIL_WINDOW_MS = 15 * 60 * 1000;
const banMemory = new Map();
const failMemory = new Map();
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);
banMemory.set(ip, { reason, banned_until: until.toISOString() });
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})`);
}
function ipGuard(req, res, next) {
const entry = banMemory.get(ipOf(req));
if (entry && new Date(entry.banned_until) > new Date()) {
return res.status(403).json({ error: 'Доступ заблокирован' });
}
next();
}
function recordFailure(req, kind, limit, ms) {
const ip = ipOf(req);
const now = Date.now();
let entry = failMemory.get(kind + ':' + ip);
if (!entry || entry.resetAt <= now) {
entry = { count: 0, resetAt: now + FAIL_WINDOW_MS };
failMemory.set(kind + ':' + ip, entry);
}
entry.count += 1;
if (entry.count >= limit) {
failMemory.delete(kind + ':' + ip);
return banIP(req, kind, ms).catch(err => console.error('Ban error:', err));
}
return Promise.resolve();
}
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);
banMemory.set(r.ip, { reason: r.reason, banned_until: r.banned_until });
}
for (const key of banMemory.keys()) {
if (!active.has(key)) banMemory.delete(key);
}
}
app.set('trust proxy', 'loopback');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Слишком много запросов. Попробуйте позже.' },
});
const entryLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Слишком много запросов. Подождите немного.' },
});
const fileLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false,
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);
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
const THUMB_WIDTH = 480;
let sharp = null;
try { sharp = require('sharp'); } catch {}
if (sharp) {
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
}
function thumbFileFor(fp) {
const base = path.basename(fp).replace(/\.[^.]+$/, '') + '.webp';
return path.join(THUMBS_DIR, base);
}
async function sendImageThumb(res, fp) {
if (!sharp) {
res.setHeader('Cache-Control', 'public, max-age=3600');
return res.sendFile(fp);
}
const tp = thumbFileFor(fp);
try {
if (!fs.existsSync(tp)) {
const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
await sharp(fp).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(fp);
}
}
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();
const fp = path.join(__dirname, 'uploads', name);
if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) return res.status(404).end();
return sendImageThumb(res, fp);
});
app.use((req, res, next) => {
const p = req.path;
if (!p.startsWith('/uploads') && !p.startsWith('/vendor')) {
res.setHeader('Cache-Control', 'no-cache');
}
next();
});
app.use('/uploads', express.static(path.join(__dirname, 'uploads'), { maxAge: '365d', immutable: true }));
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 { 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;
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 = 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;
cacheSet('setting:' + key, value, SETTINGS_TTL_MS);
return value;
}
const UPLOADS_DIR = path.join(__dirname, 'uploads');
function safeUnlink(relPath) {
if (!relPath || typeof relPath !== 'string') return;
const parts = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').split('/');
if (parts[0] === 'uploads') parts.shift();
if (!parts.length || parts.includes('..') || parts.includes('')) return;
const fp = path.resolve(UPLOADS_DIR, ...parts);
if (fp === UPLOADS_DIR || !fp.startsWith(UPLOADS_DIR + path.sep)) return;
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
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 = path.join(__dirname, 'uploads');
if (!fs.existsSync(dir)) return;
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }] = 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'),
]);
const refs = new Set();
[...photos, ...files, ...gphotos, ...ephotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (!fs.statSync(fp).isFile()) continue;
if (!refs.has('/uploads/' + f)) {
try { fs.unlinkSync(fp); } catch {}
}
}
}
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`);
}
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 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 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(`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 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');
}
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]);
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);
await banIpAddr(ip, reason, hours * 60 * 60 * 1000, req);
res.json({ ok: true, ip, reason, banned_until: banMemory.get(ip).banned_until });
});
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]);
banMemory.delete(ip);
failMemory.forEach((_, key) => { if (key.endsWith(':' + ip)) failMemory.delete(key); });
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
GROUP BY u.id
ORDER BY u.id`
);
res.json(rows.map(r => ({ ...r, branch_ids: r.branch_ids || [] })));
});
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');
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]);
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 = ['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'];
const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92' };
const result = {};
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
if (rm) {
result.photo_capture_width = rm[1];
result.photo_capture_height = rm[2];
} else {
result.photo_capture_width = '640';
result.photo_capture_height = '480';
}
const q = parseFloat(result.photo_capture_quality);
result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
return result;
});
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 === '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' });
}
}
}
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();
}
});
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 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;
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 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;
}
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),
}));
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),
}));
const entries = (data.entries || []).map(x => ({
id: reqInt(x.id),
student_name: reqStr(x.student_name, 150),
group_id: reqInt(x.group_id),
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),
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),
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 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 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);
}
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links };
}
app.get('/api/backup', requireAdmin, async (req, res) => {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try {
const [g, s, e, st, pf, br, us, ub, gp, ep] = 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'),
]);
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 };
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
const dir = path.join(__dirname, 'uploads');
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (fs.statSync(fp).isFile() && SAFE_NAME.test(f)) fs.copyFileSync(fp, path.join(staging, 'uploads', f));
}
}
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const outPath = path.join(os.tmpdir(), `whatido-backup-${stamp}.tar.gz`);
await tar.c({ gzip: true, file: outPath, cwd: staging }, ['data.json', 'uploads']);
const buf = fs.readFileSync(outPath);
fs.unlinkSync(outPath);
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', `attachment; filename="whatido-backup-${stamp}.tar.gz"`);
await logAudit(req, 'backup.download', {});
res.send(buf);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
});
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 });
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 students');
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.groups) {
await client.query(
'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end, branch_id) VALUES ($1,$2,$3,$4,$5,$6,$7)',
[x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end, x.branch_id]
);
}
for (const x of ndata.students) {
await client.query(
'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)',
[x.id, x.name, x.created_at, x.group_id]
);
}
for (const x of ndata.entries) {
await client.query(
'INSERT INTO entries (id, student_name, group_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
[x.id, x.student_name, x.group_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
);
}
for (const x of ndata.project_files) {
await client.query(
'INSERT INTO project_files (id, entry_id, token, path, name, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
[x.id, x.entry_id, x.token, x.path, x.name, 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.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 [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']) {
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');
fs.rmSync(staging, { recursive: true, force: true });
cleanupUpload(req);
throw e;
} finally {
client.release();
}
const dir = path.join(__dirname, 'uploads');
fs.mkdirSync(dir, { recursive: true });
if (legacyPhotos.length) {
for (const p of legacyPhotos) {
if (!p.path || !SAFE_NAME.test(p.path)) continue;
fs.writeFileSync(path.join(dir, p.path), Buffer.from(p.data, 'base64'));
}
} else {
const src = path.join(staging, 'uploads');
if (fs.existsSync(src)) {
for (const f of fs.readdirSync(src)) {
if (!SAFE_NAME.test(f)) continue;
const fp = path.join(src, f);
if (fs.statSync(fp).isFile()) fs.copyFileSync(fp, path.join(dir, f));
}
}
}
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 fp = path.join(__dirname, r.path);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' });
if (isImageName(r.name)) {
if (req.query.thumb) return sendImageThumb(res, fp);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
return res.sendFile(fp);
}
return res.download(fp, r.name);
});
app.get('/s/:token', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'share.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${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 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 } = 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: 'Назначение филиала — только для администратора' });
}
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
WHERE id = $6 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,
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 } = 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: 'Назначение филиала — только для администратора' });
}
try {
const { rows } = await pool.query(
'INSERT INTO groups (name, branch_id) VALUES ($1, $2) RETURNING *',
[name.trim(), isAdmin ? effectiveBranch : null]
);
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;
}
});
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 { rows } = await pool.query(
'SELECT photo_path FROM group_photos WHERE group_id = $1',
[req.params.id]
);
rows.forEach(r => safeUnlink(r.photo_path));
await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
await logAudit(req, 'group.delete', { 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]);
});
// --- 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]);
await logAudit(req, 'student.delete', { id: req.params.id });
invalidateStudents();
invalidateStats();
res.json({ ok: true });
});
// --- Student portfolio export (ZIP: HTML report + photos + files) ---
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[n] = c;
}
return table;
})();
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function dosDateTime(d = new Date()) {
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
date: ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
function createZipWriter() {
const parts = [];
const central = [];
let count = 0;
let offset = 0;
function buildEntry(nameBuf, method, crc, compressed, plain, dt) {
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(dt.time, 10);
local.writeUInt16LE(dt.date, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(compressed, 18);
local.writeUInt32LE(plain, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
const cen = Buffer.alloc(46);
cen.writeUInt32LE(0x02014b50, 0);
cen.writeUInt16LE(20, 4);
cen.writeUInt16LE(20, 6);
cen.writeUInt16LE(0x0800, 8);
cen.writeUInt16LE(method, 10);
cen.writeUInt16LE(dt.time, 12);
cen.writeUInt16LE(dt.date, 14);
cen.writeUInt32LE(crc, 16);
cen.writeUInt32LE(compressed, 20);
cen.writeUInt32LE(plain, 24);
cen.writeUInt16LE(nameBuf.length, 28);
cen.writeUInt16LE(0, 30);
cen.writeUInt16LE(0, 32);
cen.writeUInt16LE(0, 34);
cen.writeUInt16LE(0, 36);
cen.writeUInt32LE(0, 38);
cen.writeUInt32LE(offset, 42);
return { local, cen, nameBuf };
}
return {
addFile(name, data, d) {
const nameBuf = Buffer.from(name, 'utf8');
const dt = dosDateTime(d);
const crc = crc32(data);
const compressed = zlib.deflateRawSync(data, { level: 9 });
const e = buildEntry(nameBuf, 8, crc, compressed.length, data.length, dt);
const chunk = Buffer.concat([e.local, e.nameBuf, compressed]);
parts.push(chunk);
central.push(Buffer.concat([e.cen, e.nameBuf]));
offset += chunk.length;
count++;
},
addDir(name) {
const nameBuf = Buffer.from(String(name).replace(/\/?$/, '/'), 'utf8');
const dt = dosDateTime();
const e = buildEntry(nameBuf, 0, 0, 0, 0, dt);
const chunk = Buffer.concat([e.local, e.nameBuf]);
parts.push(chunk);
central.push(Buffer.concat([e.cen, e.nameBuf]));
offset += chunk.length;
count++;
},
toBuffer() {
const centralStart = offset;
const centralBuf = Buffer.concat(central);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(count, 8);
eocd.writeUInt16LE(count, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(centralStart, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...parts, centralBuf, eocd]);
},
};
}
function fmtLongDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
function fmtBytes(n) {
if (!Number.isFinite(n)) return '';
if (n < 1024) return n + ' Б';
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(/\.0$/, '') + ' КБ';
return (n / (1024 * 1024)).toFixed(1).replace(/\.0$/, '') + ' МБ';
}
function truncate(str, max) {
const s = String(str || '');
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function renderStudentReport(data, opts) {
const o = opts || {};
const showEntries = o.includeEntries !== false;
const showPhotos = o.includePhotos !== false;
const showFiles = o.includeFiles !== false;
const showCaptions = o.includeCaptions !== false;
const showDates = o.showDates !== false;
const { name, groups, entries, photos, files, generatedAt, period } = data;
const IMG_EXT = new Set(['JPG','JPEG','PNG','GIF','WEBP','BMP','AVIF','SVG','ICO','JFIF']);
const VID_EXT = new Set(['MP4','WEBM','MOV','M4V','OGV','MKV','MPEG','MPG','3GP','AVI']);
const gallery = [];
const galIdx = new Map();
for (const p of photos) { gallery.push({ type: 'image', src: 'photos/' + p.stored }); galIdx.set('photos/' + p.stored, gallery.length - 1); }
for (const f of files) {
const fn = String(f.original || f.saved || '');
const ext = fn.indexOf('.') >= 0 ? fn.split('.').pop().toUpperCase() : '';
if (VID_EXT.has(ext)) { gallery.push({ type: 'video', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); }
else if (IMG_EXT.has(ext)) { gallery.push({ type: 'image', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); }
}
const avatar = showPhotos && photos.length ? photos[0].stored : null;
const statsChips = [];
if (showEntries) statsChips.push(`<div class="chip"><b>${entries.length}</b><span>занятий</span></div>`);
if (showPhotos) statsChips.push(`<div class="chip"><b>${photos.length}</b><span>фотографий</span></div>`);
if (showFiles) statsChips.push(`<div class="chip"><b>${files.length}</b><span>файлов</span></div>`);
const photoCards = showPhotos ? photos.map(p => {
let caption = '';
if (showCaptions && p.caption) caption = truncate(p.caption, 120);
if (!caption && showDates && !p.caption) caption = fmtLongDate(p.createdAt);
const pg = galIdx.get('photos/' + p.stored);
return `
<figure class="ph" data-g="${pg}">
<img src="photos/${escapeHtml(p.stored)}" alt="${escapeHtml(name)} — фото" loading="lazy">
${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''}
</figure>`;
}).join('') : '';
const fileRows = showFiles ? files.map(f => {
const ext = f.original.indexOf('.') >= 0 ? f.original.split('.').pop().toUpperCase().slice(0, 8) : 'FILE';
const meta = showDates ? `${fmtLongDate(f.createdAt)} · ${fmtBytes(f.size)}` : fmtBytes(f.size);
const fg = galIdx.get('files/' + f.saved);
const gattr = fg !== undefined ? ` class="gfile" data-g="${fg}"` : ' target="_blank" rel="noopener"';
return `
<li>
<a href="files/${escapeHtml(f.saved)}"${gattr}>
<span class="badge">${escapeHtml(ext)}</span>
<span class="fname">${escapeHtml(f.original)}</span>
<span class="fmeta">${escapeHtml(meta)}</span>
</a>
</li>`;
}).join('') : '';
const photosByEntry = new Map();
for (const p of photos) {
if (!photosByEntry.has(p.entryId)) photosByEntry.set(p.entryId, []);
photosByEntry.get(p.entryId).push(p.stored);
}
const lessonRows = showEntries ? entries.map(e => {
const thumbs = showPhotos ? (photosByEntry.get(e.id) || []).slice(0, 4).map(t => `
<img class="tph" src="photos/${escapeHtml(t)}" alt="фото" data-g="${galIdx.get('photos/' + t)}" loading="lazy">`).join('') : '';
const entryFiles = showFiles ? files.filter(f => f.entryId === e.id) : [];
const fls = entryFiles.length ? `<div class="lfiles">${entryFiles.map(f => `<a href="files/${escapeHtml(f.saved)}" target="_blank" rel="noopener">${escapeHtml(f.original)}</a>`).join('')}</div>` : '';
const desc = e.description ? `<p class="ldesc">${escapeHtml(e.description)}</p>` : '';
const gr = e.group_name ? `<span class="lgroup">${escapeHtml(e.group_name)}</span>` : '';
const dt = showDates && e.created_at ? `<span class="ldate">${escapeHtml(fmtLongDate(e.created_at))}</span>` : '';
const head = dt + gr;
return `
<article class="lesson">
${head ? `<div class="lhead">${head}</div>` : ''}
${desc}
${thumbs ? `<div class="lthumbs">${thumbs}</div>` : ''}
${fls}
</article>`;
}).join('') : '';
const groupLine = groups.length ? escapeHtml(groups.join(' · ')) : '';
const genLabel = escapeHtml(fmtLongDate(generatedAt));
const metaBits = [];
if (groupLine) metaBits.push(groupLine);
if (period) metaBits.push(escapeHtml(period));
metaBits.push(`Сформировано ${genLabel}`);
const heroAvatar = avatar ? `<div class="avatar"><img src="photos/${escapeHtml(avatar)}" alt="${escapeHtml(name)}"></div>` : '';
const navItems = [];
if (showPhotos) navItems.push('<a class="navlink" href="#photos">Фотографии</a>');
if (showFiles) navItems.push('<a class="navlink" href="#files">Работы</a>');
if (showEntries) navItems.push('<a class="navlink" href="#lessons">Занятия</a>');
const nav = navItems.length >= 2 ? '<nav class="nav" id="topNav">' + navItems.join('') + '</nav>' : '';
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(name)} — портфолио</title>
<style>
:root{--cream:#FFF7EC;--ink:#2B2B33;--muted:#98919B;--yellow:#F7C90E;--teal:#14B8A6;--violet:#8B5CF6;--coral:#FF6B6B;--sky:#38BDF8;--line:#FFEAD2}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:"Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;background:var(--cream);color:var(--ink);line-height:1.55}
body::before{content:"";position:fixed;inset:0;z-index:-1;background:radial-gradient(520px 400px at 8% -2%,rgba(247,201,14,.18),transparent 62%),radial-gradient(620px 430px at 100% 3%,rgba(20,184,166,.15),transparent 58%)}
.wrap{max-width:960px;margin:0 auto;padding:28px 18px 58px}
.nav{position:sticky;top:12px;z-index:40;display:flex;justify-content:center;gap:8px;flex-wrap:wrap;background:rgba(255,255,255,.92);border:1px solid #F1E2C6;border-radius:999px;padding:8px 14px;box-shadow:0 12px 26px -16px rgba(20,30,50,.35);margin-top:26px}
.nav .navlink{text-decoration:none;color:var(--ink);background:#fff;border:1px solid #F1E2C6;border-radius:999px;padding:8px 18px;font-size:.9rem;font-weight:600;transition:all .15s}
.nav .navlink:hover{transform:translateY(-1px)}
.nav .navlink.on{background:var(--yellow);color:#231f12;border-color:transparent;box-shadow:0 4px 12px -4px rgba(247,201,14,.5)}
.hero{overflow:hidden;border-radius:34px;padding:36px 34px;background:linear-gradient(135deg,#FFF6DB,#FFEDBF 52%,#FFE19A);box-shadow:0 24px 50px -24px rgba(247,201,14,.5);position:relative}
.hero .deco{position:absolute;border-radius:50%;background:rgba(255,255,255,.55);pointer-events:none}
.hero .deco.d1{width:150px;height:150px;right:-40px;top:-46px}
.hero .deco.d2{width:80px;height:80px;left:40%;bottom:-30px;background:rgba(20,184,166,.14)}
.hero .deco.d3{width:44px;height:44px;left:-14px;top:30%}
.avatar-col{position:relative;display:flex;gap:24px;align-items:center}
.avatar{flex:0 0 auto;width:104px;height:104px;border-radius:50%;overflow:hidden;border:5px solid #fff;background:#fff;box-shadow:0 10px 24px -6px rgba(0,0,0,.26);transform:rotate(-4deg)}
.avatar img{width:100%;height:100%;object-fit:cover;display:block;border-radius:50%}
.avatar.aemp{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#F7C90E,#FFB34A);font-size:2.4rem;transform:rotate(-4deg)}
.kicker{font-size:.82rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#B07A00;margin-bottom:6px}
h1{font-size:clamp(1.65rem,4.4vw,2.35rem);font-weight:800;letter-spacing:-.02em;line-height:1.12;margin-bottom:6px}
.meta{font-size:.95rem;color:#7A6A33;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px}
.stats{display:flex;gap:12px;flex-wrap:wrap}
.chip{background:#fff;border:1px solid #F3DFC0;border-radius:18px;padding:10px 16px;display:flex;flex-direction:column;min-width:92px;box-shadow:0 8px 18px -12px rgba(20,30,50,.35)}
.chip b{font-size:1.5rem;line-height:1.05}
.chip span{font-size:.78rem;color:var(--muted)}
.stats .chip:nth-child(1) b{color:var(--coral)}
.stats .chip:nth-child(2) b{color:var(--teal)}
.stats .chip:nth-child(3) b{color:var(--violet)}
section{background:#fff;border:1px solid var(--line);border-radius:30px;padding:26px 26px 28px;margin-top:26px;box-shadow:0 16px 34px -22px rgba(20,30,50,.4)}
.p-sec{border-top:6px solid var(--teal)}
.f-sec{border-top:6px solid var(--violet)}
.l-sec{border-top:6px solid var(--coral)}
h2{font-size:1.25rem;font-weight:800;display:flex;align-items:center;gap:10px;margin-bottom:18px}
h2 .sico{width:38px;height:38px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;font-size:1.25rem;flex:0 0 auto}
.p-sec h2 .sico{background:#E3FAF7}
.f-sec h2 .sico{background:#EFE9FF}
.l-sec h2 .sico{background:#FFE9EE}
.empty{color:var(--muted);border:2px dashed #F0DDBE;border-radius:20px;padding:24px 18px;text-align:center;font-size:.95rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px}
.ph{background:#fff;border-radius:20px;overflow:hidden;cursor:zoom-in;border:3px solid #fff;box-shadow:0 10px 22px -14px rgba(20,30,50,.4);transition:transform .16s ease,box-shadow .16s ease}
.ph:hover{transform:translateY(-4px) rotate(-1deg);box-shadow:0 18px 30px -16px rgba(20,30,50,.45)}
.ph img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}
.ph figcaption{background:rgba(255,247,236,.92);padding:9px 12px;font-size:.82rem;color:#6A6155;line-height:1.35}
.filelist{list-style:none;display:flex;flex-direction:column;gap:9px}
.filelist li a{display:flex;align-items:center;gap:14px;background:#FDFAF4;border:1px solid #F1E2C6;border-radius:18px;padding:12px 16px;text-decoration:none;color:var(--ink);transition:transform .15s,box-shadow .15s}
.filelist li a:hover{transform:translateX(4px);box-shadow:0 10px 20px -12px rgba(20,30,50,.35)}
.badge{flex:0 0 auto;min-width:52px;text-align:center;font-size:.68rem;font-weight:800;letter-spacing:.04em;padding:6px 9px;border-radius:10px;background:linear-gradient(135deg,#F7C90E,#FFB34A);color:#231f12}
.fname{flex:1;min-width:0;font-weight:700;word-break:break-word}
.fmeta{flex:0 0 auto;color:var(--muted);font-size:.8rem;white-space:nowrap}
.gfile{cursor:pointer}
.lessons{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:stretch}
.lesson{background:#FFFCF6;border:1px solid #F3E4CA;border-left:6px solid var(--coral);border-radius:20px;padding:16px 20px}
.lhead{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
.ldate{background:var(--coral);color:#fff;border-radius:999px;padding:3px 12px;font-size:.74rem;font-weight:700}
.lgroup{background:rgba(20,184,166,.14);color:#0C9488;font-weight:700;border-radius:999px;padding:3px 12px;font-size:.74rem}
.ldesc{white-space:pre-wrap;color:#3A3733;font-size:.95rem}
.lthumbs{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.tph{width:80px;height:80px;object-fit:cover;border-radius:14px;cursor:zoom-in;border:2px solid #fff;box-shadow:0 6px 12px -8px rgba(20,30,50,.35)}
.lfiles{margin-top:10px;display:flex;flex-wrap:wrap;gap:8px}
.lfiles a{font-size:.8rem;color:#0C9488;background:rgba(20,184,166,.1);border-radius:999px;padding:5px 12px;text-decoration:none;word-break:break-word}
.lfiles a:hover{background:rgba(20,184,166,.18)}
footer{margin-top:44px;color:#B9B4BE;font-size:.82rem;text-align:center}
.lightbox{position:fixed;inset:0;background:rgba(22,20,40,.9);display:none;flex-direction:column;z-index:60;padding:16px 92px 48px;cursor:zoom-out;overflow:hidden}
.lightbox.open{display:flex}
.lightbox .lb-media{display:flex;align-items:center;justify-content:center;flex:1;min-height:0}
.lightbox .lb-media img,.lightbox .lb-media video{display:block;max-width:100%;max-height:100%;object-fit:contain;border-radius:14px}
.lightbox .lb-media video{background:#000;max-height:calc(100% - 16px)}
.lightbox .lb-btn{position:absolute;top:50%;transform:translateY(-50%);z-index:5;width:52px;height:52px;border-radius:50%;border:none;background:rgba(247,201,14,.94);color:#231f12;font-size:1.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 22px -8px rgba(0,0,0,.5)}
.lightbox .lb-btn:hover{background:#F7C90E}
.lightbox .lb-prev{left:20px}
.lightbox .lb-next{right:20px}
.lightbox .lb-count{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);background:rgba(22,20,40,.72);color:#fff;border-radius:999px;padding:6px 16px;font-size:.85rem;letter-spacing:.03em}
.lightbox .lb-close{position:absolute;top:16px;right:20px;z-index:6;width:44px;height:44px;border-radius:50%;border:none;background:rgba(22,20,40,.6);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 18px -8px rgba(0,0,0,.5)}
.lightbox .lb-close:hover{background:rgba(247,201,14,.92);color:#231f12}
@media print{body::before{display:none}.nav{display:none}.wrap{max-width:none;padding:0;-webkit-print-color-adjust:exact}body{background:#fff}.hero,section{box-shadow:none}section,.lesson,.ph{break-inside:avoid}}
@media (max-width:560px){.avatar-col{flex-direction:column;align-items:flex-start;gap:16px}.hero{padding:26px 22px}.grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}.stats{gap:8px}.fmeta{display:none}.chip{min-width:74px;padding:8px 12px}.lessons{grid-template-columns:1fr}.lightbox{padding:8px 6px 44px}.lb-prev{left:6px}.lb-next{right:6px}.lb-btn{width:44px;height:44px;font-size:1.4rem}.lb-close{width:38px;height:38px;top:10px;right:10px;font-size:1.15rem}}
</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<span class="deco d1"></span>
<span class="deco d2"></span>
<span class="deco d3"></span>
<div class="avatar-col">
${heroAvatar || '<div class="avatar aemp"><span>🎨</span></div>'}
<div>
<p class="kicker">✦ Портфолио ученика</p>
<h1>${escapeHtml(name)}</h1>
<p class="meta">${metaBits.join(' · ')}</p>
<div class="stats">
${statsChips.join('')}
</div>
</div>
</div>
</header>
${nav}
${showPhotos ? `<section id="photos" class="p-sec">
<h2><span class="sico">🏞️</span>Фотографии</h2>
${photoCards ? `<div class="grid">${photoCards}</div>` : '<p class="empty">🎈 Фотографий пока нет</p>'}
</section>` : ''}
${showFiles ? `<section id="files" class="f-sec">
<h2><span class="sico">📁</span>Работы и файлы</h2>
${fileRows ? `<ul class="filelist">${fileRows}</ul>` : '<p class="empty">🎈 Файлов пока нет</p>'}
</section>` : ''}
${showEntries ? `<section id="lessons" class="l-sec">
<h2><span class="sico">🎒</span>Журнал занятий</h2>
${lessonRows ? `<div class="lessons">${lessonRows}</div>` : '<p class="empty">🎈 Записей о занятиях пока нет</p>'}
</section>` : ''}
<footer>Сформировано ${genLabel} · WhatIDo</footer>
</div>
<div class="lightbox" id="lightbox">
<button type="button" class="lb-btn lb-prev" id="lbPrev" aria-label="Назад">&#10094;</button>
<div class="lb-media" id="lbMedia"></div>
<button type="button" class="lb-btn lb-next" id="lbNext" aria-label="Вперёд">&#10095;</button>
<button type="button" class="lb-close" id="lbClose" aria-label="Закрыть">&#10005;</button>
<div class="lb-count" id="lbCount"></div>
</div>
<script>
(function () {
var GAL = ${JSON.stringify(gallery)};
var box = document.getElementById('lightbox');
var media = document.getElementById('lbMedia');
var count = document.getElementById('lbCount');
var current = 0;
function isVid(m) { return m && m.type === 'video'; }
function render(i) {
if (!GAL.length) return;
var m = GAL[i];
current = i;
count.textContent = (i + 1) + ' / ' + GAL.length;
media.innerHTML = '';
if (isVid(m)) {
var v = document.createElement('video');
v.src = m.src;
v.controls = true;
v.autoplay = true;
media.appendChild(v);
} else {
var im = document.createElement('img');
im.src = m.src;
im.alt = '';
media.appendChild(im);
}
}
function open(i) { if (!GAL.length) return; render(i); box.classList.add('open'); }
function close() { box.classList.remove('open'); media.innerHTML = ''; }
function step(d) { if (!GAL.length) return; render((current + d + GAL.length) % GAL.length); }
document.addEventListener('click', function (e) {
var t = e.target.closest('[data-g]');
if (t) { e.preventDefault(); var gi = parseInt(t.getAttribute('data-g'), 10); if (!isNaN(gi) && gi >= 0 && GAL[gi]) open(gi); return; }
if (e.target === box) close();
});
document.getElementById('lbPrev').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); step(-1); });
document.getElementById('lbNext').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); step(1); });
document.getElementById('lbClose').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); close(); });
document.addEventListener('keydown', function (e) {
if (!box.classList.contains('open')) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowRight') step(1);
else if (e.key === 'ArrowLeft') step(-1);
});
var links = Array.prototype.slice.call(document.querySelectorAll('.navlink'));
function spy() {
if (!links.length) return;
var hit = null;
for (var i = 0; i < links.length; i++) {
var el = document.getElementById(links[i].getAttribute('href').slice(1));
if (el && el.getBoundingClientRect().top <= 140) hit = links[i];
}
for (var j = 0; j < links.length; j++) {
if (links[j] === hit) links[j].classList.add('on');
else links[j].classList.remove('on');
}
}
if (links.length) window.addEventListener('scroll', spy, { passive: true });
spy();
})();
</script>
</body>
</html>`;
}
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] = await Promise.all([
pool.query(`SELECT s.name, g.name AS group_name FROM students s LEFT JOIN groups g ON g.id = s.group_id WHERE s.name = $1`, [name]),
pool.query(`SELECT e.id, e.description, e.created_at, g.name AS group_name
FROM entries e JOIN groups g ON g.id = e.group_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
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
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
FROM project_files pf
JOIN entries e ON e.id = pf.entry_id
JOIN groups g ON g.id = e.group_id
WHERE ${condStr} AND pf.detached_at IS NULL
ORDER BY e.created_at DESC, pf.id DESC`, params),
]);
const entryRows = entriesRes.rows;
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
}
const zip = createZipWriter();
if (opts.includePhotos) zip.addDir('photos');
if (opts.includeFiles) zip.addDir('files');
const seenPhotos = new Set();
const photosBuilt = [];
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 src = path.join(UPLOADS_DIR, stored);
if (!fs.existsSync(src)) return;
const data = fs.readFileSync(src);
zip.addFile('photos/' + stored, data, new Date(p.created_at));
photosBuilt.push({ stored, caption: p.caption, createdAt: p.created_at, desc: p.description, entryId: p.entry_id });
}
if (opts.includePhotos) {
for (const p of photosRes.rows) addPhoto(p);
for (const m of mainsRes.rows) addPhoto(m);
photosBuilt.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
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 src = path.join(UPLOADS_DIR, stored);
if (!fs.existsSync(src)) continue;
const data = fs.readFileSync(src);
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, size: data.length, createdAt: f.created_at, entryId: f.entry_id });
}
}
const groupSet = new Set();
if (studRes.rows[0]?.group_name) groupSet.add(studRes.rows[0].group_name);
for (const e of entryRows) if (e.group_name) groupSet.add(e.group_name);
const groups = [...groupSet];
let period = null;
if (hasPeriod) {
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
}
const html = renderStudentReport({
name,
groups,
entries: entryRows,
photos: photosBuilt,
files: filesBuilt,
generatedAt: new Date(),
period,
}, opts);
zip.addFile('index.html', Buffer.from(html, '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, 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 (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 FROM entries e
JOIN groups g ON g.id = e.group_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 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: '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 = rows.map(r => {
let size = 0;
try { size = fs.statSync(path.join(__dirname, r.path)).size; } catch {}
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 = rows.map(r => {
let size = 0;
try { size = fs.statSync(path.join(__dirname, r.path)).size; } catch {}
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 fp = path.join(__dirname, r.path);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' });
if (isImageName(r.name)) {
if (req.query.thumb) return sendImageThumb(res, fp);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
return res.sendFile(fp);
}
return res.download(fp, r.name);
});
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 [entries, trash, groups, students, today] = 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${groupFilter.where}`, groupFilter.params),
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),
]);
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,
};
});
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
`);
function sumPhotoSizes(paths) {
let bytes = 0;
for (const p of paths) {
if (!p) continue;
const fp = path.join(__dirname, p);
try {
const st = fs.statSync(fp);
if (st.isFile()) bytes += st.size;
} catch {}
}
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 = sumPhotoSizes(groupPhotoSizes.rows.map(r => r.photo_path));
const entryPhotoBytes = sumPhotoSizes(entryPhotoSizes.rows.map(r => r.photo_path));
const uploadsDir = path.join(__dirname, 'uploads');
let uploadsSize = 0;
let uploadsCount = 0;
if (fs.existsSync(uploadsDir)) {
for (const f of fs.readdirSync(uploadsDir)) {
const fp = path.join(uploadsDir, f);
if (fs.statSync(fp).isFile()) {
uploadsCount++;
uploadsSize += fs.statSync(fp).size;
}
}
}
const diskInfo = getDiskInfo();
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,
},
disk: diskInfo,
};
});
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, 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 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, description, description_original, photo_path)
VALUES ($1, $2, $3, $3, $4) RETURNING *`,
[student_name.trim(), gid, 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 } = req.body;
const newPhotos = req.files || [];
for (const p of newPhotos) {
await convertPhoto(p);
}
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),
description_ai = CASE WHEN $3 IS NULL THEN description_ai ELSE NULL END,
ai_status = CASE WHEN $3 IS NULL THEN ai_status ELSE 'pending' END,
ai_error = CASE WHEN $3 IS NULL THEN ai_error ELSE NULL END,
ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END
WHERE id = $4 RETURNING *`,
[
student_name ? student_name.trim() : null,
group_id || null,
description ? description.trim() : null,
req.params.id,
]
);
if (!rows.length) {
newPhotos.forEach(p => safeUnlink(p.path));
return res.status(404).json({ error: 'Not found' });
}
if (description && entryAutoChecker) entryAutoChecker.notify();
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 });
});
app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, 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: 'У записи нет фото' });
}
if (!req.file) return res.status(400).json({ error: 'Нет файла' });
await convertPhoto(req.file);
const newPath = `/uploads/${req.file.filename}`;
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]);
safeUnlink(oldPath);
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await logAudit(req, 'entry.photo.enhance', { entry_id: req.params.id, old_path: oldPath, new_path: newPath });
invalidateEntries();
res.json({ ok: true, photo_path: newPath });
} catch (e) {
if (req.file) safeUnlink(req.file.path);
console.error('PUT /api/entries/:id/photo/enhance:', 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 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: 'Нет доступа к этой записи' });
}
}
await removeEntryFiles(req.params.id);
await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]);
await logAudit(req, 'entry.permanent-delete', { 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/trash', requireAuth, async (req, res) => {
const { limit, offset } = req.query;
const bw = branchScope(req.user);
const scoped = req.user.role !== 'admin';
let where = ' WHERE e.deleted_at IS NOT NULL';
const params = [];
if (scoped) {
if (bw.ids.length) {
where += ` AND g.branch_id IN (${bw.ids.map(id => `$${params.push(id)}`).join(',')})`;
} else {
where += ' AND 1 = 0';
}
}
console.log(`[TRASH] User ${req.user.id} (${req.user.username}) role=${req.user.role} branch_ids=${JSON.stringify(bw.ids)} scoped=${scoped} where=${where} params=${JSON.stringify(params)}`);
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 FROM entries e
JOIN groups g ON g.id = e.group_id
${where} ORDER BY e.deleted_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);
console.log(`[TRASH] Found ${rows.length} entries for user ${req.user.id}`);
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] || []; });
res.json({ entries: rows, total });
});
app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => {
const { rows } = await pool.query(
`SELECT photo_path AS p FROM entries WHERE deleted_at IS NOT NULL
UNION ALL
SELECT pf.path AS p FROM project_files pf
JOIN entries e ON e.id = pf.entry_id WHERE e.deleted_at IS NOT NULL`
);
rows.forEach(r => safeUnlink(r.p));
const d = await pool.query('DELETE FROM entries WHERE deleted_at IS NOT NULL');
invalidateEntries();
invalidateStats();
res.json({ ok: true, deleted: d.rowCount });
});
// --- 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/');
}
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); });
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 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 ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
entryAutoChecker.start();
console.log('AI auto-check worker started');
})();