const express = require('express'); const { Pool, types } = require('pg'); const multer = require('multer'); const cors = require('cors'); 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 ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'; app.use(cors()); app.use(express.json()); 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(); } const upload = multer({ storage: multer.diskStorage({ destination: (_, __, cb) => { fs.mkdirSync('uploads', { recursive: true }); cb(null, 'uploads'); }, filename: (_, file, cb) => { const ext = path.extname(file.originalname) || '.jpg'; cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); }, }), limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (_, file, cb) => { if (file.mimetype.startsWith('image/')) cb(null, true); else cb(new Error('Only images')); }, }); 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; } function removeUpload(file) { if (!file) return; const fp = path.join(__dirname, file.path); if (fs.existsSync(fp)) fs.unlinkSync(fp); } // --- 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.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 tar = require('tar'); const os = require('os'); const uploadBackup = multer({ storage: multer.memoryStorage(), limits: { fileSize: 300 * 1024 * 1024 }, }); const SAFE_NAME = /^[\w,.()-]+$/; app.get('/api/backup', requireAdmin, async (_, res) => { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { const [g, s, e, st] = 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'), ]); 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 }; 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 }); } }); 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 gunz = await gunzipAsync(req.file.buffer).catch(() => null); if (gunz && gunz[0] === 0x7b) { data = JSON.parse(gunz.toString('utf8')); legacyPhotos = data.photos || []; } else { const inPath = path.join(staging, 'input.tar.gz'); fs.writeFileSync(inPath, req.file.buffer); await tar.x({ file: inPath, cwd: staging }); data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8')); } } catch { fs.rmSync(staging, { recursive: true, force: true }); return res.status(400).json({ error: 'Неверный файл бэкапа' }); } if (!data || data.version !== 1 || !Array.isArray(data.groups)) { fs.rmSync(staging, { recursive: true, force: true }); return res.status(400).json({ error: 'Неверный формат бэкапа' }); } 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 data.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 ?? null, x.time_start ?? null, x.time_end ?? null] ); } for (const x of data.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 ?? null] ); } for (const x of data.entries) { await client.query( 'INSERT INTO entries (id, student_name, group_id, description, photo_path, created_at) VALUES ($1,$2,$3,$4,$5,$6)', [x.id, x.student_name, x.group_id, x.description, x.photo_path ?? null, x.created_at] ); } for (const [k, v] of Object.entries(data.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']) { 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 }); 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 }); 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 } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); const token = crypto.randomBytes(20).toString('hex'); const { rows } = await pool.query( `INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null] ); 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 } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); const { rows } = await pool.query( `UPDATE share_links SET name = $1, group_id = $2, student_name = $3, date_from = $4, date_to = $5 WHERE id = $6 RETURNING *`, [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.params.id] ); 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', 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]; 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')`); } 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 ); 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, entries, }); }); app.get('/s/:token', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'share.html')); }); // --- Groups CRUD --- app.get('/api/groups', async (_, res) => { const { rows } = await pool.query('SELECT * FROM groups ORDER BY id'); res.json(rows); }); app.get('/api/groups/active', 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) => { await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]); res.json({ ok: true }); }); // --- Students CRUD --- app.get('/api/students', 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 } = req.query; const conditions = []; const params = []; 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); res.json({ entries: rows, total }); }); app.get('/api/stats', requireAdmin, async (_, res) => { const [entries, groups, students, today] = await Promise.all([ pool.query('SELECT count(*)::int AS n FROM entries'), pool.query('SELECT count(*)::int AS n FROM groups'), pool.query('SELECT count(DISTINCT student_name)::int AS n FROM entries'), pool.query("SELECT count(*)::int AS n FROM entries WHERE created_at >= now()::date"), ]); res.json({ entries: entries.rows[0].n, groups: groups.rows[0].n, students: students.rows[0].n, today: today.rows[0].n, }); }); app.post('/api/entries', upload.single('photo'), async (req, res) => { const { student_name, group_id, description } = req.body; if (!student_name?.trim() || !group_id || !description?.trim()) { removeUpload(req.file); return res.status(400).json({ error: 'All fields required' }); } 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(req.file); return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` }); } } const photo_path = req.file ? `/uploads/${req.file.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(), group_id, description.trim(), photo_path] ); await client.query('COMMIT'); res.status(201).json(rows[0]); } catch (e) { await client.query('ROLLBACK'); throw e; } 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) => { const { rows } = await pool.query( 'DELETE FROM entries WHERE id = $1 RETURNING photo_path', [req.params.id] ); if (rows[0]?.photo_path) { const fp = path.join(__dirname, rows[0].photo_path); if (fs.existsSync(fp)) fs.unlinkSync(fp); } res.json({ ok: true }); }); const PORT = process.env.PORT || 3000; const HTTPS_PORT = process.env.HTTPS_PORT || 3443; 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'); }