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 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 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_PASSWORD = process.env.ADMIN_PASSWORD; if (!ADMIN_PASSWORD) { console.error('FATAL: ADMIN_PASSWORD environment variable is not set. Refusing to start.'); process.exit(1); } app.use(helmet({ contentSecurityPolicy: false })); app.use(express.json({ limit: '1mb' })); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use(express.static(path.join(__dirname, 'public'))); function requireAdmin(req, res, next) { const token = req.headers['x-admin-token']; if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' }); next(); } 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']); 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(); if (file.fieldname === 'photo') { if (!file.mimetype || !file.mimetype.startsWith('image/') || !ALLOWED_IMAGE_EXT.has(ext)) { return cb(new Error('Only images')); } } if (ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension')); cb(null, true); }, }); async function getSetting(key, def) { const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]); return rows.length ? rows[0].value : def; } 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 convertHeicPhoto(file) { if (!file || !file.path) return; const ext = (path.extname(file.originalname || '') || '').toLowerCase(); if (ext !== '.heic' && ext !== '.heif') return; try { 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.85 }); fs.writeFileSync(outPath, jpeg); safeUnlink(file.path); file.path = outPath; file.filename = outName; file.originalname = outName; } catch (e) { console.error('HEIC 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`, [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 }] = 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'), ]); const refs = new Set(); [...photos, ...files, ...gphotos].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 {} } } } // --- 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 keys = ['footer_left', 'footer_right']; const out = {}; for (const k of keys) out[k] = await getSetting(k, ''); 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' }); 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'); 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(); } }); // --- Backup / Restore --- const gunzipAsync = require('util').promisify(require('zlib').gunzip); const zlib = require('zlib'); const tar = require('tar'); const os = require('os'); 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: 50 * 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 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); } 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), })); const students = (data.students || []).map(x => ({ id: reqInt(x.id), name: reqStr(x.name, 150), created_at: optTs(x.created_at), group_id: optInt(x.group_id, 0, 2147483647), })); const entries = (data.entries || []).map(x => ({ id: reqInt(x.id), student_name: reqStr(x.student_name, 150), group_id: reqInt(x.group_id), description: reqStr(x.description, 100000), 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 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 }; } app.get('/api/backup', requireAdmin, async (_, res) => { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { const [g, s, e, st, pf] = 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'), ]); 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 }; 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"`); 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 entries'); await client.query('DELETE FROM students'); await client.query('DELETE FROM groups'); for (const x of ndata.groups) { await client.query( 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end) VALUES ($1,$2,$3,$4,$5,$6)', [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end] ); } for (const x of ndata.students) { await client.query( 'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)', [x.id, x.name, x.created_at, x.group_id] ); } for (const x of ndata.entries) { await client.query( 'INSERT INTO entries (id, student_name, group_id, description, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', [x.id, x.student_name, x.group_id, x.description, 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 [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']) { 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)); 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; } app.get('/api/links', requireAdmin, async (_, 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 ORDER BY l.created_at DESC` ); rows.forEach(normDates); res.json(rows); }); app.post('/api/links', requireAdmin, async (req, res) => { const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body; if (!name?.trim()) return res.status(400).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, anonymize_names, expires_at, access_password_hash) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash] ); res.status(201).json(normDates(rows[0])); }); app.put('/api/links/:id', requireAdmin, async (req, res) => { const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body; if (!name?.trim()) return res.status(400).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', 'anonymize_names = $6']; const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false]; let paramIdx = 7; 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: 'Не найдено' }); res.json(normDates(rows[0])); }); app.delete('/api/links/:id', requireAdmin, async (req, res) => { await pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]); 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) { return res.status(401).json({ error: 'Неверный пароль' }); } } 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] || []; }); // Anonymize names if enabled if (l.anonymize_names) { 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 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 })); } res.json({ 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, created_at: l.created_at, anonymize_names: l.anonymize_names, entries, photos, }); }); 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) 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)) res.sendFile(fp); else res.download(fp, r.name); }); app.get('/s/:token', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'share.html')); }); // --- Groups CRUD --- app.get('/api/groups', apiLimiter, async (_, res) => { const { rows } = await pool.query( `SELECT g.*, gp.photo_path AS cover_path FROM groups g LEFT JOIN LATERAL ( SELECT photo_path FROM group_photos WHERE group_id = g.id ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1 ) gp ON true ORDER BY g.id` ); res.json(rows); }); app.get('/api/groups/active', apiLimiter, async (_, res) => { 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 `); res.json(rows); }); app.put('/api/groups/:id', requireAdmin, async (req, res) => { const { name, day_of_week, time_start, time_end } = req.body; try { const { rows } = await pool.query( `UPDATE groups SET name = COALESCE($1, name), day_of_week = $2, time_start = $3, time_end = $4 WHERE id = $5 RETURNING *`, [name, day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week, time_start || null, time_end || null, req.params.id] ); res.json(rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' }); throw e; } }); app.post('/api/groups', requireAdmin, async (req, res) => { const { name } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); try { const { rows } = await pool.query( 'INSERT INTO groups (name) VALUES ($1) RETURNING *', [name.trim()] ); 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', requireAdmin, async (req, res) => { 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]); res.json({ ok: true }); }); app.get('/api/groups/:id/photos', requireAdmin, async (req, res) => { 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 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', requireAdmin, (req, res, next) => { groupPhotoUpload(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)' }); 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 { caption, taken_at } = req.body; if (!req.file) return res.status(400).json({ error: 'Файл обязателен' }); try { await convertHeicPhoto(req.file); const { rows } = await pool.query( `INSERT INTO group_photos (group_id, photo_path, caption, taken_at) VALUES ($1, $2, $3, $4) RETURNING *`, [req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null] ); res.status(201).json(rows[0]); } catch (e) { safeUnlink(`uploads/${req.file.filename}`); throw e; } }); app.put('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => { 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: 'Не найдено' }); res.json(rows[0]); }); app.delete('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => { 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('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]); res.json({ ok: true }); }); // --- Students CRUD --- app.get('/api/students', apiLimiter, async (_, res) => { 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 ORDER BY s.name` ); res.json(rows); }); app.get('/api/students/names', requireAdmin, async (_, res) => { const { rows } = await pool.query( 'SELECT DISTINCT student_name AS name FROM entries WHERE student_name IS NOT NULL AND student_name <> \'\' ORDER BY student_name' ); res.json(rows.map(r => r.name)); }); app.post('/api/students', requireAdmin, 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; try { const { rows } = await pool.query( 'INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *', [name.trim(), gid] ); 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', requireAdmin, async (req, res) => { const { name, group_id } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); try { const { rows } = await pool.query( `UPDATE students SET name = $1, group_id = $2 WHERE id = $3 RETURNING *`, [name.trim(), group_id === undefined || group_id === null || group_id === '' ? null : group_id, req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'Not found' }); 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', requireAdmin, 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' }); } 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 ); res.json({ ok: true, updated: rows.length }); }); app.delete('/api/students/:id', requireAdmin, async (req, res) => { await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]); res.json({ ok: true }); }); // --- Entries --- app.get('/api/entries', requireAdmin, async (req, res) => { const { group_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query; const conditions = []; const params = []; if (deleted === '1') conditions.push('e.deleted_at IS NOT NULL'); else conditions.push('e.deleted_at IS NULL'); if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); } if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); } if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); } if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); } if (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); } const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; const { rows: crows } = await pool.query( `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`, params ); const total = crows[0].n; let q = `SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`; const qparams = params.slice(); const lim = parseInt(limit, 10); if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; } const off = parseInt(offset, 10); if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; } const { rows } = await pool.query(q, qparams); let files; if (rows.length) { const ids = rows.map(r => r.id); const fRes = await pool.query( 'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id', [ids] ); files = {}; fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); }); } else { files = {}; } rows.forEach(r => { r.files = files[r.id] || []; }); res.json({ entries: rows, total }); }); app.get('/api/entries/:id/files', requireAdmin, async (req, res) => { 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); }); function isImageName(name) { return /\.(jpe?g|png|gif|webp|bmp|avif|ico)$/i.test(name || ''); } app.get('/api/files', requireAdmin, 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')`); } 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${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', requireAdmin, async (req, res) => { 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: 'Не найдено' }); res.json({ ok: true }); }); app.delete('/api/files/:id', requireAdmin, async (req, res) => { 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]); 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)) res.sendFile(fp); else res.download(fp, r.name); }); app.get('/api/stats', requireAdmin, async (_, res) => { const [entries, trash, groups, students, today] = await Promise.all([ pool.query('SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NULL'), pool.query('SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NOT NULL'), pool.query('SELECT count(*)::int AS n FROM groups'), pool.query('SELECT count(DISTINCT student_name)::int AS n FROM entries WHERE deleted_at IS NULL'), pool.query("SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NULL AND created_at >= now()::date"), ]); res.json({ 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, }); }); app.get('/api/dashboard', requireAdmin, async (req, res) => { const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']; const [stats, activity, active, recent, top, photos, latestPhotos] = await Promise.all([ (async () => { const [entries, trash, groups, students, today] = await Promise.all([ pool.query('SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NULL'), pool.query('SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NOT NULL'), pool.query('SELECT count(*)::int AS n FROM groups'), pool.query('SELECT count(DISTINCT student_name)::int AS n FROM entries WHERE deleted_at IS NULL'), pool.query("SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NULL AND created_at >= now()::date"), ]); 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(created_at, 'YYYY-MM-DD') AS d, count(*)::int AS n FROM entries WHERE deleted_at IS NULL AND created_at >= (now() - interval '13 days')::date GROUP BY 1 ORDER BY 1` ), 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` ), 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 ORDER BY e.created_at DESC LIMIT 7` ), pool.query( `SELECT student_name, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY student_name ORDER BY n DESC, student_name LIMIT 5` ), 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 ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8` ), pool.query( `SELECT photo_path, taken_at FROM group_photos ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1` ), ]); 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), })); res.json({ 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, }); }); const entryFields = upload.fields([{ name: 'photo', maxCount: 1 }, { 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)' }); if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' }); return res.status(400).json({ error: 'Недопустимый файл' }); }); }, async (req, res) => { const { student_name, group_id, description, website } = req.body; const photo = req.files?.photo?.[0] || null; const projectFiles = req.files?.files || []; if (website) { removeUpload(photo); projectFiles.forEach(removeUpload); return res.status(400).json({ error: 'Spam detected' }); } if (!student_name?.trim() || !group_id || !description?.trim()) { removeUpload(photo); projectFiles.forEach(removeUpload); return res.status(400).json({ error: 'All fields required' }); } const totalBytes = (photo?.size || 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0); if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) { removeUpload(photo); projectFiles.forEach(removeUpload); return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' }); } const gid = Number.parseInt(group_id, 10); if (!Number.isInteger(gid)) { removeUpload(photo); 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) { removeUpload(photo); 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) { removeUpload(photo); projectFiles.forEach(removeUpload); return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` }); } } await convertHeicPhoto(photo); const photo_path = photo ? `/uploads/${photo.filename}` : null; 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 { rows } = await client.query( `INSERT INTO entries (student_name, group_id, description, photo_path) VALUES ($1, $2, $3, $4) RETURNING *`, [student_name.trim(), gid, description.trim(), photo_path] ); 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'); res.status(201).json({ ...rows[0], files: projectFiles.length }); } catch (e) { await client.query('ROLLBACK').catch(() => {}); removeUpload(photo); projectFiles.forEach(removeUpload); console.error('POST /api/entries:', e); res.status(500).json({ error: e.message }); } finally { client.release(); } }); app.put('/api/entries/:id', requireAdmin, async (req, res) => { const { student_name, group_id, description } = req.body; const { rows } = await pool.query( `UPDATE entries SET student_name = COALESCE($1, student_name), group_id = COALESCE($2, group_id), description = COALESCE($3, description) WHERE id = $4 RETURNING *`, [ student_name ? student_name.trim() : null, group_id || null, description ? description.trim() : null, req.params.id, ] ); if (!rows.length) return res.status(404).json({ error: 'Not found' }); res.json(rows[0]); }); app.delete('/api/entries/:id', requireAdmin, async (req, res) => { await pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1', [req.params.id]); res.json({ ok: true }); }); app.put('/api/entries/:id/restore', requireAdmin, async (req, res) => { await pool.query('UPDATE entries SET deleted_at = NULL WHERE id = $1', [req.params.id]); res.json({ ok: true }); }); app.delete('/api/entries/:id/permanent', requireAdmin, async (req, res) => { await removeEntryFiles(req.params.id); await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]); res.json({ ok: true }); }); app.get('/api/trash', requireAdmin, async (req, res) => { const { limit, offset } = req.query; const { rows: crows } = await pool.query( "SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NOT NULL" ); 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 e.deleted_at IS NOT NULL ORDER BY e.deleted_at DESC`; const qparams = []; 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 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', 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'); res.json({ ok: true, deleted: d.rowCount }); }); // --- Error handlers --- const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8'); function isApiRoute(req) { return req.path.startsWith('/api/') || req.path.startsWith('/s/'); } function escapeHtml(str) { return String(str).replace(/&/g, '&').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 || 3000;
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');
const redirect = express();
redirect.use((req, res) => {
res.redirect(301, `https://${req.headers.host.split(':')[0]}:${HTTPS_PORT}${req.originalUrl}`);
});
redirect.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (redirect → HTTPS)`));
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app)
.listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
} else {
console.log('HTTPS: no certs found, skipping');
}
sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));