1716 lines
67 KiB
JavaScript
1716 lines
67 KiB
JavaScript
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);
|
|
},
|
|
});
|
|
|
|
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']);
|
|
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 { 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 {}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)');
|
|
}
|
|
|
|
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 logAudit(req, action, target) {
|
|
try {
|
|
await pool.query(
|
|
'INSERT INTO audit_log (action, target, ip) VALUES ($1, $2, $3)',
|
|
[action, target ?? null, req?.ip?.slice(0, 45) || null]
|
|
);
|
|
} catch (e) {
|
|
console.error('audit log failed:', e);
|
|
}
|
|
}
|
|
|
|
// --- 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' });
|
|
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) {
|
|
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом ≥ 1' });
|
|
}
|
|
}
|
|
}
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
for (const [key, value] of Object.entries(settings)) {
|
|
await client.query(
|
|
`INSERT INTO settings (key, value) VALUES ($1, $2)
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
|
[key, String(value ?? '')]
|
|
);
|
|
}
|
|
await client.query('COMMIT');
|
|
await logAudit(req, 'settings.update', { settings });
|
|
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 { rows } = await pool.query(
|
|
'SELECT id, action, target, ip, created_at FROM audit_log ORDER BY id DESC LIMIT $1',
|
|
[limit]
|
|
);
|
|
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 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 (req, 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"`);
|
|
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 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));
|
|
await logAudit(req, 'backup.restore', {});
|
|
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]
|
|
);
|
|
await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() });
|
|
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: 'Не найдено' });
|
|
await logAudit(req, 'link.update', { id: req.params.id, name: name.trim() });
|
|
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]);
|
|
await logAudit(req, 'link.delete', { id: 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, b.name AS branch_name
|
|
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
|
|
LEFT JOIN branches b ON b.id = g.branch_id
|
|
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, branch_id } = 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,
|
|
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 });
|
|
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, branch_id } = req.body;
|
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
|
try {
|
|
const { rows } = await pool.query(
|
|
'INSERT INTO groups (name, branch_id) VALUES ($1, $2) RETURNING *',
|
|
[name.trim(), branch_id === undefined || branch_id === null || branch_id === '' ? null : branch_id]
|
|
);
|
|
await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id });
|
|
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]);
|
|
await logAudit(req, 'group.delete', { id: req.params.id });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Branches CRUD ---
|
|
app.get('/api/branches', requireAdmin, async (_, res) => {
|
|
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
|
|
GROUP BY b.id
|
|
ORDER BY b.id`
|
|
);
|
|
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() });
|
|
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 });
|
|
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 });
|
|
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]
|
|
);
|
|
await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path });
|
|
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/: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: 'Не найдено' });
|
|
await logAudit(req, 'group.photo.update', { group_id: req.params.id, photo_id: req.params.photoId });
|
|
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]);
|
|
await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.put('/api/groups/:id/photos/:photoId/cover', 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: 'Не найдено' });
|
|
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 });
|
|
const { rows: gRows } = await pool.query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
|
|
res.json(gRows[0]);
|
|
});
|
|
|
|
// --- Students CRUD ---
|
|
app.get('/api/students', apiLimiter, 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]
|
|
);
|
|
await logAudit(req, 'student.create', { id: rows[0].id, name: name.trim() });
|
|
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' });
|
|
await logAudit(req, 'student.update', { id: req.params.id, name: name.trim() });
|
|
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
|
|
);
|
|
await logAudit(req, 'student.batch-group', { group_id, count: rows.length });
|
|
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]);
|
|
await logAudit(req, 'student.delete', { id: 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);
|
|
});
|
|
|
|
const entryFilesUpload = adminUpload.array('files', 10);
|
|
app.post('/api/entries/:id/files', requireAdmin, (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: 'Файлы не выбраны' });
|
|
|
|
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');
|
|
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|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/system-info', requireAdmin, async (_, res) => {
|
|
try {
|
|
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
|
|
`);
|
|
|
|
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 totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
|
|
let diskInfo = null;
|
|
if (totalDisk) {
|
|
const blockSize = totalDisk.bsize || 4096;
|
|
const total = totalDisk.blocks * blockSize;
|
|
const free = totalDisk.bfree * blockSize;
|
|
const used = total - free;
|
|
diskInfo = {
|
|
total: formatBytes(total),
|
|
total_bytes: total,
|
|
used: formatBytes(used),
|
|
used_bytes: used,
|
|
free: formatBytes(free),
|
|
free_bytes: free,
|
|
used_pct: Math.round((used / total) * 100),
|
|
};
|
|
} else {
|
|
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);
|
|
diskInfo = {
|
|
total: formatBytes(total),
|
|
total_bytes: total,
|
|
used: formatBytes(used),
|
|
used_bytes: used,
|
|
free: formatBytes(free),
|
|
free_bytes: free,
|
|
used_pct: Math.round((used / total) * 100),
|
|
};
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
res.json({
|
|
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 },
|
|
entry_photos: { count: entryPhotoStats.rows[0].count || 0 },
|
|
total_count: (photoStats.rows[0].count || 0) + (entryPhotoStats.rows[0].count || 0),
|
|
},
|
|
files: {
|
|
project_files: { count: fileStats.rows[0].count || 0 },
|
|
},
|
|
uploads: {
|
|
count: uploadsCount,
|
|
size: formatBytes(uploadsSize),
|
|
size_bytes: uploadsSize,
|
|
},
|
|
disk: diskInfo,
|
|
});
|
|
} catch (e) {
|
|
console.error('System info error:', e);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
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' });
|
|
await logAudit(req, 'entry.update', { id: req.params.id });
|
|
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]);
|
|
await logAudit(req, 'entry.soft-delete', { id: 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]);
|
|
await logAudit(req, 'entry.restore', { id: 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]);
|
|
await logAudit(req, 'entry.permanent-delete', { id: 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, '"').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('<pre id="errorStack"></pre>', details ? `<pre id="errorStack">${escapeHtml(details)}</pre>` : '<pre id="errorStack"></pre>');
|
|
}
|
|
|
|
app.use((req, res, next) => {
|
|
if (isApiRoute(req)) {
|
|
return res.status(404).json({ error: 'Not found' });
|
|
}
|
|
res.status(404).send(renderErrorPage(404, 'Страница не найдена', 'Запрашиваемая страница не существует или была перемещена.'));
|
|
});
|
|
|
|
app.use((err, req, res, next) => {
|
|
console.error('Error:', err);
|
|
if (isApiRoute(req)) {
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
const msg = process.env.NODE_ENV === 'production' ? 'Произошла ошибка на сервере.' : (err?.message || 'Internal server error');
|
|
const details = process.env.NODE_ENV === 'production' ? '' : (err?.stack || '');
|
|
res.status(500).send(renderErrorPage(500, 'Ошибка сервера', msg, details));
|
|
});
|
|
|
|
const PORT = process.env.PORT || 3003;
|
|
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
|
|
|
|
process.on('unhandledRejection', (err) => { console.error('Unhandled rejection:', err); });
|
|
process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); });
|
|
|
|
const certPath = path.join(__dirname, 'certs', 'cert.pem');
|
|
const keyPath = path.join(__dirname, 'certs', 'key.pem');
|
|
|
|
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|
const httpsServer = https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app);
|
|
httpsServer.listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`));
|
|
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT}`));
|
|
} else {
|
|
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (no TLS certs)`));
|
|
}
|
|
|
|
sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
|
ensureAuditTable().catch(err => console.error('Audit table:', err));
|
|
ensureBranchesTable().catch(err => console.error('Branches table:', err));
|