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, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ DEFAULT now() )`); await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true'); await pool.query('ALTER TABLE 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, is_active: x.is_active !== false, 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, is_active, created_at) VALUES ($1,$2,$3,$4,$5)', [x.id, x.name, x.lessons_count, x.is_active, 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, active } = req.query; const conditions = []; const params = []; if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); } if (active === '1' || active === 'true') conditions.push('m.is_active = true'); const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params); const total = crows[0].n; let q = `SELECT m.*, count(e.id)::int AS entries_count FROM modules m LEFT JOIN entries e ON e.module_id = m.id${where} GROUP BY m.id ORDER BY m.is_active DESC, m.id`; const qparams = params.slice(); const lim = parseInt(limit, 10); if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; } const off = parseInt(offset, 10); if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; } const { rows } = await pool.query(q, qparams); res.json({ modules: rows, total }); }); function parseLessonsCount(v) { if (v === undefined || v === null || v === '') return 0; const n = Number(v); if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error('Количество занятий — целое число от 0 до 10000'); return n; } app.post('/api/modules', requireAdmin, async (req, res) => { const { name, lessons_count } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); let lessons; try { lessons = parseLessonsCount(lessons_count); } catch (e) { return res.status(400).json({ error: e.message }); } try { const { rows } = await pool.query( 'INSERT INTO modules (name, lessons_count) VALUES ($1, $2) RETURNING *', [name.trim(), lessons] ); await logAudit(req, 'module.create', { id: rows[0].id, name: name.trim(), lessons_count: lessons }); res.status(201).json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' }); throw e; } }); app.put('/api/modules/:id', requireAdmin, async (req, res) => { const { name, lessons_count } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); let lessons; try { lessons = parseLessonsCount(lessons_count); } catch (e) { return res.status(400).json({ error: e.message }); } try { const { rows } = await pool.query( 'UPDATE modules SET name = $1, lessons_count = $2 WHERE id = $3 RETURNING *', [name.trim(), lessons, req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); await logAudit(req, 'module.update', { id: req.params.id, name: name.trim(), lessons_count: lessons }); res.json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' }); throw e; } }); app.delete('/api/modules/:id', requireAdmin, async (req, res) => { const { rows } = await pool.query('UPDATE modules SET is_active = false WHERE id = $1 RETURNING id', [req.params.id]); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); await logAudit(req, 'module.delete', { id: req.params.id }); res.json({ ok: true }); }); app.put('/api/modules/:id/restore', requireAdmin, async (req, res) => { const { rows } = await pool.query('UPDATE modules SET is_active = true WHERE id = $1 RETURNING *', [req.params.id]); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); await logAudit(req, 'module.restore', { id: req.params.id }); res.json(rows[0]); }); // --- 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(`
${entries.length}${plural(entries.length, 'занятие', 'занятия', 'занятий')}
`); if (showPhotos) counters.push(`
${photos.length}${plural(photos.length, 'фотография', 'фотографии', 'фотографий')}
`); if (showFiles) counters.push(`
${files.length}${plural(files.length, 'работа', 'работы', 'работ')}
`); counters.push(`
${groups.length}${plural(groups.length, 'группа', 'группы', 'групп')}
`); 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 `
${escapeHtml(name)} — фото ${caption ? `
${escapeHtml(caption)}
` : ''}
`; }).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 `
${String(i + 1).padStart(2, '0')} ${escapeHtml(f.original)} ${escapeHtml(ext)}${escapeHtml(meta)} ↗
`; }).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 => ` фото`).join('') : ''; const entryFiles = showFiles ? files.filter(f => f.entryId === e.id) : []; const fls = entryFiles.length ? `
${entryFiles.map(f => `${escapeHtml(f.original)}`).join('')}
` : ''; const desc = e.description ? `

${escapeHtml(e.description)}

` : ''; const gr = e.group_name ? `${escapeHtml(e.group_name)}` : ''; const dt = showDates && e.created_at ? `${escapeHtml(fmtLongDate(e.created_at))}` : ''; return `
${dt ? `
${escapeHtml(fmtLongDate(e.created_at))}
` : ''}
${gr ? `${escapeHtml(e.group_name)}` : ''} ${desc} ${thumbs ? `
${thumbs}
` : ''} ${fls}
`; }).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 ? `
${escapeHtml(name)}
` : ''; const navItems = []; navItems.push('Обзор'); if (showPhotos) navItems.push('Фотографии'); if (showFiles) navItems.push('Работы'); if (showEntries) navItems.push('Занятия'); const nav = navItems.join(''); return ` ${escapeHtml(name)} — портфолио
${heroAvatar || '
🎨
'}

Портфолио ученика

${escapeHtml(name)}

${metaBits.join(' · ')}

${showFiles ? 'Смотреть работы' : ''} ${showEntries ? 'Хронология занятий' : ''}

Обзор

${counters.join('')}
${showPhotos ? `

Фотографии

[${photos.length}]
${photoCards ? `
${photoCards}
` : '

Фотографий пока нет.

'}
` : ''} ${showFiles ? `

Проекты и работы

[${files.length}]
${projectCards ? `
${projectCards}
` : '

Работ пока нет.

'}
` : ''} ${showEntries ? `

Журнал занятий

[${entries.length}]
${lessonRows ? `
${lessonRows}
` : '

Записей о занятиях пока нет.

'}
` : ''}
`; } 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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function renderErrorPage(code, title, message, details) { return ERROR_HTML .replace('id="errorCode">404', `id="errorCode">${code}`) .replace('id="errorTitle">Страница не найдена', `id="errorTitle">${title}`) .replace('id="errorMessage">Запрашиваемая страница не существует или была перемещена.', `id="errorMessage">${message}`) .replace('style="display:none"', details ? '' : 'style="display:none"') .replace('
', details ? `
${escapeHtml(details)}
` : '
');
}

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');
})();