Files
WhatIDo/server.js
T
dev e6291a0235 feat(modules): module topics for student form, admin CRUD with pagination
- db: modules table (name, lessons_count) + entries.module_id (ON DELETE SET NULL), migration + idempotent startup ensure
- api: GET /api/modules (public, search + limit/offset, entries_count), POST/PUT/DELETE (admin, audit-logged)
- entries: accept/validate module_id on create/update, return module_name, module_id filter
- backup/restore: include modules and entries.module_id
- student form: required module select, hidden while no modules exist
- admin: modules.html + js/modules.js list with pagination, search, create/edit/delete modal
- journal: module filter, module select in edit modal, module badge, CSV column
2026-09-18 18:56:42 +03:00

4837 lines
210 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const { Pool, types } = require('pg');
const multer = require('multer');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert');
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
const 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 ORIGINALS_DIR = path.join(__dirname, 'uploads', '.originals');
const THUMB_WIDTH = 480;
let sharp = null;
try { sharp = require('sharp'); } catch {}
if (sharp) {
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
}
try { fs.mkdirSync(ORIGINALS_DIR, { recursive: true }); } catch {}
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.get('/uploads/.originals/:name', fileLimiter, async (req, res) => {
const name = req.params.name;
if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).end();
const fp = path.join(ORIGINALS_DIR, 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 }, { rows: pendingJobs }] = await Promise.all([
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
pool.query('SELECT path AS p FROM project_files'),
pool.query('SELECT photo_path AS p FROM group_photos'),
pool.query('SELECT photo_path AS p FROM entry_photos'),
pool.query(`SELECT after_path AS p FROM photo_jobs WHERE status = 'done' AND applied = false AND after_path IS NOT NULL`),
]);
const refs = new Set();
[...photos, ...files, ...gphotos, ...ephotos, ...pendingJobs].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 ensureModulesTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query('ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL');
await pool.query('CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id)');
}
async function ensureEntryPhotosTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
caption TEXT,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
}
async function ensurePhotoOriginalColumn() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
}
async function ensureEntryAiColumns() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending'`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
await pool.query(`
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
`);
await pool.query(`DROP TRIGGER IF EXISTS trg_entries_notify ON entries`);
await pool.query(`CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries FOR EACH ROW EXECUTE FUNCTION notify_entries_changed()`);
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
const marker = await getSetting('ai_autocheck_migrated', '');
if (marker !== '1') {
await pool.query(`UPDATE entries SET description_original = description WHERE description_original IS NULL`);
await pool.query(`UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending'`);
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1') ON CONFLICT (key) DO UPDATE SET value = '1'`);
}
}
async function ensurePhotoJobsTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
action VARCHAR(20) NOT NULL DEFAULT 'ai',
params JSONB,
before_path VARCHAR(255),
after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ
)`);
await pool.query(`ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending'`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id)`);
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
}
async function ensureUserTables() {
await pool.query(`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(150),
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query(`CREATE TABLE IF NOT EXISTS user_branches (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, branch_id)
)`);
await pool.query(`CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`);
await pool.query('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL');
}
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', 'photo_enhance_engine'];
const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto' };
const result = {};
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
if (rm) {
result.photo_capture_width = rm[1];
result.photo_capture_height = rm[2];
} else {
result.photo_capture_width = '640';
result.photo_capture_height = '480';
}
const q = parseFloat(result.photo_capture_quality);
result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
result.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
return result;
});
res.json(out);
});
app.put('/api/settings', requireAdmin, async (req, res) => {
const { settings } = req.body;
if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' });
for (const [key, value] of Object.entries(settings)) {
if (key === 'spam_interval_min') {
const n = parseInt(String(value), 10);
if (!Number.isFinite(n) || n < 1 || n > 10080) {
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' });
}
}
if (key === 'photo_capture_resolution') {
if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) {
return res.status(400).json({ error: 'photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480' });
}
}
if (key === 'photo_capture_quality') {
const q = parseFloat(String(value));
if (!Number.isFinite(q) || q < 0.5 || q > 1) {
return res.status(400).json({ error: 'photo_capture_quality должен быть числом от 0.5 до 1' });
}
}
if (key === 'photo_enhance_engine' && !['auto', 'server', 'client'].includes(String(value))) {
return res.status(400).json({ error: 'photo_enhance_engine должен быть auto, server или client' });
}
}
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 PHOTO_AI_URL = process.env.PHOTO_AI_URL || '';
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
let entryAutoChecker = null;
let photoWorker = null;
async function getAiPrompt() {
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT);
return prompt;
}
async function getAiProfiles() {
try {
const raw = await getSetting('ai_profiles', '[]');
const list = JSON.parse(raw || '[]');
return Array.isArray(list) ? list.filter(p => p && p.id && p.base_url && p.model) : [];
} catch (e) {
return [];
}
}
async function getActiveAiProfile() {
const active = await getSetting('ai_active_profile', 'native');
if (!active || active === 'native') return null;
const profiles = await getAiProfiles();
return profiles.find(p => p.id === active) || null;
}
function normalizeOpenAiBase(base) {
let b = String(base || '').trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(b)) return null;
if (!/\/v1$/i.test(b)) b += '/v1';
return b;
}
async function aiCorrectText(text) {
const profile = await getActiveAiProfile();
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
let model = AI_MODEL;
const headers = { 'Content-Type': 'application/json' };
let maxTokens = Math.min(256, Math.max(128, text.length + 64));
if (profile) {
const base = normalizeOpenAiBase(profile.base_url);
if (!base) throw new Error('Некорректный base_url профиля ИИ');
url = `${base}/chat/completions`;
model = profile.model;
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
}
try {
const systemPrompt = await getAiPrompt();
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
],
temperature: 0.1,
max_tokens: maxTokens
})
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
return data.choices?.[0]?.message?.content?.trim() || text;
} catch (e) {
console.error('AI correct error:', e);
throw e;
}
}
const BACKUP_UPLOAD_LIMIT_MB = parseInt(process.env.BACKUP_UPLOAD_LIMIT_MB || '500', 10);
const uploadBackup = multer({
storage: multer.diskStorage({
destination: (_, __, cb) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-up-'));
cb(null, dir);
},
filename: (_, file, cb) => {
const ext = path.extname(file.originalname) || '.bin';
cb(null, `backup-${Date.now()}${ext}`);
},
}),
limits: { fileSize: BACKUP_UPLOAD_LIMIT_MB * 1024 * 1024 },
});
const SAFE_NAME = /^[\w,.()-]+$/;
function isSafeUploadPath(p) {
if (typeof p !== 'string' || !p.startsWith('/uploads/')) return false;
const name = p.slice('/uploads/'.length);
return name !== '' && !name.includes('/') && !name.includes('..') && SAFE_NAME.test(name);
}
function reqInt(v) {
const n = Number(v);
if (!Number.isInteger(n)) throw new Error('Invalid integer');
return n;
}
function optInt(v, lo = -Infinity, hi = Infinity) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < lo || n > hi) throw new Error('Invalid integer');
return n;
}
function reqStr(v, max) {
if (typeof v !== 'string') throw new Error('Invalid string');
const s = v.trim();
if (!s || s.length > max) throw new Error('Invalid string length');
return s;
}
function optStr(v, max) {
if (v === null || v === undefined) return null;
return reqStr(v, max);
}
function optTs(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp');
return v;
}
function optTime(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{2}:\d{2}(:\d{2})?$/.test(v)) throw new Error('Invalid time');
return v;
}
function optDate(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error('Invalid date');
return v;
}
function optBool(v) {
if (v === null || v === undefined) return null;
return !!v;
}
function reqToken(v) {
if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token');
return v;
}
function reqUploadPath(v, max) {
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid path');
if (!isSafeUploadPath(v)) throw new Error('Invalid upload path');
return v;
}
function optUploadPath(v, max) {
if (v === null || v === undefined) return null;
return reqUploadPath(v, max);
}
const 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),
module_id: optInt(x.module_id, 0, 2147483647),
description: reqStr(x.description, 100000),
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
description_ai: optAiText(x.description_ai, 100000),
ai_status: reqAiStatus(x.ai_status, 'skipped'),
ai_checked_at: optTs(x.ai_checked_at),
ai_error: optAiText(x.ai_error, 500),
photo_path: optUploadPath(x.photo_path, 255),
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 modules = (data.modules || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 200),
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
created_at: optTs(x.created_at),
}));
const entry_photos = (data.entry_photos || []).map(x => ({
id: reqInt(x.id),
entry_id: reqInt(x.entry_id),
photo_path: reqUploadPath(x.photo_path, 255),
caption: optStr(x.caption, 10000),
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
created_at: optTs(x.created_at),
}));
const 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, modules };
}
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, md] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'),
pool.query('SELECT key, value FROM settings'),
pool.query('SELECT * FROM project_files ORDER BY id'),
pool.query('SELECT * FROM branches ORDER BY id'),
pool.query('SELECT * FROM users ORDER BY id'),
pool.query('SELECT * FROM user_branches ORDER BY user_id, branch_id'),
pool.query('SELECT * FROM group_photos ORDER BY id'),
pool.query('SELECT * FROM entry_photos ORDER BY id'),
pool.query('SELECT * FROM modules ORDER BY id'),
]);
const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; });
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows };
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 modules');
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.modules) {
await client.query(
'INSERT INTO modules (id, name, lessons_count, created_at) VALUES ($1,$2,$3,$4)',
[x.id, x.name, x.lessons_count, x.created_at]
);
}
for (const x of ndata.entries) {
await client.query(
'INSERT INTO entries (id, student_name, group_id, module_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)',
[x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.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', 'modules']) {
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'));
});
app.get('/r/:token', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'report.html'));
});
// --- Groups CRUD ---
app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
const rows = await cacheWrap('groups:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
const { rows } = await pool.query(
`SELECT g.*,
COALESCE(g.cover_path,
(SELECT photo_path FROM group_photos
WHERE group_id = g.id
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
LIMIT 1)) AS cover_path,
b.name AS branch_name
FROM groups g
LEFT JOIN branches b ON b.id = g.branch_id
WHERE 1=1${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]);
});
// --- Modules (темы модулей) ---
app.get('/api/modules', apiLimiter, async (req, res) => {
const { limit, offset, search } = req.query;
const conditions = [];
const params = [];
if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); }
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params);
const total = crows[0].n;
let q = `SELECT m.*, count(e.id)::int AS entries_count
FROM modules m
LEFT JOIN entries e ON e.module_id = m.id${where}
GROUP BY m.id ORDER BY m.id`;
const qparams = params.slice();
const lim = parseInt(limit, 10);
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
const off = parseInt(offset, 10);
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
const { rows } = await pool.query(q, qparams);
res.json({ modules: rows, total });
});
function parseLessonsCount(v) {
if (v === undefined || v === null || v === '') return 0;
const n = Number(v);
if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error('Количество занятий — целое число от 0 до 10000');
return n;
}
app.post('/api/modules', requireAdmin, async (req, res) => {
const { name, lessons_count } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
let lessons;
try { lessons = parseLessonsCount(lessons_count); }
catch (e) { return res.status(400).json({ error: e.message }); }
try {
const { rows } = await pool.query(
'INSERT INTO modules (name, lessons_count) VALUES ($1, $2) RETURNING *',
[name.trim(), lessons]
);
await logAudit(req, 'module.create', { id: rows[0].id, name: name.trim(), lessons_count: lessons });
res.status(201).json(rows[0]);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
throw e;
}
});
app.put('/api/modules/:id', requireAdmin, async (req, res) => {
const { name, lessons_count } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
let lessons;
try { lessons = parseLessonsCount(lessons_count); }
catch (e) { return res.status(400).json({ error: e.message }); }
try {
const { rows } = await pool.query(
'UPDATE modules SET name = $1, lessons_count = $2 WHERE id = $3 RETURNING *',
[name.trim(), lessons, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
await logAudit(req, 'module.update', { id: req.params.id, name: name.trim(), lessons_count: lessons });
res.json(rows[0]);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
throw e;
}
});
app.delete('/api/modules/:id', requireAdmin, async (req, res) => {
const { rows } = await pool.query('DELETE FROM modules WHERE id = $1 RETURNING id', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
await logAudit(req, 'module.delete', { id: req.params.id });
res.json({ ok: true });
});
// --- 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 plural = (n, one, few, many) => {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
};
const counters = [];
if (showEntries) counters.push(`<div class="counter c1"><b>${entries.length}</b><span>${plural(entries.length, 'занятие', 'занятия', 'занятий')}</span></div>`);
if (showPhotos) counters.push(`<div class="counter c2"><b>${photos.length}</b><span>${plural(photos.length, 'фотография', 'фотографии', 'фотографий')}</span></div>`);
if (showFiles) counters.push(`<div class="counter c3"><b>${files.length}</b><span>${plural(files.length, 'работа', 'работы', 'работ')}</span></div>`);
counters.push(`<div class="counter c4"><b>${groups.length}</b><span>${plural(groups.length, 'группа', 'группы', 'групп')}</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 projectCards = showFiles ? files.map((f, i) => {
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 ? ` data-g="${fg}"` : ' target="_blank" rel="noopener"';
return `
<article class="proj">
<a class="proj-link${fg !== undefined ? ' gfile' : ''}" href="files/${escapeHtml(f.saved)}"${gattr}>
<span class="pnum">${String(i + 1).padStart(2, '0')}</span>
<span class="pbody">
<span class="pname">${escapeHtml(f.original)}</span>
<span class="pmeta"><span class="badge">${escapeHtml(ext)}</span><span class="pdate">${escapeHtml(meta)}</span></span>
</span>
<span class="parrow">&#8599;</span>
</a>
</article>`;
}).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>` : '';
return `
<article class="lesson">
${dt ? `<div class="ldate">${escapeHtml(fmtLongDate(e.created_at))}</div>` : ''}
<div class="lmain">
${gr ? `<span class="lgroup">${escapeHtml(e.group_name)}</span>` : ''}
${desc}
${thumbs ? `<div class="lthumbs">${thumbs}</div>` : ''}
${fls}
</div>
</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 = [];
navItems.push('<a class="navlink" href="#overview">Обзор</a>');
if (showPhotos) navItems.push('<a class="navlink" href="#photos">Фотографии</a>');
if (showFiles) navItems.push('<a class="navlink" href="#works">Работы</a>');
if (showEntries) navItems.push('<a class="navlink" href="#lessons">Занятия</a>');
const nav = navItems.join('');
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{--bg:#0c0c0c;--bg2:#141414;--ink:#ffffff;--muted:#8a8a8a;--line:#232323;--accent:#3290ff}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font-family:Inter,"Inter Tight","Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;background:var(--bg);color:var(--ink);line-height:1.45;-webkit-font-smoothing:antialiased}
.wrap{max-width:1080px;margin:0 auto;padding:0 32px}
.topnav{position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:24px;background:rgba(12,12,12,.85);backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:18px 32px}
.topnav .brand{font-size:.8rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;text-decoration:none;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.topnav .brand::before{content:"";display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--accent);margin-right:10px}
.topnav .links{display:flex;gap:26px;flex-wrap:wrap;justify-content:flex-end}
.topnav .navlink{text-decoration:none;color:var(--muted);font-size:.78rem;font-weight:500;letter-spacing:.1em;text-transform:uppercase;position:relative;padding-bottom:4px;transition:color .15s}
.topnav .navlink:hover{color:var(--ink)}
.topnav .navlink.on{color:var(--ink)}
.topnav .navlink.on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--accent)}
.hero{padding:88px 0 72px;border-bottom:1px solid var(--line)}
.hero-head{display:flex;align-items:center;gap:28px}
.avatar{flex:0 0 auto;width:88px;height:88px;border-radius:50%;overflow:hidden;border:1px solid var(--line);background:var(--bg2)}
.avatar img{width:100%;height:100%;object-fit:cover;display:block}
.avatar.aemp{display:flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;font-size:2rem}
.kicker{font-size:.78rem;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:12px;margin-bottom:14px}
.kicker::before{content:"[";color:var(--accent);font-weight:700}
.kicker::after{content:"]";color:var(--accent);font-weight:700}
h1{font-size:clamp(2.6rem,6.5vw,4.4rem);font-weight:800;letter-spacing:-.04em;line-height:1.02}
.meta{font-size:.92rem;color:var(--muted);margin-top:12px}
.cta-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:36px}
.btn{display:inline-block;border-radius:999px;padding:13px 28px;font-size:.92rem;font-weight:600;letter-spacing:-.01em;text-decoration:none;transition:opacity .15s,background .15s,color .15s;border:1px solid transparent}
.btn-solid{background:var(--accent);color:#fff}
.btn-solid:hover{opacity:.85}
.btn-line{border-color:var(--line);color:var(--ink)}
.btn-line:hover{border-color:#3a3a3a;background:var(--bg2)}
.sec{padding:72px 0;border-bottom:1px solid var(--line)}
.sec-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:36px}
.sec-head h2{font-size:clamp(1.6rem,3.2vw,2.2rem);font-weight:700;letter-spacing:-.03em;line-height:1.05}
.sec-head .count{color:var(--muted);font-size:.9rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.sec-head .count::before{content:"[";color:var(--accent)}
.sec-head .count::after{content:"]";color:var(--accent)}
.note{color:var(--muted);font-size:.95rem}
.counters{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:24px}
.counter{padding:10px 26px;border-left:1px solid var(--line)}
.counter:first-child{border-left:none;padding-left:0}
.counter b{display:block;font-size:clamp(2.8rem,5.5vw,4.2rem);font-weight:800;letter-spacing:-.04em;line-height:1;font-variant-numeric:tabular-nums;color:var(--accent)}
.counter span{display:block;margin-top:12px;font-size:.85rem;color:var(--muted)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
.ph{cursor:zoom-in}
.ph img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;border-radius:16px;border:1px solid var(--line);transition:transform .3s}
.ph:hover img{transform:scale(1.02)}
.ph figcaption{padding:10px 2px 0;font-size:.85rem;color:var(--muted)}
.projects{display:flex;flex-direction:column}
.proj{border-top:1px solid var(--line)}
.proj:last-child{border-bottom:1px solid var(--line)}
.proj-link{display:grid;grid-template-columns:80px 1fr auto auto;gap:22px;align-items:center;padding:26px 2px;text-decoration:none;color:var(--ink)}
.pnum{font-size:2rem;font-weight:800;letter-spacing:-.04em;line-height:1;color:#2b2b2b;font-variant-numeric:tabular-nums;transition:color .15s}
.pbody{min-width:0;display:flex;flex-direction:column;gap:6px}
.pname{font-size:1.2rem;font-weight:600;letter-spacing:-.02em;word-break:break-word;transition:color .15s}
.pmeta{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.badge{flex:0 0 auto;font-size:.66rem;font-weight:600;letter-spacing:.08em;border:1px solid var(--line);border-radius:999px;padding:4px 12px;color:var(--muted)}
.pdate{color:var(--muted);font-size:.85rem;white-space:nowrap}
.parrow{color:var(--muted);font-size:1.05rem;transition:transform .15s,color .15s}
.proj-link:hover .pnum{color:var(--accent)}
.proj-link:hover .pname{color:var(--accent)}
.proj-link:hover .parrow{transform:translate(2px,-2px);color:var(--accent)}
.gfile{cursor:pointer}
.lessons{display:flex;flex-direction:column}
.lesson{display:grid;grid-template-columns:150px 1fr;gap:26px;padding:28px 2px;border-top:1px solid var(--line)}
.lesson:last-child{border-bottom:1px solid var(--line)}
.ldate{color:var(--accent);font-size:.85rem;font-weight:600;font-variant-numeric:tabular-nums;padding-top:4px}
.lmain{min-width:0}
.lgroup{display:inline-block;font-size:.7rem;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);margin-bottom:10px}
.ldesc{white-space:pre-wrap;font-size:1rem;letter-spacing:-.01em;max-width:660px;color:#b5b5b5}
.lthumbs{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
.tph{width:92px;height:92px;object-fit:cover;border-radius:14px;border:1px solid var(--line);cursor:zoom-in;transition:transform .2s}
.tph:hover{transform:scale(1.04)}
.lfiles{margin-top:14px;display:flex;flex-wrap:wrap;gap:10px}
.lfiles a{font-size:.88rem;color:#cfcfcf;text-decoration:none;border:1px solid var(--line);border-radius:12px;padding:8px 14px;word-break:break-word;transition:border-color .15s}
.lfiles a:hover{border-color:var(--accent);color:#fff}
.totop{position:fixed;right:22px;bottom:22px;z-index:45;width:46px;height:46px;border-radius:50%;border:1px solid var(--line);background:var(--bg2);color:var(--ink);font-size:1.1rem;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .2s,background .15s}
.totop.show{opacity:1;pointer-events:auto}
.totop:hover{background:var(--accent);border-color:var(--accent);color:#fff}
footer{padding:36px 0 30px;display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;color:var(--muted);font-size:.85rem}
.lightbox{position:fixed;inset:0;background:rgba(0,0,0,.94);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:1px solid var(--line);background:var(--bg2);color:#fff;font-size:1.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s}
.lightbox .lb-btn:hover{background:var(--accent);border-color:var(--accent)}
.lightbox .lb-prev{left:20px}
.lightbox .lb-next{right:20px}
.lightbox .lb-count{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);background:rgba(20,20,20,.85);color:#fff;border:1px solid var(--line);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:1px solid var(--line);background:rgba(20,20,20,.8);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s}
.lightbox .lb-close:hover{background:var(--accent);border-color:var(--accent)}
@media print{.topnav{display:none}.cta-row{display:none}.totop{display:none}.wrap{max-width:none;padding:0}body{background:#fff;color:#111}.sec,.proj,.lesson{break-inside:avoid}}
@media (max-width:900px){.lesson{grid-template-columns:1fr;gap:8px;padding:24px 0}.proj-link{grid-template-columns:56px 1fr auto auto;gap:14px}.pdate{display:none}.hero-head{flex-direction:column;align-items:flex-start;gap:18px}.counters{grid-template-columns:repeat(2,1fr)}.counter{padding:14px 0 4px 16px}.counter:nth-child(odd){border-left:none;padding-left:0}.counter b{font-size:2.4rem}}
@media (max-width:560px){.topnav{padding:14px 18px}.topnav .links{gap:14px}.wrap{padding:0 18px}.hero{padding:44px 0 40px}.proj-link{grid-template-columns:44px 1fr auto;padding:18px 0}.pnum{font-size:1.2rem}.pname{font-size:1rem}.grid{grid-template-columns:repeat(auto-fill,minmax(140px,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>
<nav class="topnav" id="topNav">
<a class="brand" href="#top">✦ ${escapeHtml(name)}</a>
<div class="links">${nav}</div>
</nav>
<div class="wrap" id="top">
<header class="hero" id="overview">
<div class="hero-head">
${heroAvatar || '<div class="avatar aemp"><span>🎨</span></div>'}
<div>
<p class="kicker">Портфолио ученика</p>
<h1>${escapeHtml(name)}</h1>
<p class="meta">${metaBits.join(' · ')}</p>
</div>
</div>
<div class="cta-row">
${showFiles ? '<a class="btn btn-solid" href="#works">Смотреть работы</a>' : ''}
${showEntries ? '<a class="btn btn-line" href="#lessons">Хронология занятий</a>' : ''}
</div>
</header>
<section id="overview-stats" class="sec">
<div class="sec-head"><h2>Обзор</h2></div>
<div class="counters">
${counters.join('')}
</div>
</section>
${showPhotos ? `<section id="photos" class="sec">
<div class="sec-head"><h2>Фотографии</h2><span class="count">[${photos.length}]</span></div>
${photoCards ? `<div class="grid">${photoCards}</div>` : '<p class="note">Фотографий пока нет.</p>'}
</section>` : ''}
${showFiles ? `<section id="works" class="sec">
<div class="sec-head"><h2>Проекты и работы</h2><span class="count">[${files.length}]</span></div>
${projectCards ? `<div class="projects">${projectCards}</div>` : '<p class="note">Работ пока нет.</p>'}
</section>` : ''}
${showEntries ? `<section id="lessons" class="sec">
<div class="sec-head"><h2>Журнал занятий</h2><span class="count">[${entries.length}]</span></div>
${lessonRows ? `<div class="lessons">${lessonRows}</div>` : '<p class="note">Записей о занятиях пока нет.</p>'}
</section>` : ''}
<footer>
<span>Сформировано ${genLabel}</span>
<span>WhatIDo · Портфолио</span>
</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();
var toTop = document.getElementById('toTop');
function toTopSpy() {
if (window.scrollY > 420) toTop.classList.add('show');
else toTop.classList.remove('show');
}
toTop.addEventListener('click', function () { window.scrollTo({ top: 0, behavior: 'smooth' }); });
window.addEventListener('scroll', toTopSpy, { passive: true });
toTopSpy();
})();
</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, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
const conditions = [];
const params = [];
if (deleted === '1') conditions.push('e.deleted_at IS NOT NULL');
else conditions.push('e.deleted_at IS NULL');
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
if (module_id) { params.push(module_id); conditions.push(`e.module_id = $${params.length}`); }
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
if (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); }
if (req.user.role !== 'admin') {
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
const s = branchScope(req.user);
if (!s.ids.length) {
conditions.push('1 = 0');
} else {
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
conditions.push(`g.branch_id IN (${ph})`);
}
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const { rows: crows } = await pool.query(
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`,
params
);
const total = crows[0].n;
let q = `SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
JOIN groups g ON g.id = e.group_id
LEFT JOIN modules m ON m.id = e.module_id${where} ORDER BY e.created_at DESC`;
const qparams = params.slice();
const lim = parseInt(limit, 10);
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
const off = parseInt(offset, 10);
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
const { rows } = await pool.query(q, qparams);
let files;
if (rows.length) {
const ids = rows.map(r => r.id);
const fRes = await pool.query(
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
[ids]
);
files = {};
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
} else {
files = {};
}
rows.forEach(r => { r.files = files[r.id] || []; });
if (rows.length) {
const ids = rows.map(r => r.id);
const pRes = await pool.query(
'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = ANY($1) ORDER BY sort_order, id',
[ids]
);
const photos = {};
pRes.rows.forEach(p => { (photos[p.entry_id] = photos[p.entry_id] || []).push(p); });
rows.forEach(r => { r.photos = photos[r.id] || []; });
} else {
rows.forEach(r => { r.photos = []; });
}
res.json({ entries: rows, total });
});
app.get('/api/entries/:id', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query(
`SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
JOIN groups g ON g.id = e.group_id
LEFT JOIN modules m ON m.id = e.module_id
WHERE e.id = $1`,
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
const entry = rows[0];
const fRes = await pool.query(
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
[entry.id]
);
entry.files = fRes.rows;
const pRes = await pool.query(
'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id',
[entry.id]
);
entry.photos = pRes.rows;
res.json(entry);
});
app.get('/api/entries/:id/files', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const { rows } = await pool.query(`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
if (rows[0].group_id && !(await groupBelongsToBranches(req.user, rows[0].group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
}
const { rows } = await pool.query(
'SELECT id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
[req.params.id]
);
res.json(rows);
});
const entryFilesUpload = adminUpload.array('files', 10);
app.post('/api/entries/:id/files', requireAuth, (req, res, next) => {
entryFilesUpload(req, res, (err) => {
if (!err) return next();
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
return res.status(400).json({ error: 'Недопустимый файл' });
});
}, async (req, res) => {
const entryId = req.params.id;
const files = req.files || [];
if (!files.length) return res.status(400).json({ error: 'Файлы не выбраны' });
if (req.user.role !== 'admin') {
const { rows } = await pool.query(`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`, [entryId]);
if (!rows.length) {
files.forEach(removeUpload);
return res.status(404).json({ error: 'Запись не найдена' });
}
if (rows[0].group_id && !(await groupBelongsToBranches(req.user, rows[0].group_id))) {
files.forEach(removeUpload);
return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
}
const entryCheck = await pool.query('SELECT id FROM entries WHERE id = $1', [entryId]);
if (!entryCheck.rows.length) {
files.forEach(removeUpload);
return res.status(404).json({ error: 'Запись не найдена' });
}
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
files.forEach(removeUpload);
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const f of files) {
const token = crypto.randomBytes(16).toString('hex');
await client.query(
'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)',
[entryId, token, `/uploads/${f.filename}`, f.originalname]
);
}
await client.query('COMMIT');
invalidateShare();
invalidateEntries();
res.status(201).json({ ok: true, count: files.length });
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
files.forEach(removeUpload);
console.error('POST /api/entries/:id/files:', e);
res.status(500).json({ error: e.message });
} finally {
client.release();
}
});
function isImageName(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i.test(name || '');
}
app.get('/api/files', requireAuth, async (req, res) => {
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
const conditions = [];
const params = [];
conditions.push('e.deleted_at IS NULL');
if (search) { params.push(`%${search}%`); conditions.push(`pf.name ILIKE $${params.length}`); }
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
if (req.user.role !== 'admin') {
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
return res.status(403).json({ error: 'Нет доступа к этой группе' });
}
const s = branchScope(req.user);
if (!s.ids.length) {
conditions.push('1 = 0');
} else {
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
conditions.push(`g.branch_id IN (${ph})`);
}
}
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const { rows: crows } = await pool.query(
`SELECT count(*)::int AS n FROM project_files pf JOIN entries e ON e.id = pf.entry_id JOIN groups g ON g.id = e.group_id${where}`,
params
);
const total = crows[0].n;
let q = `SELECT pf.id, pf.token, pf.name, pf.path, e.student_name, e.created_at, g.name AS group_name
FROM project_files pf
JOIN entries e ON e.id = pf.entry_id
JOIN groups g ON g.id = e.group_id${where}
ORDER BY pf.id DESC`;
const qparams = params.slice();
const lim = parseInt(limit, 10);
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
const off = parseInt(offset, 10);
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
const { rows } = await pool.query(q, qparams);
const files = 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, module_id, website } = req.body;
const photos = req.files?.photo || [];
const projectFiles = req.files?.files || [];
if (website) {
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
await recordFailure(req, 'honeypot', 1, BAN_TTL_MS);
return res.status(400).json({ error: 'Spam detected' });
}
if (!student_name?.trim() || !group_id || !description?.trim()) {
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'All fields required' });
}
if (!photos.length) {
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Фото обязательно' });
}
const totalBytes = photos.reduce((s, f) => s + (f.size || 0), 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0);
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
}
const gid = Number.parseInt(group_id, 10);
if (!Number.isInteger(gid)) {
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Группа не найдена' });
}
const grpCheck = await pool.query('SELECT id FROM groups WHERE id = $1', [gid]);
if (!grpCheck.rows.length) {
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Группа не найдена' });
}
const mid = module_id === undefined || module_id === null || module_id === '' ? null : Number.parseInt(module_id, 10);
if (mid !== null && !Number.isInteger(mid)) {
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Модуль не найден' });
}
if (mid !== null) {
const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
if (!modCheck.rows.length) {
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Модуль не найден' });
}
}
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
if (intervalMin > 0) {
const dup = await pool.query(
'SELECT count(*)::int AS n FROM entries WHERE student_name = $1 AND created_at >= now() - ($2 || \' minutes\')::interval',
[student_name.trim(), intervalMin]
);
if (dup.rows[0].n > 0) {
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
}
}
for (const p of photos) {
await convertPhoto(p);
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
'INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
[student_name.trim()]
);
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
const { rows } = await client.query(
`INSERT INTO entries (student_name, group_id, module_id, description, description_original, photo_path)
VALUES ($1, $2, $3, $4, $4, $5) RETURNING *`,
[student_name.trim(), gid, mid, description.trim(), mainPhotoPath]
);
for (let i = 0; i < photos.length; i++) {
const p = photos[i];
await client.query(
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
[rows[0].id, `/uploads/${p.filename}`, i]
);
}
for (const f of projectFiles) {
const token = crypto.randomBytes(16).toString('hex');
await client.query(
'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)',
[rows[0].id, token, `/uploads/${f.filename}`, f.originalname]
);
}
await client.query('COMMIT');
if (entryAutoChecker) entryAutoChecker.notify();
invalidateEntries();
invalidateStats();
broadcastEntryChanged();
res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length });
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
photos.forEach(removeUpload);
projectFiles.forEach(removeUpload);
console.error('POST /api/entries:', e);
res.status(500).json({ error: e.message });
} finally {
client.release();
}
});
app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { student_name, group_id, description, module_id } = req.body;
const newPhotos = req.files || [];
for (const p of newPhotos) {
await convertPhoto(p);
}
const hasModule = Object.prototype.hasOwnProperty.call(req.body, 'module_id');
let mid = null;
if (hasModule && module_id !== undefined && module_id !== null && module_id !== '') {
mid = Number.parseInt(module_id, 10);
if (!Number.isInteger(mid)) {
newPhotos.forEach(p => safeUnlink(p.path));
return res.status(400).json({ error: 'Модуль не найден' });
}
const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
if (!modCheck.rows.length) {
newPhotos.forEach(p => safeUnlink(p.path));
return res.status(400).json({ error: 'Модуль не найден' });
}
}
const { rows } = await pool.query(
`UPDATE entries SET
student_name = COALESCE($1, student_name),
group_id = COALESCE($2, group_id),
description = COALESCE($3, description),
description_original = COALESCE($3, description_original),
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,
module_id = CASE WHEN $5 THEN $6 ELSE module_id END
WHERE id = $4 RETURNING *`,
[
student_name ? student_name.trim() : null,
group_id || null,
description ? description.trim() : null,
req.params.id,
hasModule,
mid,
]
);
if (!rows.length) {
newPhotos.forEach(p => safeUnlink(p.path));
return res.status(404).json({ error: 'Not found' });
}
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 });
});
function clampEnhanceParam(v, min, max, def) {
const n = parseFloat(v);
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
}
async function swapEntryPhotoFiles(req, entryId, oldPath, newPath, { keepOriginal = true, action = 'enhance' } = {}) {
let originalPath = null;
const { rows: prevRows } = await pool.query('SELECT photo_original_path FROM entries WHERE id = $1', [entryId]);
const prevOriginal = prevRows.length ? prevRows[0].photo_original_path : null;
const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
if (prevOriginal) {
originalPath = prevOriginal;
try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {}
} else if (keepOriginal && fs.existsSync(oldAbs)) {
try {
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
const backupPath = path.join(ORIGINALS_DIR, backupName);
fs.renameSync(oldAbs, backupPath);
originalPath = `/uploads/.originals/${backupName}`;
} catch (e) {
console.error('photo original backup failed:', e);
}
} else {
try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {}
}
await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = $2 WHERE id = $3', [newPath, originalPath, entryId]);
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, entryId, oldPath]);
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await pool.query(
'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, finished_at) VALUES ($1, $2, $3, $4, $5, now())',
[entryId, action, 'done', originalPath, newPath]
);
await logAudit(req, 'entry.photo.enhance', { entry_id: entryId, old_path: oldPath, new_path: newPath, original_path: originalPath });
invalidateEntries();
}
app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => {
if (req.is('json')) return next();
upload.single('photo')(req, res, next);
}, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
try {
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
if (!cur.length) {
if (req.file) safeUnlink(req.file.path);
return res.status(404).json({ error: 'Запись не найдена' });
}
const oldPath = cur[0].photo_path;
if (!oldPath) {
if (req.file) safeUnlink(req.file.path);
return res.status(400).json({ error: 'У записи нет фото' });
}
let newPath;
let engine = 'client';
if (req.is('json')) {
if (!sharp) return res.status(503).json({ error: 'sharp недоступен на сервере' });
const p = req.body.params || {};
const brightness = clampEnhanceParam(p.brightness, 10, 300, 100);
const contrast = clampEnhanceParam(p.contrast, 10, 300, 100);
const saturate = clampEnhanceParam(p.saturate, 0, 300, 100);
const sharpAmt = clampEnhanceParam(p.sharp, 0, 100, 0);
const denoise = clampEnhanceParam(p.denoise, 0, 100, 0);
const srcPath = path.join(__dirname, oldPath.replace(/^\/+/, ''));
let pipeline = sharp(srcPath).rotate();
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
if (contrast !== 100) {
const a = contrast / 100;
pipeline = pipeline.linear(a, 128 * (1 - a));
}
if (sharpAmt > 0) {
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
}
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
const outPath = path.join(__dirname, 'uploads', newName);
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
newPath = `/uploads/${newName}`;
engine = 'sharp';
} else {
if (!req.file) return res.status(400).json({ error: 'Нет файла' });
await convertPhoto(req.file);
newPath = `/uploads/${req.file.filename}`;
}
await swapEntryPhotoFiles(req, req.params.id, oldPath, newPath, { action: 'enhance' });
res.json({ ok: true, photo_path: newPath, engine });
} catch (e) {
if (req.file) safeUnlink(req.file.path);
console.error('PUT /api/entries/:id/photo/enhance:', e);
res.status(500).json({ error: 'Ошибка замены фото' });
}
});
app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => {
if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' });
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' });
if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' });
try {
const { rows } = await pool.query(
`INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1, 'ai', 'pending', NULL) RETURNING id`,
[req.params.id]
);
if (photoWorker) photoWorker.notify();
await logAudit(req, 'entry.photo.enhance-ai.queue', { entry_id: req.params.id, job_id: rows[0].id });
res.json({ jobId: rows[0].id });
} catch (e) {
console.error('POST /api/entries/:id/photo/enhance-ai:', e);
res.status(500).json({ error: 'Ошибка постановки задания в очередь' });
}
});
app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res) => {
const jobId = parseInt(req.params.jobId, 10);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
const { rows } = await pool.query(
'SELECT id, status, error, after_path, applied FROM photo_jobs WHERE id = $1 AND entry_id = $2',
[jobId, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Задание не найдено' });
const job = rows[0];
const out = { status: job.status, applied: job.applied, job_id: job.id };
if (job.status === 'error') out.error = job.error;
if (job.status === 'done') out.photo_path = job.after_path;
res.json(out);
});
app.post('/api/entries/:id/photo/jobs/:jobId/apply', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const client = await pool.connect();
try {
const jobId = parseInt(req.params.jobId, 10);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
await client.query('BEGIN');
const { rows: jobRows } = await client.query(
`SELECT id, entry_id, action, status, applied, after_path FROM photo_jobs
WHERE id = $1 AND entry_id = $2 FOR UPDATE`,
[jobId, req.params.id]
);
const job = jobRows[0];
if (!job) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Задание не найдено' }); }
if (job.status !== 'done') { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Результат ещё не готов' }); }
if (job.applied) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Результат уже применён' }); }
if (!job.after_path || !isSafeUploadPath(job.after_path)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Некорректный путь результата' });
}
const afterAbs = path.join(UPLOADS_DIR, String(job.after_path).replace(/^\/+/, '').replace(/^uploads\//, ''));
if (!fs.existsSync(afterAbs)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Файл результата не найден' });
}
const { rows: entryRows } = await client.query('SELECT photo_path, photo_original_path FROM entries WHERE id = $1', [req.params.id]);
if (!entryRows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Запись не найдена' }); }
const oldPath = entryRows[0].photo_path;
if (oldPath === job.after_path) {
await client.query(`UPDATE photo_jobs SET applied = true WHERE id = $1`, [jobId]);
await client.query('COMMIT');
return res.json({ ok: true, photo_path: job.after_path });
}
let originalPath = entryRows[0].photo_original_path;
let beforePath = originalPath;
const oldAbs = path.join(UPLOADS_DIR, String(oldPath || '').replace(/^\/+/, '').replace(/^uploads\//, ''));
if (oldPath && fs.existsSync(oldAbs)) {
try {
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
fs.renameSync(oldAbs, path.join(ORIGINALS_DIR, backupName));
beforePath = `/uploads/.originals/${backupName}`;
if (!originalPath) originalPath = beforePath;
} catch (e) {
console.error('photo apply backup failed:', e);
}
}
await client.query('UPDATE entries SET photo_path = $1, photo_original_path = $2 WHERE id = $3', [job.after_path, originalPath, req.params.id]);
await client.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [job.after_path, req.params.id, oldPath]);
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath || '').replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await client.query(`UPDATE photo_jobs SET applied = true, before_path = COALESCE(before_path, $1) WHERE id = $2`, [beforePath, jobId]);
await client.query('COMMIT');
await logAudit(req, 'entry.photo.apply', { entry_id: req.params.id, job_id: jobId, before_path: beforePath, after_path: job.after_path });
invalidateEntries();
res.json({ ok: true, photo_path: job.after_path });
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
console.error('POST /api/entries/:id/photo/jobs/:jobId/apply:', e);
res.status(500).json({ error: 'Ошибка применения фотографии' });
} finally {
client.release();
}
});
app.post('/api/entries/:id/photo/jobs/:jobId/reject', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
try {
const jobId = parseInt(req.params.jobId, 10);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
const { rows } = await pool.query(
`SELECT id, status, applied, after_path FROM photo_jobs WHERE id = $1 AND entry_id = $2`,
[jobId, req.params.id]
);
const job = rows[0];
if (!job) return res.status(404).json({ error: 'Задание не найдено' });
if (job.applied) return res.status(400).json({ error: 'Результат уже применён' });
if (job.status === 'done' && job.after_path && isSafeUploadPath(job.after_path)) safeUnlink(job.after_path);
await pool.query(
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now()
WHERE id = $1 AND applied = false`,
[jobId]
);
await logAudit(req, 'entry.photo.reject', { entry_id: req.params.id, job_id: jobId });
res.json({ ok: true });
} catch (e) {
console.error('POST /api/entries/:id/photo/jobs/:jobId/reject:', e);
res.status(500).json({ error: 'Ошибка отклонения результата' });
}
});
app.get('/api/entries/:id/photo/jobs', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query(
`SELECT id, action, status, applied, params, before_path, after_path, error, created_at, finished_at
FROM photo_jobs WHERE entry_id = $1 ORDER BY id DESC LIMIT 50`,
[req.params.id]
);
res.json(rows);
});
app.post('/api/entries/:id/photo/jobs/:jobId/rollback', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
try {
const jobId = parseInt(req.params.jobId, 10);
if (!Number.isFinite(jobId)) return res.status(404).json({ error: 'Задание не найдено' });
const { rows: jobRows } = await pool.query(
`SELECT id, before_path FROM photo_jobs WHERE id = $1 AND entry_id = $2 AND status = 'done'`,
[jobId, req.params.id]
);
if (!jobRows.length) return res.status(404).json({ error: 'Задание не найдено' });
const job = jobRows[0];
if (!job.before_path || !job.before_path.startsWith('/uploads/.originals/')) {
return res.status(400).json({ error: 'Нет сохранённой версии для отката' });
}
const beforeName = path.basename(job.before_path);
if (!/^[A-Za-z0-9._-]+$/.test(beforeName)) return res.status(400).json({ error: 'Некорректный путь' });
const beforeAbs = path.join(ORIGINALS_DIR, beforeName);
if (!fs.existsSync(beforeAbs)) return res.status(400).json({ error: 'Файл версии не найден' });
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' });
const oldPath = cur[0].photo_path;
if (!oldPath) return res.status(400).json({ error: 'У записи нет фото' });
const ext = path.extname(beforeName) || '.jpg';
const newPath = `/uploads/${crypto.randomBytes(12).toString('hex')}${ext}`;
fs.copyFileSync(beforeAbs, path.join(UPLOADS_DIR, path.basename(newPath)));
const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
let rollbackBefore = null;
if (fs.existsSync(oldAbs)) {
try {
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
fs.renameSync(oldAbs, path.join(ORIGINALS_DIR, backupName));
rollbackBefore = `/uploads/.originals/${backupName}`;
} catch (e) {
console.error('photo rollback backup failed:', e);
}
}
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, req.params.id]);
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]);
await pool.query(
'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, error, finished_at) VALUES ($1, $2, $3, $4, $5, NULL, now())',
[req.params.id, 'rollback', 'done', rollbackBefore, newPath]
);
await logAudit(req, 'entry.photo.rollback', { entry_id: req.params.id, job_id: jobId, restored_path: newPath });
invalidateEntries();
res.json({ ok: true, photo_path: newPath });
} catch (e) {
console.error('POST /api/entries/:id/photo/jobs/:jobId/rollback:', e);
res.status(500).json({ error: 'Ошибка отката фотографии' });
}
});
app.delete('/api/entries/:id/photo/enhance-ai/preview', requireAuth, async (req, res) => {
const { path: p } = req.body || {};
if (!isSafeUploadPath(p)) return res.status(400).json({ error: 'Некорректный путь' });
safeUnlink(p);
await pool.query(
`UPDATE photo_jobs SET status = 'rejected', error = 'Отклонено пользователем', finished_at = now()
WHERE entry_id = $1 AND after_path = $2 AND status = 'done' AND applied = false`,
[req.params.id, p]
);
res.json({ ok: true });
});
app.post('/api/entries/:id/photo/restore-original', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
try {
const { rows } = await pool.query('SELECT photo_path, photo_original_path FROM entries WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
const origPath = rows[0].photo_original_path;
if (!origPath) return res.status(400).json({ error: 'Оригинал не сохранён' });
const origName = path.basename(origPath);
if (!/^[A-Za-z0-9._-]+$/.test(origName)) return res.status(400).json({ error: 'Некорректный путь оригинала' });
const origAbs = path.join(ORIGINALS_DIR, origName);
if (!fs.existsSync(origAbs)) return res.status(400).json({ error: 'Файл оригинала не найден' });
const newPath = `/uploads/${crypto.randomBytes(12).toString('hex')}${path.extname(origName) || '.jpg'}`;
fs.renameSync(origAbs, path.join(UPLOADS_DIR, path.basename(newPath)));
const oldPath = rows[0].photo_path;
await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = NULL WHERE id = $2', [newPath, req.params.id]);
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]);
const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
let beforePath = null;
if (fs.existsSync(oldAbs)) {
try {
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
fs.renameSync(oldAbs, path.join(ORIGINALS_DIR, backupName));
beforePath = `/uploads/.originals/${backupName}`;
} catch (e) {
console.error('photo original backup failed:', e);
}
}
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
safeUnlink(oldThumb);
await pool.query(
'INSERT INTO photo_jobs (entry_id, action, status, before_path, after_path, error, finished_at) VALUES ($1, $2, $3, $4, $5, NULL, now())',
[req.params.id, 'restore', 'done', beforePath, newPath]
);
await logAudit(req, 'entry.photo.restore_original', { entry_id: req.params.id, restored_path: newPath });
invalidateEntries();
res.json({ ok: true, photo_path: newPath });
} catch (e) {
console.error('POST /api/entries/:id/photo/restore-original:', e);
res.status(500).json({ error: 'Ошибка восстановления оригинала' });
}
});
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
await pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1', [req.params.id]);
await logAudit(req, 'entry.soft-delete', { id: req.params.id });
invalidateEntries();
invalidateStats();
res.json({ ok: true });
});
app.put('/api/entries/:id/restore', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) {
console.warn(`[RESTORE DENIED] User ${req.user.id} (${req.user.username}) role=${req.user.role} branches=${JSON.stringify(req.user.branch_ids)} tried to restore entry ${req.params.id} (group_id=${acc.group_id})`);
return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
}
const result = await pool.query('UPDATE entries SET deleted_at = NULL 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/photo-jobs/status', requireAdmin, async (_, res) => {
const { rows } = await pool.query(
`SELECT status, count(*)::int AS n FROM photo_jobs GROUP BY status`
);
const counts = { pending: 0, processing: 0, done: 0, error: 0 };
rows.forEach(r => { counts[r.status] = r.n; });
const [pending, recent, errors] = await Promise.all([
pool.query(
`SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name
FROM photo_jobs j
JOIN entries e ON e.id = j.entry_id
JOIN groups g ON g.id = e.group_id
WHERE j.status IN ('pending', 'processing')
ORDER BY j.id ASC LIMIT 20`
),
pool.query(
`SELECT j.id, j.entry_id, j.action, j.status, j.params, j.before_path, j.after_path, j.error, j.created_at, j.finished_at,
e.student_name, g.name AS group_name
FROM photo_jobs j
JOIN entries e ON e.id = j.entry_id
JOIN groups g ON g.id = e.group_id
WHERE j.finished_at IS NOT NULL
ORDER BY j.finished_at DESC LIMIT 30`
),
pool.query(
`SELECT j.id, j.entry_id, j.action, j.error, j.finished_at, e.student_name, g.name AS group_name
FROM photo_jobs j
JOIN entries e ON e.id = j.entry_id
JOIN groups g ON g.id = e.group_id
WHERE j.status = 'error'
ORDER BY j.finished_at DESC NULLS LAST, j.id DESC LIMIT 20`
),
]);
const enabled = String(await getSetting('photo_worker_enabled', 'true')) !== 'false';
const service = await aiHealthCheck();
res.json({
enabled,
ai_configured: !!PHOTO_AI_URL,
ai_url: PHOTO_AI_URL,
worker: photoWorker ? photoWorker.getInfo() : null,
counts,
pending: pending.rows,
recent: recent.rows,
errors: errors.rows,
});
});
app.post('/api/photo-jobs/wake', requireAdmin, async (req, res) => {
if (photoWorker) photoWorker.notify();
await logAudit(req, 'photo-jobs.wake', {});
res.json({ ok: true });
});
app.post('/api/photo-jobs/enabled', requireAdmin, async (req, res) => {
const enabled = !!req.body?.enabled;
await pool.query(
`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[enabled ? 'true' : 'false']
);
if (enabled && photoWorker) photoWorker.notify();
await logAudit(req, 'photo-jobs.enabled', { enabled });
res.json({ ok: true, enabled });
});
app.post('/api/photo-jobs/requeue-failed', requireAdmin, async (req, res) => {
const { rowCount } = await pool.query(
`UPDATE photo_jobs SET status = 'pending', error = NULL, finished_at = NULL
WHERE status = 'error'`
);
if (photoWorker) photoWorker.notify();
await logAudit(req, 'photo-jobs.requeue-failed', { count: rowCount });
invalidateEntries();
res.json({ ok: true, count: rowCount });
});
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/') || req.path.startsWith('/r/');
}
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
function renderErrorPage(code, title, message, details) {
return ERROR_HTML
.replace('id="errorCode">404', `id="errorCode">${code}`)
.replace('id="errorTitle">Страница не найдена', `id="errorTitle">${title}`)
.replace('id="errorMessage">Запрашиваемая страница не существует или была перемещена.', `id="errorMessage">${message}`)
.replace('style="display:none"', details ? '' : 'style="display:none"')
.replace('<pre id="errorStack"></pre>', details ? `<pre id="errorStack">${escapeHtml(details)}</pre>` : '<pre id="errorStack"></pre>');
}
app.use((req, res, next) => {
if (isApiRoute(req)) {
return res.status(404).json({ error: 'Not found' });
}
res.status(404).send(renderErrorPage(404, 'Страница не найдена', 'Запрашиваемая страница не существует или была перемещена.'));
});
app.use((err, req, res, next) => {
console.error('Error:', err);
if (isApiRoute(req)) {
return res.status(500).json({ error: 'Internal server error' });
}
const msg = process.env.NODE_ENV === 'production' ? 'Произошла ошибка на сервере.' : (err?.message || 'Internal server error');
const details = process.env.NODE_ENV === 'production' ? '' : (err?.stack || '');
res.status(500).send(renderErrorPage(500, 'Ошибка сервера', msg, details));
});
const PORT = process.env.PORT || 3003;
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); });
process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); });
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 ensureModulesTable(); } catch (err) { console.error('Modules table:', err); }
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); }
try { await 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');
photoWorker = createPhotoEnhanceWorker({
pool,
getSetting,
logAudit,
invalidateEntries,
sharp,
photoAiUrl: PHOTO_AI_URL,
uploadsDir: UPLOADS_DIR,
originalsDir: ORIGINALS_DIR,
});
photoWorker.start();
console.log('Photo enhance worker started');
})();