3860 lines
163 KiB
JavaScript
3860 lines
163 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 { createEntryAutoChecker } = require('./worker');
|
|
|
|
const https = require('https');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
|
|
types.setTypeParser(1082, v => v);
|
|
|
|
const app = express();
|
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
|
|
const pgClient = require('pg').Client;
|
|
const lister = new pgClient({ connectionString: process.env.DATABASE_URL });
|
|
let listerConnected = false;
|
|
function connectLister() {
|
|
if (listerConnected) return;
|
|
listerConnected = true;
|
|
lister.connect()
|
|
.then(() => lister.query('LISTEN entries_changed'))
|
|
.catch(e => {
|
|
listerConnected = false;
|
|
console.error('LISTEN entries_changed failed:', e.message);
|
|
setTimeout(connectLister, 5000);
|
|
});
|
|
}
|
|
connectLister();
|
|
lister.on('error', (e) => {
|
|
console.error('LISTEN connection error:', e.message);
|
|
});
|
|
lister.on('end', () => {
|
|
listerConnected = false;
|
|
setTimeout(connectLister, 3000);
|
|
});
|
|
lister.on('notification', (msg) => {
|
|
let payload = null;
|
|
try { payload = JSON.parse(msg.payload || '{}'); } catch (e) { payload = null; }
|
|
const type = payload && payload.type ? payload.type : 'entry_created';
|
|
if (type === 'ai_status') {
|
|
broadcastAiStatus(payload.id, payload.status, payload.error);
|
|
} else {
|
|
broadcastEntryChanged();
|
|
}
|
|
});
|
|
|
|
const cacheStore = new Map();
|
|
const SETTINGS_TTL_MS = 30 * 1000;
|
|
const PUBLIC_TTL_MS = 60 * 1000;
|
|
const SHARE_TTL_MS = 60 * 1000;
|
|
const STATS_TTL_MS = 15 * 1000;
|
|
const SYSTEM_TTL_MS = 30 * 1000;
|
|
|
|
function cacheGet(key) {
|
|
const entry = cacheStore.get(key);
|
|
if (!entry) return undefined;
|
|
if (entry.exp && entry.exp <= Date.now()) {
|
|
cacheStore.delete(key);
|
|
return undefined;
|
|
}
|
|
return entry.value;
|
|
}
|
|
|
|
function cacheSet(key, value, ttlMs) {
|
|
cacheStore.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 });
|
|
}
|
|
|
|
function cacheDrop(prefix) {
|
|
for (const key of cacheStore.keys()) {
|
|
if (key.startsWith(prefix)) cacheStore.delete(key);
|
|
}
|
|
}
|
|
|
|
async function cacheWrap(key, ttlMs, fn) {
|
|
const hit = cacheGet(key);
|
|
if (hit !== undefined) return hit;
|
|
const value = await fn();
|
|
cacheSet(key, value, ttlMs);
|
|
return value;
|
|
}
|
|
|
|
function scopeKey(user) {
|
|
if (!user) return 'anon';
|
|
const s = branchScope(user);
|
|
if (s.admin) return 'all';
|
|
return s.ids.length ? s.ids.slice().sort((a, b) => a - b).join('-') : 'none';
|
|
}
|
|
|
|
function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload:'); cacheDrop('public-settings'); }
|
|
function invalidateStudents() { cacheDrop('students:'); }
|
|
function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
|
function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
|
|
|
const sseClients = new Set();
|
|
function broadcastEntryChanged() {
|
|
const frame = `event: entries_changed\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`;
|
|
for (const client of sseClients) {
|
|
try { client.write(frame); } catch (e) { sseClients.delete(client); }
|
|
}
|
|
}
|
|
|
|
function broadcastAiStatus(entryId, status, error) {
|
|
const frame = `event: ai_status\ndata: ${JSON.stringify({ id: entryId, ai_status: status, ai_error: error || null, ts: Date.now() })}\n\n`;
|
|
for (const client of sseClients) {
|
|
try { client.write(frame); } catch (e) { sseClients.delete(client); }
|
|
}
|
|
}
|
|
|
|
app.get('/api/events', async (req, res) => {
|
|
try {
|
|
const token = req.headers['x-auth-token'] || req.query.token;
|
|
const user = await loadUserByToken(token);
|
|
if (!user || !user.is_active) return res.status(401).end();
|
|
} catch (e) {
|
|
return res.status(500).end();
|
|
}
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no'
|
|
});
|
|
res.write(':ok\n\n');
|
|
sseClients.add(res);
|
|
const ping = setInterval(() => {
|
|
try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); sseClients.delete(res); }
|
|
}, 25000);
|
|
req.on('close', () => { clearInterval(ping); sseClients.delete(res); });
|
|
});
|
|
function invalidateShare() { cacheDrop('share:payload:'); }
|
|
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
|
function invalidateAll() { cacheStore.clear(); }
|
|
|
|
const BAN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
const FAIL_WINDOW_MS = 15 * 60 * 1000;
|
|
const banMemory = new Map();
|
|
const failMemory = new Map();
|
|
|
|
function ipOf(req) {
|
|
return String(req.ip || req.socket?.remoteAddress || 'unknown').slice(0, 64);
|
|
}
|
|
|
|
async function banIP(req, reason, ms) {
|
|
await banIpAddr(ipOf(req), reason, ms, req);
|
|
}
|
|
|
|
async function banIpAddr(ip, reason, ms, actorReq) {
|
|
const until = new Date(Date.now() + ms);
|
|
banMemory.set(ip, { reason, banned_until: until.toISOString() });
|
|
await pool.query(
|
|
'INSERT INTO banned_ips (ip, reason, banned_until) VALUES ($1, $2, $3) ON CONFLICT (ip) DO UPDATE SET reason = $2, banned_until = $3',
|
|
[ip, reason, until.toISOString()]
|
|
);
|
|
await logAudit(actorReq, 'ip.ban', { ip, reason });
|
|
console.log(`IP banned: ${ip} (${reason})`);
|
|
}
|
|
|
|
function ipGuard(req, res, next) {
|
|
const entry = banMemory.get(ipOf(req));
|
|
if (entry && new Date(entry.banned_until) > new Date()) {
|
|
return res.status(403).json({ error: 'Доступ заблокирован' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function recordFailure(req, kind, limit, ms) {
|
|
const ip = ipOf(req);
|
|
const now = Date.now();
|
|
let entry = failMemory.get(kind + ':' + ip);
|
|
if (!entry || entry.resetAt <= now) {
|
|
entry = { count: 0, resetAt: now + FAIL_WINDOW_MS };
|
|
failMemory.set(kind + ':' + ip, entry);
|
|
}
|
|
entry.count += 1;
|
|
if (entry.count >= limit) {
|
|
failMemory.delete(kind + ':' + ip);
|
|
return banIP(req, kind, ms).catch(err => console.error('Ban error:', err));
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
|
|
async function loadBans() {
|
|
const { rows } = await pool.query('SELECT ip, reason, banned_until FROM banned_ips WHERE banned_until > now()');
|
|
const active = new Set();
|
|
for (const r of rows) {
|
|
active.add(r.ip);
|
|
banMemory.set(r.ip, { reason: r.reason, banned_until: r.banned_until });
|
|
}
|
|
for (const key of banMemory.keys()) {
|
|
if (!active.has(key)) banMemory.delete(key);
|
|
}
|
|
}
|
|
|
|
app.set('trust proxy', 'loopback');
|
|
|
|
const apiLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 300,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
|
});
|
|
|
|
const entryLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Слишком много запросов. Подождите немного.' },
|
|
});
|
|
|
|
const fileLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 300,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Слишком много запросов. Попробуйте позже.' },
|
|
});
|
|
|
|
const ADMIN_USERNAME = (process.env.ADMIN_USERNAME || 'admin').toLowerCase().trim();
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
|
if (!ADMIN_PASSWORD) {
|
|
console.warn('ADMIN_PASSWORD не задан. Первый админ не будет создан автоматически.');
|
|
}
|
|
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", "data:", "blob:"],
|
|
mediaSrc: ["'self'", "blob:"],
|
|
connectSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
formAction: ["'self'"],
|
|
frameAncestors: ["'none'"]
|
|
}
|
|
}
|
|
}));
|
|
app.use(express.json({ limit: '1mb' }));
|
|
app.use(ipGuard);
|
|
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
|
|
const THUMB_WIDTH = 480;
|
|
let sharp = null;
|
|
try { sharp = require('sharp'); } catch {}
|
|
if (sharp) {
|
|
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
|
|
}
|
|
|
|
function thumbFileFor(fp) {
|
|
const base = path.basename(fp).replace(/\.[^.]+$/, '') + '.webp';
|
|
return path.join(THUMBS_DIR, base);
|
|
}
|
|
|
|
async function sendImageThumb(res, fp) {
|
|
if (!sharp) {
|
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
return res.sendFile(fp);
|
|
}
|
|
const tp = thumbFileFor(fp);
|
|
try {
|
|
if (!fs.existsSync(tp)) {
|
|
const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
|
|
await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 72 }).toFile(tmp);
|
|
fs.renameSync(tmp, tp);
|
|
}
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
return res.sendFile(tp);
|
|
} catch {
|
|
try { if (fs.existsSync(tp)) fs.unlinkSync(tp); } catch {}
|
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
return res.sendFile(fp);
|
|
}
|
|
}
|
|
|
|
app.get('/uploads/thumb/:name', fileLimiter, async (req, res) => {
|
|
const name = req.params.name;
|
|
if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).end();
|
|
const fp = path.join(__dirname, 'uploads', name);
|
|
if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) return res.status(404).end();
|
|
return sendImageThumb(res, fp);
|
|
});
|
|
|
|
app.use((req, res, next) => {
|
|
const p = req.path;
|
|
if (!p.startsWith('/uploads') && !p.startsWith('/vendor')) {
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
}
|
|
next();
|
|
});
|
|
|
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads'), { maxAge: '365d', immutable: true }));
|
|
app.use('/vendor', express.static(path.join(__dirname, 'public', 'vendor'), { maxAge: '30d' }));
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function getDiskInfo() {
|
|
const totalDisk = fs.statfsSync ? fs.statfsSync(__dirname) : null;
|
|
if (totalDisk) {
|
|
const blockSize = totalDisk.bsize || 4096;
|
|
const total = totalDisk.blocks * blockSize;
|
|
const free = totalDisk.bfree * blockSize;
|
|
const used = total - free;
|
|
return {
|
|
total: formatBytes(total),
|
|
total_bytes: total,
|
|
used: formatBytes(used),
|
|
used_bytes: used,
|
|
free: formatBytes(free),
|
|
free_bytes: free,
|
|
used_pct: Math.round((used / total) * 100),
|
|
};
|
|
}
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
const out = execSync('df -B1 .', { encoding: 'utf8' });
|
|
const lines = out.trim().split('\n');
|
|
if (lines.length > 1) {
|
|
const parts = lines[1].split(/\s+/);
|
|
const total = parseInt(parts[1], 10);
|
|
const used = parseInt(parts[2], 10);
|
|
const free = parseInt(parts[3], 10);
|
|
return {
|
|
total: formatBytes(total),
|
|
total_bytes: total,
|
|
used: formatBytes(used),
|
|
used_bytes: used,
|
|
free: formatBytes(free),
|
|
free_bytes: free,
|
|
used_pct: Math.round((used / total) * 100),
|
|
};
|
|
}
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
function safeUser(u) {
|
|
return {
|
|
id: u.id,
|
|
username: u.username,
|
|
name: u.name,
|
|
role: u.role,
|
|
is_active: u.is_active,
|
|
branch_ids: u.branch_ids || [],
|
|
};
|
|
}
|
|
|
|
async function loadUserByToken(token) {
|
|
if (!token || typeof token !== 'string') return null;
|
|
const { rows } = await pool.query(
|
|
`SELECT u.id, u.username, u.name, u.role, u.is_active,
|
|
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
|
|
FROM sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
LEFT JOIN user_branches ub ON ub.user_id = u.id
|
|
WHERE s.token = $1 AND s.expires_at > now()
|
|
GROUP BY u.id`,
|
|
[token]
|
|
);
|
|
if (!rows.length) return null;
|
|
return rows[0];
|
|
}
|
|
|
|
async function requireAuth(req, res, next) {
|
|
try {
|
|
const token = req.headers['x-auth-token'];
|
|
const user = await loadUserByToken(token);
|
|
if (!user || !user.is_active) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
req.user = user;
|
|
req.authToken = token;
|
|
next();
|
|
} catch (e) {
|
|
console.error('requireAuth error:', e);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (req.user) {
|
|
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden: требуется роль администратора' });
|
|
return next();
|
|
}
|
|
requireAuth(req, res, () => {
|
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Forbidden: требуется роль администратора' });
|
|
next();
|
|
});
|
|
}
|
|
|
|
function branchScope(user) {
|
|
if (user.role === 'admin') return { admin: true, ids: null };
|
|
return { admin: false, ids: user.branch_ids || [] };
|
|
}
|
|
|
|
async function optionalAuth(req, res, next) {
|
|
try {
|
|
const token = req.headers['x-auth-token'];
|
|
if (token && typeof token === 'string') {
|
|
const user = await loadUserByToken(token);
|
|
if (user?.is_active) {
|
|
req.user = user;
|
|
req.authToken = token;
|
|
}
|
|
}
|
|
} catch {}
|
|
next();
|
|
}
|
|
|
|
function branchWhere(user, alias) {
|
|
const s = branchScope(user);
|
|
if (s.admin) return { where: '', params: [] };
|
|
const ids = s.ids;
|
|
if (!ids.length) return { where: ` AND 1 = 0`, params: [] };
|
|
return { where: ` AND ${alias}.branch_id IN (${ids.map((_, i) => '$' + (i + 1)).join(',')})`, params: ids };
|
|
}
|
|
|
|
function assertAccessToGroup(user, groupId, res) {
|
|
const s = branchScope(user);
|
|
if (s.admin) return true;
|
|
return s.ids.includes(Number(groupId));
|
|
}
|
|
|
|
async function groupBelongsToBranches(user, groupId) {
|
|
const s = branchScope(user);
|
|
if (s.admin) return true;
|
|
if (!s.ids.length) return false;
|
|
const { rows } = await pool.query(
|
|
'SELECT 1 AS one FROM groups WHERE id = $1 AND branch_id = ANY($2::int[])',
|
|
[groupId, s.ids]
|
|
);
|
|
return !!rows.length;
|
|
}
|
|
|
|
async function entryAccessible(user, entryId) {
|
|
const { rows } = await pool.query(
|
|
`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`,
|
|
[entryId]
|
|
);
|
|
if (!rows.length) return { found: false };
|
|
const groupId = rows[0].group_id;
|
|
if (user.role === 'admin') return { found: true, group_id: groupId };
|
|
const allowed = groupId && (await groupBelongsToBranches(user, groupId));
|
|
if (!allowed) {
|
|
console.warn(`[ACCESS DENIED] entryAccessible: user=${user.id} (${user.username}) branch_ids=${JSON.stringify(user.branch_ids)} entry=${entryId} group_id=${groupId}`);
|
|
}
|
|
return { found: true, group_id: groupId, allowed };
|
|
}
|
|
|
|
function fixFilename(str) {
|
|
try {
|
|
return Buffer.from(str, 'latin1').toString('utf8');
|
|
} catch {
|
|
return str;
|
|
}
|
|
}
|
|
|
|
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
|
const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.ico', '.heic', '.heif', '.jfif']);
|
|
const MAX_TOTAL_UPLOAD_BYTES = 30 * 1024 * 1024;
|
|
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (_, __, cb) => {
|
|
fs.mkdirSync('uploads', { recursive: true });
|
|
cb(null, 'uploads');
|
|
},
|
|
filename: (_, file, cb) => {
|
|
const original = fixFilename(file.originalname);
|
|
const ext = path.extname(original) || '.jpg';
|
|
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: 10 * 1024 * 1024 },
|
|
fileFilter: (req, file, cb) => {
|
|
file.originalname = fixFilename(file.originalname);
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
const isImageExt = ALLOWED_IMAGE_EXT.has(ext);
|
|
if (file.fieldname === 'photo') {
|
|
if (!isImageExt) {
|
|
return cb(new Error('Only images'));
|
|
}
|
|
}
|
|
if (ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
|
|
cb(null, true);
|
|
},
|
|
});
|
|
|
|
const ADMIN_ALLOWED_EXT = new Set(['.pdf', '.doc', '.docx', '.txt', '.md', '.html', '.htm', '.zip', '.rar', '.7z', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.heif', '.jfif']);
|
|
const adminUpload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (_, __, cb) => {
|
|
fs.mkdirSync('uploads', { recursive: true });
|
|
cb(null, 'uploads');
|
|
},
|
|
filename: (_, file, cb) => {
|
|
const original = fixFilename(file.originalname);
|
|
const ext = path.extname(original) || '.bin';
|
|
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: 10 * 1024 * 1024 },
|
|
fileFilter: (req, file, cb) => {
|
|
file.originalname = fixFilename(file.originalname);
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
if (ext && BLOCKED_EXT.test(ext) && !ADMIN_ALLOWED_EXT.has(ext)) {
|
|
return cb(new Error('Not allowed extension'));
|
|
}
|
|
cb(null, true);
|
|
},
|
|
});
|
|
|
|
async function getSetting(key, def) {
|
|
const cached = cacheGet('setting:' + key);
|
|
if (cached !== undefined) return cached;
|
|
const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]);
|
|
const value = rows.length ? rows[0].value : def;
|
|
cacheSet('setting:' + key, value, SETTINGS_TTL_MS);
|
|
return value;
|
|
}
|
|
|
|
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
|
|
|
function safeUnlink(relPath) {
|
|
if (!relPath || typeof relPath !== 'string') return;
|
|
const parts = String(relPath).replace(/\\/g, '/').replace(/^\/+/, '').split('/');
|
|
if (parts[0] === 'uploads') parts.shift();
|
|
if (!parts.length || parts.includes('..') || parts.includes('')) return;
|
|
const fp = path.resolve(UPLOADS_DIR, ...parts);
|
|
if (fp === UPLOADS_DIR || !fp.startsWith(UPLOADS_DIR + path.sep)) return;
|
|
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
|
}
|
|
|
|
function removeUpload(file) {
|
|
safeUnlink(file && file.path);
|
|
}
|
|
|
|
async function convertPhoto(file) {
|
|
if (!file || !file.path) return;
|
|
const ext = (path.extname(file.originalname || '') || '').toLowerCase();
|
|
if (ext !== '.heic' && ext !== '.heif' && ext !== '.jfif') return;
|
|
try {
|
|
if (ext === '.jfif') {
|
|
// JFIF is already JPEG, just rename to .jpg for consistency
|
|
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
|
|
const outPath = path.join(path.dirname(file.path), outName);
|
|
fs.renameSync(file.path, outPath);
|
|
file.path = outPath;
|
|
file.filename = outName;
|
|
file.originalname = outName;
|
|
return;
|
|
}
|
|
// HEIC/HEIF conversion
|
|
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
|
|
const outPath = path.join(path.dirname(file.path), outName);
|
|
const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.85 });
|
|
fs.writeFileSync(outPath, jpeg);
|
|
safeUnlink(file.path);
|
|
file.path = outPath;
|
|
file.filename = outName;
|
|
file.originalname = outName;
|
|
} catch (e) {
|
|
console.error('Photo convert failed:', e);
|
|
}
|
|
}
|
|
|
|
async function removeEntryFiles(entryId) {
|
|
const { rows } = await pool.query(
|
|
`SELECT photo_path AS p FROM entries WHERE id = $1
|
|
UNION ALL
|
|
SELECT path AS p FROM project_files WHERE entry_id = $1
|
|
UNION ALL
|
|
SELECT photo_path AS p FROM entry_photos WHERE entry_id = $1`,
|
|
[entryId]
|
|
);
|
|
rows.forEach(r => safeUnlink(r.p));
|
|
}
|
|
|
|
async function sweepOrphanedUploads() {
|
|
const dir = path.join(__dirname, 'uploads');
|
|
if (!fs.existsSync(dir)) return;
|
|
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }] = await Promise.all([
|
|
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
|
|
pool.query('SELECT path AS p FROM project_files'),
|
|
pool.query('SELECT photo_path AS p FROM group_photos'),
|
|
pool.query('SELECT photo_path AS p FROM entry_photos'),
|
|
]);
|
|
const refs = new Set();
|
|
[...photos, ...files, ...gphotos, ...ephotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
|
|
for (const f of fs.readdirSync(dir)) {
|
|
const fp = path.join(dir, f);
|
|
if (!fs.statSync(fp).isFile()) continue;
|
|
if (!refs.has('/uploads/' + f)) {
|
|
try { fs.unlinkSync(fp); } catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ensureAuditTable() {
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS audit_log (
|
|
id SERIAL PRIMARY KEY,
|
|
action VARCHAR(100) NOT NULL,
|
|
target JSONB,
|
|
ip VARCHAR(45),
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)`);
|
|
await pool.query('CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC)');
|
|
await pool.query('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL');
|
|
}
|
|
|
|
async function ensureBranchesTable() {
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS branches (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(200) NOT NULL UNIQUE,
|
|
address TEXT,
|
|
phone VARCHAR(50),
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)`);
|
|
await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`);
|
|
}
|
|
|
|
async function ensureBannedIpsTable() {
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS banned_ips (
|
|
ip VARCHAR(64) PRIMARY KEY,
|
|
reason VARCHAR(100) NOT NULL,
|
|
banned_until TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)`);
|
|
}
|
|
|
|
async function ensureEntryPhotosTable() {
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
|
id SERIAL PRIMARY KEY,
|
|
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
photo_path VARCHAR(255) NOT NULL,
|
|
caption TEXT,
|
|
sort_order INT DEFAULT 0,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)`);
|
|
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
|
|
}
|
|
|
|
async function ensureEntryAiColumns() {
|
|
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT`);
|
|
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT`);
|
|
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending'`);
|
|
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
|
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
|
|
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
|
const marker = await getSetting('ai_autocheck_migrated', '');
|
|
if (marker !== '1') {
|
|
await pool.query(`UPDATE entries SET description_original = description WHERE description_original IS NULL`);
|
|
await pool.query(`UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending'`);
|
|
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1') ON CONFLICT (key) DO UPDATE SET value = '1'`);
|
|
}
|
|
}
|
|
|
|
async function ensureUserTables() {
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
username VARCHAR(100) NOT NULL UNIQUE,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
name VARCHAR(150),
|
|
role VARCHAR(20) NOT NULL DEFAULT 'tutor' CHECK (role IN ('admin','tutor')),
|
|
is_active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
)`);
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS user_branches (
|
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (user_id, branch_id)
|
|
)`);
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS sessions (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token VARCHAR(64) NOT NULL UNIQUE,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
expires_at TIMESTAMPTZ NOT NULL
|
|
)`);
|
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)`);
|
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`);
|
|
await pool.query('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL');
|
|
}
|
|
|
|
async function ensureFirstAdmin() {
|
|
if (!ADMIN_PASSWORD) return;
|
|
const { rows } = await pool.query('SELECT id FROM users WHERE role = \'admin\' LIMIT 1');
|
|
if (rows.length) return;
|
|
const exists = await pool.query('SELECT id FROM users WHERE username = $1', [ADMIN_USERNAME]);
|
|
const username = exists.rows.length ? (ADMIN_USERNAME + '-' + Date.now()) : ADMIN_USERNAME;
|
|
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
|
|
await pool.query(
|
|
'INSERT INTO users (username, password_hash, name, role) VALUES ($1, $2, $3, $4)',
|
|
[username, hash, 'Администратор', 'admin']
|
|
);
|
|
console.log(`Создан первый администратор: ${username}`);
|
|
}
|
|
|
|
async function ensureUsersAndFirstAdmin() {
|
|
await ensureUserTables();
|
|
await ensureFirstAdmin();
|
|
}
|
|
|
|
async function logAudit(req, action, target) {
|
|
try {
|
|
await pool.query(
|
|
'INSERT INTO audit_log (user_id, action, target, ip) VALUES ($1, $2, $3, $4)',
|
|
[req?.user?.id || null, action, target ?? null, req?.ip?.slice(0, 45) || null]
|
|
);
|
|
} catch (e) {
|
|
console.error('audit log failed:', e);
|
|
}
|
|
}
|
|
|
|
// --- Auth ---
|
|
app.post('/api/auth/login', apiLimiter, async (req, res) => {
|
|
const rawUsername = typeof req.body?.username === 'string' ? req.body.username.trim().toLowerCase() : '';
|
|
const password = String(req.body?.password || '');
|
|
if (typeof req.body?.website === 'string' && req.body.website) {
|
|
await recordFailure(req, 'honeypot', 1, BAN_TTL_MS);
|
|
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
|
}
|
|
if (!rawUsername || rawUsername.length > 100 || !password) {
|
|
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
|
}
|
|
const username = rawUsername;
|
|
const { rows } = await pool.query('SELECT * FROM users WHERE username = $1', [username]);
|
|
const user = rows[0];
|
|
if (!user || !user.is_active) {
|
|
await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS);
|
|
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
|
}
|
|
const valid = await bcrypt.compare(password, user.password_hash || '');
|
|
if (!valid) {
|
|
await recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS);
|
|
return res.status(401).json({ error: 'Неверный логин или пароль' });
|
|
}
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
await pool.query('INSERT INTO sessions (user_id, token, expires_at) VALUES ($1, $2, $3)', [user.id, token, expiresAt.toISOString()]);
|
|
await logAudit(req, 'auth.login', { username: user.username });
|
|
res.json({ token, expires_at: expiresAt.toISOString() });
|
|
});
|
|
|
|
app.post('/api/auth/logout', requireAuth, async (req, res) => {
|
|
await pool.query('DELETE FROM sessions WHERE token = $1', [req.authToken]);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
|
res.json(safeUser(req.user));
|
|
});
|
|
|
|
app.get('/api/bans', requireAuth, requireAdmin, async (_, res) => {
|
|
const { rows } = await pool.query(
|
|
'SELECT ip, reason, banned_until, created_at FROM banned_ips WHERE banned_until > now() ORDER BY banned_until DESC'
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
app.post('/api/bans', requireAuth, requireAdmin, async (req, res) => {
|
|
const ip = String(req.body?.ip || '').trim();
|
|
if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) {
|
|
return res.status(400).json({ error: 'Некорректный IP' });
|
|
}
|
|
const reason = (typeof req.body?.reason === 'string' && req.body.reason.trim())
|
|
? req.body.reason.trim().slice(0, 100)
|
|
: 'manual';
|
|
const hours = Math.min(Math.max(parseInt(req.body?.hours, 10) || 24, 1), 24 * 30);
|
|
await banIpAddr(ip, reason, hours * 60 * 60 * 1000, req);
|
|
res.json({ ok: true, ip, reason, banned_until: banMemory.get(ip).banned_until });
|
|
});
|
|
|
|
app.delete('/api/bans/:ip', requireAuth, requireAdmin, async (req, res) => {
|
|
const ip = String(req.params.ip || '').trim();
|
|
if (!ip || ip.length > 64 || !/^[0-9a-fA-F:.]+$/.test(ip)) {
|
|
return res.status(400).json({ error: 'Некорректный IP' });
|
|
}
|
|
await pool.query('DELETE FROM banned_ips WHERE ip = $1', [ip]);
|
|
banMemory.delete(ip);
|
|
failMemory.forEach((_, key) => { if (key.endsWith(':' + ip)) failMemory.delete(key); });
|
|
await logAudit(req, 'ip.unban', { ip });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Users (admin only) ---
|
|
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
|
const { rows } = await pool.query(
|
|
`SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at,
|
|
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
|
|
FROM users u
|
|
LEFT JOIN user_branches ub ON ub.user_id = u.id
|
|
GROUP BY u.id
|
|
ORDER BY u.id`
|
|
);
|
|
res.json(rows.map(r => ({ ...r, branch_ids: r.branch_ids || [] })));
|
|
});
|
|
|
|
app.get('/api/users/branches', requireAuth, async (req, res) => {
|
|
const s = branchScope(req.user);
|
|
const params = [];
|
|
let where = '';
|
|
if (!s.admin) {
|
|
if (!s.ids.length) return res.json([]);
|
|
where = `WHERE id = ANY($1::int[])`;
|
|
params.push(s.ids);
|
|
}
|
|
const { rows } = await pool.query(`SELECT * FROM branches ${where} ORDER BY id`, params);
|
|
res.json(rows);
|
|
});
|
|
|
|
app.post('/api/users', requireAuth, requireAdmin, async (req, res) => {
|
|
const username = reqStr(req.body?.username, 100).toLowerCase();
|
|
const password = String(req.body?.password || '');
|
|
const name = optStr(req.body?.name, 150);
|
|
const role = req.body?.role === 'admin' ? 'admin' : 'tutor';
|
|
const isActive = req.body?.is_active !== false;
|
|
let branchIds = Array.isArray(req.body?.branch_ids) ?
|
|
[...new Set(req.body.branch_ids.map(Number).filter(Boolean))] : [];
|
|
if (role === 'admin') branchIds = [];
|
|
if (password.length < 6) return res.status(400).json({ error: 'Пароль должен быть не короче 6 символов' });
|
|
const hash = await bcrypt.hash(password, 10);
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const { rows } = await client.query(
|
|
'INSERT INTO users (username, password_hash, name, role, is_active) VALUES ($1, $2, $3, $4, $5) RETURNING *',
|
|
[username, hash, name, role, isActive]
|
|
);
|
|
const user = rows[0];
|
|
for (const b of branchIds) {
|
|
await client.query('INSERT INTO user_branches (user_id, branch_id) VALUES ($1, $2)', [user.id, b]);
|
|
}
|
|
await client.query('COMMIT');
|
|
await logAudit(req, 'user.create', { id: user.id, username: user.username, role });
|
|
res.status(201).json(safeUser({ ...user, branch_ids: branchIds }));
|
|
} catch (e) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
if (e.code === '23505') return res.status(409).json({ error: 'Логин уже занят' });
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
|
const id = req.params.id;
|
|
const current = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
|
|
if (!current.rows.length) return res.status(404).json({ error: 'Пользователь не найден' });
|
|
const target = current.rows[0];
|
|
if (target.id === req.user.id && req.body?.is_active === false) {
|
|
return res.status(400).json({ error: 'Нельзя деактивировать самого себя' });
|
|
}
|
|
if (target.id === req.user.id && req.body?.role && req.body.role !== 'admin') {
|
|
return res.status(400).json({ error: 'Нельзя снять роль администратора с самого себя' });
|
|
}
|
|
const name = req.body?.name !== undefined ? optStr(req.body.name, 150) : target.name;
|
|
const newRole = req.body?.role ? (req.body.role === 'admin' ? 'admin' : 'tutor') : target.role;
|
|
const isActive = req.body?.is_active !== undefined ? req.body.is_active !== false : target.is_active;
|
|
let branchIds = null;
|
|
if (Array.isArray(req.body?.branch_ids)) {
|
|
branchIds = [...new Set(req.body.branch_ids.map(Number).filter(Boolean))];
|
|
}
|
|
let hash = null;
|
|
if (req.body?.password) {
|
|
if (String(req.body.password).length < 6) return res.status(400).json({ error: 'Пароль должен быть не короче 6 символов' });
|
|
hash = await bcrypt.hash(String(req.body.password), 10);
|
|
}
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
if (hash) {
|
|
await client.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
|
|
}
|
|
await client.query(
|
|
'UPDATE users SET name = $1, role = $2, is_active = $3 WHERE id = $4',
|
|
[name, newRole, isActive, id]
|
|
);
|
|
if (branchIds !== null) {
|
|
await client.query('DELETE FROM user_branches WHERE user_id = $1', [id]);
|
|
if (newRole === 'tutor') {
|
|
for (const b of branchIds) {
|
|
await client.query('INSERT INTO user_branches (user_id, branch_id) VALUES ($1, $2)', [id, b]);
|
|
}
|
|
}
|
|
}
|
|
if (!isActive) {
|
|
await client.query('DELETE FROM sessions WHERE user_id = $1', [id]);
|
|
}
|
|
await client.query('COMMIT');
|
|
await logAudit(req, 'user.update', { id, role: newRole, is_active: isActive });
|
|
const fresh = await pool.query(
|
|
`SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at,
|
|
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS branch_ids
|
|
FROM users u LEFT JOIN user_branches ub ON ub.user_id = u.id WHERE u.id = $1 GROUP BY u.id`,
|
|
[id]
|
|
);
|
|
res.json(safeUser({ ...fresh.rows[0], branch_ids: fresh.rows[0].branch_ids || [] }));
|
|
} catch (e) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
if (e.code === '23505') return res.status(409).json({ error: 'Логин уже занят' });
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
|
if (req.params.id === String(req.user.id)) {
|
|
return res.status(400).json({ error: 'Нельзя удалить самого себя' });
|
|
}
|
|
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
|
await logAudit(req, 'user.delete', { id: req.params.id });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Settings ---
|
|
app.get('/api/settings', requireAdmin, async (_, res) => {
|
|
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
|
const out = {};
|
|
rows.forEach(r => { out[r.key] = r.value; });
|
|
res.json(out);
|
|
});
|
|
|
|
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
|
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
|
const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min'];
|
|
const defaults = { spam_interval_min: '30' };
|
|
const result = {};
|
|
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
|
return result;
|
|
});
|
|
res.json(out);
|
|
});
|
|
|
|
app.put('/api/settings', requireAdmin, async (req, res) => {
|
|
const { settings } = req.body;
|
|
if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' });
|
|
for (const [key, value] of Object.entries(settings)) {
|
|
if (key === 'spam_interval_min') {
|
|
const n = parseInt(String(value), 10);
|
|
if (!Number.isFinite(n) || n < 1) {
|
|
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 });
|
|
invalidateSettings();
|
|
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
|
const out = {};
|
|
rows.forEach(r => { out[r.key] = r.value; });
|
|
res.json(out);
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.get('/api/audit', requireAdmin, async (req, res) => {
|
|
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000);
|
|
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
|
const { rows } = await pool.query(
|
|
`SELECT a.id, a.action, a.target, a.ip, a.created_at, a.user_id, u.username AS user_name
|
|
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
|
|
ORDER BY a.id DESC LIMIT $1 OFFSET $2`,
|
|
[limit, offset]
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
// --- Backup / Restore ---
|
|
const gunzipAsync = require('util').promisify(require('zlib').gunzip);
|
|
const zlib = require('zlib');
|
|
const tar = require('tar');
|
|
const os = require('os');
|
|
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
|
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
|
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
|
let entryAutoChecker = null;
|
|
|
|
async function getAiPrompt() {
|
|
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT);
|
|
return prompt;
|
|
}
|
|
|
|
async function aiCorrectText(text) {
|
|
try {
|
|
const systemPrompt = await getAiPrompt();
|
|
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: AI_MODEL,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: text }
|
|
],
|
|
temperature: 0.1,
|
|
max_tokens: 512
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
|
const data = await res.json();
|
|
return data.choices?.[0]?.message?.content?.trim() || text;
|
|
} catch (e) {
|
|
console.error('AI correct error:', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
const BACKUP_UPLOAD_LIMIT_MB = parseInt(process.env.BACKUP_UPLOAD_LIMIT_MB || '500', 10);
|
|
|
|
const uploadBackup = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (_, __, cb) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-up-'));
|
|
cb(null, dir);
|
|
},
|
|
filename: (_, file, cb) => {
|
|
const ext = path.extname(file.originalname) || '.bin';
|
|
cb(null, `backup-${Date.now()}${ext}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: BACKUP_UPLOAD_LIMIT_MB * 1024 * 1024 },
|
|
});
|
|
|
|
const SAFE_NAME = /^[\w,.()-]+$/;
|
|
|
|
function isSafeUploadPath(p) {
|
|
if (typeof p !== 'string' || !p.startsWith('/uploads/')) return false;
|
|
const name = p.slice('/uploads/'.length);
|
|
return name !== '' && !name.includes('/') && !name.includes('..') && SAFE_NAME.test(name);
|
|
}
|
|
|
|
function reqInt(v) {
|
|
const n = Number(v);
|
|
if (!Number.isInteger(n)) throw new Error('Invalid integer');
|
|
return n;
|
|
}
|
|
|
|
function optInt(v, lo = -Infinity, hi = Infinity) {
|
|
if (v === null || v === undefined || v === '') return null;
|
|
const n = Number(v);
|
|
if (!Number.isInteger(n) || n < lo || n > hi) throw new Error('Invalid integer');
|
|
return n;
|
|
}
|
|
|
|
function reqStr(v, max) {
|
|
if (typeof v !== 'string') throw new Error('Invalid string');
|
|
const s = v.trim();
|
|
if (!s || s.length > max) throw new Error('Invalid string length');
|
|
return s;
|
|
}
|
|
|
|
function optStr(v, max) {
|
|
if (v === null || v === undefined) return null;
|
|
return reqStr(v, max);
|
|
}
|
|
|
|
function optTs(v) {
|
|
if (v === null || v === undefined) return null;
|
|
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp');
|
|
return v;
|
|
}
|
|
|
|
function optTime(v) {
|
|
if (v === null || v === undefined) return null;
|
|
if (typeof v !== 'string' || !/^\d{2}:\d{2}(:\d{2})?$/.test(v)) throw new Error('Invalid time');
|
|
return v;
|
|
}
|
|
|
|
function optDate(v) {
|
|
if (v === null || v === undefined) return null;
|
|
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error('Invalid date');
|
|
return v;
|
|
}
|
|
|
|
function optBool(v) {
|
|
if (v === null || v === undefined) return null;
|
|
return !!v;
|
|
}
|
|
|
|
function reqToken(v) {
|
|
if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token');
|
|
return v;
|
|
}
|
|
|
|
function reqUploadPath(v, max) {
|
|
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid path');
|
|
if (!isSafeUploadPath(v)) throw new Error('Invalid upload path');
|
|
return v;
|
|
}
|
|
|
|
function optUploadPath(v, max) {
|
|
if (v === null || v === undefined) return null;
|
|
return reqUploadPath(v, max);
|
|
}
|
|
|
|
const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
|
|
|
|
function optAiText(v, max) {
|
|
if (v === null || v === undefined) return null;
|
|
return reqStr(v, max);
|
|
}
|
|
|
|
function reqAiStatus(v, fallback) {
|
|
if (v === null || v === undefined) return fallback;
|
|
const s = String(v);
|
|
if (s === 'processing') return 'pending';
|
|
return AI_STATUSES.has(s) ? s : fallback;
|
|
}
|
|
|
|
function normalizeRestoreData(data) {
|
|
const groups = (data.groups || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
name: reqStr(x.name, 100),
|
|
created_at: optTs(x.created_at),
|
|
day_of_week: optInt(x.day_of_week, 0, 6),
|
|
time_start: optTime(x.time_start),
|
|
time_end: optTime(x.time_end),
|
|
branch_id: optInt(x.branch_id, 0, 2147483647),
|
|
}));
|
|
const students = (data.students || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
name: reqStr(x.name, 150),
|
|
created_at: optTs(x.created_at),
|
|
group_id: optInt(x.group_id, 0, 2147483647),
|
|
}));
|
|
const entries = (data.entries || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
student_name: reqStr(x.student_name, 150),
|
|
group_id: reqInt(x.group_id),
|
|
description: reqStr(x.description, 100000),
|
|
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
|
|
description_ai: optAiText(x.description_ai, 100000),
|
|
ai_status: reqAiStatus(x.ai_status, 'skipped'),
|
|
ai_checked_at: optTs(x.ai_checked_at),
|
|
ai_error: optAiText(x.ai_error, 500),
|
|
photo_path: optUploadPath(x.photo_path, 255),
|
|
deleted_at: optTs(x.deleted_at),
|
|
created_at: optTs(x.created_at),
|
|
}));
|
|
const project_files = (data.project_files || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
entry_id: optInt(x.entry_id, 0, 2147483647),
|
|
token: reqToken(x.token),
|
|
path: reqUploadPath(x.path, 255),
|
|
name: reqStr(x.name, 255),
|
|
created_at: optTs(x.created_at),
|
|
}));
|
|
const branches = (data.branches || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
name: reqStr(x.name, 200),
|
|
address: optStr(x.address, 1000),
|
|
phone: optStr(x.phone, 50),
|
|
created_at: optTs(x.created_at),
|
|
}));
|
|
const users = (data.users || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
username: reqStr(x.username, 100),
|
|
password_hash: reqStr(x.password_hash, 255),
|
|
name: optStr(x.name, 150),
|
|
role: (x.role === 'admin' || x.role === 'tutor') ? x.role : 'tutor',
|
|
is_active: !!x.is_active,
|
|
created_at: optTs(x.created_at),
|
|
}));
|
|
const user_branches = (data.user_branches || []).map(x => ({
|
|
user_id: reqInt(x.user_id),
|
|
branch_id: reqInt(x.branch_id),
|
|
}));
|
|
const entry_photos = (data.entry_photos || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
entry_id: reqInt(x.entry_id),
|
|
photo_path: reqUploadPath(x.photo_path, 255),
|
|
caption: optStr(x.caption, 10000),
|
|
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
|
|
created_at: optTs(x.created_at),
|
|
}));
|
|
const group_photos = (data.group_photos || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
group_id: reqInt(x.group_id),
|
|
photo_path: reqUploadPath(x.photo_path, 255),
|
|
caption: optStr(x.caption, 10000),
|
|
taken_at: optDate(x.taken_at),
|
|
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
|
|
created_at: optTs(x.created_at),
|
|
}));
|
|
const share_links = (data.share_links || []).map(x => ({
|
|
id: reqInt(x.id),
|
|
token: optStr(x.token, 40),
|
|
name: reqStr(x.name, 200),
|
|
group_id: optInt(x.group_id, 0, 2147483647),
|
|
student_name: optStr(x.student_name, 150),
|
|
date_from: optDate(x.date_from),
|
|
date_to: optDate(x.date_to),
|
|
show_student_names: optBool(x.show_student_names),
|
|
expires_at: optTs(x.expires_at),
|
|
access_password_hash: optStr(x.access_password_hash, 255),
|
|
message: optStr(x.message, 2000),
|
|
link_url: optStr(x.link_url, 500),
|
|
show_student_message: optBool(x.show_student_message),
|
|
show_entry_date: optBool(x.show_entry_date),
|
|
show_group_photos: optBool(x.show_group_photos),
|
|
created_at: optTs(x.created_at),
|
|
}));
|
|
const settings = {};
|
|
for (const [k, v] of Object.entries(data.settings || {})) {
|
|
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
|
}
|
|
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links };
|
|
}
|
|
|
|
app.get('/api/backup', requireAdmin, async (req, res) => {
|
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
|
try {
|
|
const [g, s, e, st, pf, br, us, ub, gp, ep] = await Promise.all([
|
|
pool.query('SELECT * FROM groups ORDER BY id'),
|
|
pool.query('SELECT * FROM students ORDER BY id'),
|
|
pool.query('SELECT * FROM entries ORDER BY id'),
|
|
pool.query('SELECT key, value FROM settings'),
|
|
pool.query('SELECT * FROM project_files ORDER BY id'),
|
|
pool.query('SELECT * FROM branches ORDER BY id'),
|
|
pool.query('SELECT * FROM users ORDER BY id'),
|
|
pool.query('SELECT * FROM user_branches ORDER BY user_id, branch_id'),
|
|
pool.query('SELECT * FROM group_photos ORDER BY id'),
|
|
pool.query('SELECT * FROM entry_photos ORDER BY id'),
|
|
]);
|
|
const settings = {};
|
|
st.rows.forEach(r => { settings[r.key] = r.value; });
|
|
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows };
|
|
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
|
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
|
const dir = path.join(__dirname, 'uploads');
|
|
if (fs.existsSync(dir)) {
|
|
for (const f of fs.readdirSync(dir)) {
|
|
const fp = path.join(dir, f);
|
|
if (fs.statSync(fp).isFile() && SAFE_NAME.test(f)) fs.copyFileSync(fp, path.join(staging, 'uploads', f));
|
|
}
|
|
}
|
|
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
|
const outPath = path.join(os.tmpdir(), `whatido-backup-${stamp}.tar.gz`);
|
|
await tar.c({ gzip: true, file: outPath, cwd: staging }, ['data.json', 'uploads']);
|
|
const buf = fs.readFileSync(outPath);
|
|
fs.unlinkSync(outPath);
|
|
res.setHeader('Content-Type', 'application/gzip');
|
|
res.setHeader('Content-Disposition', `attachment; filename="whatido-backup-${stamp}.tar.gz"`);
|
|
await logAudit(req, 'backup.download', {});
|
|
res.send(buf);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
} finally {
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function cleanupUpload(req) {
|
|
try {
|
|
if (req?.file?.destination) fs.rmSync(req.file.destination, { recursive: true, force: true });
|
|
} catch {}
|
|
}
|
|
|
|
function peekGunzip(filePath, n) {
|
|
return new Promise((resolve) => {
|
|
const gunz = zlib.createGunzip();
|
|
const bufs = [];
|
|
let total = 0;
|
|
let done = false;
|
|
gunz.on('data', (c) => {
|
|
if (done) return;
|
|
const need = n - total;
|
|
bufs.push(c.length > need ? c.subarray(0, need) : c);
|
|
total += Math.min(c.length, need);
|
|
if (total >= n) {
|
|
done = true;
|
|
gunz.destroy();
|
|
}
|
|
});
|
|
gunz.on('error', () => resolve(null));
|
|
gunz.on('close', () => resolve(Buffer.concat(bufs)));
|
|
fs.createReadStream(filePath).pipe(gunz);
|
|
});
|
|
}
|
|
|
|
app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req, res) => {
|
|
if (!req.file) return res.status(400).json({ error: 'backup file required' });
|
|
let data;
|
|
let legacyPhotos = [];
|
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-rst-'));
|
|
try {
|
|
const head = await peekGunzip(req.file.path, 8);
|
|
if (head && head[0] === 0x7b) {
|
|
const buf = await fs.promises.readFile(req.file.path);
|
|
const gunz = await gunzipAsync(buf);
|
|
data = JSON.parse(gunz.toString('utf8'));
|
|
legacyPhotos = data.photos || [];
|
|
} else {
|
|
await tar.x({ file: req.file.path, cwd: staging });
|
|
data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8'));
|
|
}
|
|
} catch {
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
cleanupUpload(req);
|
|
return res.status(400).json({ error: 'Неверный файл бэкапа' });
|
|
}
|
|
if (!data || data.version !== 1 || !Array.isArray(data.groups)) {
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
cleanupUpload(req);
|
|
return res.status(400).json({ error: 'Неверный формат бэкапа' });
|
|
}
|
|
let ndata;
|
|
try {
|
|
ndata = normalizeRestoreData(data);
|
|
} catch (e) {
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
cleanupUpload(req);
|
|
return res.status(400).json({ error: 'Неверный формат бэкапа: ' + e.message });
|
|
}
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
await client.query('DELETE FROM project_files');
|
|
await client.query('DELETE FROM entries');
|
|
await client.query('DELETE FROM students');
|
|
await client.query('DELETE FROM groups');
|
|
await client.query('DELETE FROM user_branches');
|
|
await client.query('DELETE FROM sessions');
|
|
await client.query('DELETE FROM users');
|
|
await client.query('DELETE FROM branches');
|
|
for (const x of ndata.branches) {
|
|
await client.query(
|
|
'INSERT INTO branches (id, name, address, phone, created_at) VALUES ($1,$2,$3,$4,$5)',
|
|
[x.id, x.name, x.address, x.phone, x.created_at]
|
|
);
|
|
}
|
|
for (const x of ndata.groups) {
|
|
await client.query(
|
|
'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end, branch_id) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
[x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end, x.branch_id]
|
|
);
|
|
}
|
|
for (const x of ndata.students) {
|
|
await client.query(
|
|
'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)',
|
|
[x.id, x.name, x.created_at, x.group_id]
|
|
);
|
|
}
|
|
for (const x of ndata.entries) {
|
|
await client.query(
|
|
'INSERT INTO entries (id, student_name, group_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
|
|
[x.id, x.student_name, x.group_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
|
|
);
|
|
}
|
|
for (const x of ndata.project_files) {
|
|
await client.query(
|
|
'INSERT INTO project_files (id, entry_id, token, path, name, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
[x.id, x.entry_id, x.token, x.path, x.name, x.created_at]
|
|
);
|
|
}
|
|
for (const x of ndata.group_photos) {
|
|
await client.query(
|
|
'INSERT INTO group_photos (id, group_id, photo_path, caption, taken_at, sort_order, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
[x.id, x.group_id, x.photo_path, x.caption, x.taken_at, x.sort_order, x.created_at]
|
|
);
|
|
}
|
|
for (const x of ndata.entry_photos) {
|
|
await client.query(
|
|
'INSERT INTO entry_photos (id, entry_id, photo_path, caption, sort_order, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
[x.id, x.entry_id, x.photo_path, x.caption, x.sort_order, x.created_at]
|
|
);
|
|
}
|
|
for (const x of ndata.users) {
|
|
await client.query(
|
|
'INSERT INTO users (id, username, password_hash, name, role, is_active, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
[x.id, x.username, x.password_hash, x.name, x.role, x.is_active, x.created_at]
|
|
);
|
|
}
|
|
for (const x of ndata.user_branches) {
|
|
await client.query(
|
|
'INSERT INTO user_branches (user_id, branch_id) VALUES ($1,$2)',
|
|
[x.user_id, x.branch_id]
|
|
);
|
|
}
|
|
for (const [k, v] of Object.entries(ndata.settings)) {
|
|
await client.query(
|
|
'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
|
|
[k, String(v ?? '')]
|
|
);
|
|
}
|
|
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos']) {
|
|
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
|
|
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
|
}
|
|
await client.query('COMMIT');
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
cleanupUpload(req);
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
const dir = path.join(__dirname, 'uploads');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
if (legacyPhotos.length) {
|
|
for (const p of legacyPhotos) {
|
|
if (!p.path || !SAFE_NAME.test(p.path)) continue;
|
|
fs.writeFileSync(path.join(dir, p.path), Buffer.from(p.data, 'base64'));
|
|
}
|
|
} else {
|
|
const src = path.join(staging, 'uploads');
|
|
if (fs.existsSync(src)) {
|
|
for (const f of fs.readdirSync(src)) {
|
|
if (!SAFE_NAME.test(f)) continue;
|
|
const fp = path.join(src, f);
|
|
if (fs.statSync(fp).isFile()) fs.copyFileSync(fp, path.join(dir, f));
|
|
}
|
|
}
|
|
}
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
cleanupUpload(req);
|
|
await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
|
await ensureFirstAdmin().catch(err => console.error('First admin:', err));
|
|
await logAudit(req, 'backup.restore', {});
|
|
invalidateAll();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Share links ---
|
|
function normDates(o) {
|
|
if (o && o.date_from) o.date_from = o.date_from instanceof Date ? o.date_from.toISOString().slice(0, 10) : String(o.date_from).slice(0, 10);
|
|
if (o && o.date_to) o.date_to = o.date_to instanceof Date ? o.date_to.toISOString().slice(0, 10) : String(o.date_to).slice(0, 10);
|
|
return o;
|
|
}
|
|
|
|
function parseShareMessage(v) {
|
|
const s = typeof v === 'string' ? v.trim() : '';
|
|
if (s.length > 2000) throw new Error('Сообщение слишком длинное (макс. 2000 символов)');
|
|
return s || null;
|
|
}
|
|
|
|
function parseShareLinkUrl(v) {
|
|
const s = typeof v === 'string' ? v.trim() : '';
|
|
if (!s) return null;
|
|
if (s.length > 500) throw new Error('Ссылка слишком длинная (макс. 500 символов)');
|
|
let u;
|
|
try { u = new URL(s); } catch { throw new Error('Некорректная ссылка'); }
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Ссылка должна начинаться с http:// или https://');
|
|
return s;
|
|
}
|
|
|
|
app.get('/api/links', requireAuth, async (req, res) => {
|
|
const s = branchScope(req.user);
|
|
let where = '';
|
|
const params = [];
|
|
if (!s.admin) {
|
|
if (s.ids.length) {
|
|
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
|
|
where = `WHERE l.group_id IN (${ph})`;
|
|
} else {
|
|
where = `WHERE l.group_id IS NULL AND 1 = 0`;
|
|
}
|
|
}
|
|
const { rows } = await pool.query(
|
|
`SELECT l.*, g.name AS group_name FROM share_links l
|
|
LEFT JOIN groups g ON g.id = l.group_id ${where} ORDER BY l.created_at DESC`,
|
|
params
|
|
);
|
|
rows.forEach(normDates);
|
|
res.json(rows);
|
|
});
|
|
|
|
app.post('/api/links', requireAuth, async (req, res) => {
|
|
const { name, group_id, student_name, date_from, date_to, expires_at, access_password } = req.body;
|
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
|
let message, linkUrl;
|
|
try {
|
|
message = parseShareMessage(req.body.message);
|
|
linkUrl = parseShareLinkUrl(req.body.link_url);
|
|
} catch (e) {
|
|
return res.status(400).json({ error: e.message });
|
|
}
|
|
if (req.user.role !== 'admin' && group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const token = crypto.randomBytes(20).toString('hex');
|
|
let passwordHash = null;
|
|
if (access_password && access_password.trim()) {
|
|
passwordHash = await bcrypt.hash(access_password.trim(), 10);
|
|
}
|
|
let expiresAt = null;
|
|
if (expires_at) {
|
|
const parsed = new Date(expires_at);
|
|
if (isNaN(parsed.getTime())) {
|
|
return res.status(400).json({ error: 'Неверный формат даты истечения' });
|
|
}
|
|
expiresAt = parsed.toISOString();
|
|
} else {
|
|
// Default 7 days from now
|
|
const defaultExp = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
expiresAt = defaultExp.toISOString();
|
|
}
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, show_student_names, expires_at, access_password_hash, message, link_url, show_student_message, show_entry_date, show_group_photos)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`,
|
|
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, expiresAt, passwordHash, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos]
|
|
);
|
|
await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() });
|
|
invalidateShare();
|
|
res.status(201).json(normDates(rows[0]));
|
|
});
|
|
|
|
app.put('/api/links/:id', requireAuth, async (req, res) => {
|
|
const { name, group_id, student_name, date_from, date_to, expires_at, access_password } = req.body;
|
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
|
let message, linkUrl;
|
|
try {
|
|
message = parseShareMessage(req.body.message);
|
|
linkUrl = parseShareLinkUrl(req.body.link_url);
|
|
} catch (e) {
|
|
return res.status(400).json({ error: e.message });
|
|
}
|
|
if (req.user.role !== 'admin') {
|
|
const { rows: lr } = await pool.query('SELECT group_id FROM share_links WHERE id = $1', [req.params.id]);
|
|
if (!lr.length) return res.status(404).json({ error: 'Не найдено' });
|
|
const curGid = lr[0].group_id;
|
|
if (curGid && !(await groupBelongsToBranches(req.user, curGid))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой ссылке' });
|
|
}
|
|
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
}
|
|
let passwordHash = undefined;
|
|
if (access_password !== undefined) {
|
|
if (access_password && access_password.trim()) {
|
|
passwordHash = await bcrypt.hash(access_password.trim(), 10);
|
|
} else {
|
|
passwordHash = null; // Clear password if empty string sent
|
|
}
|
|
}
|
|
let expiresAt = undefined;
|
|
if (expires_at !== undefined) {
|
|
if (expires_at) {
|
|
const parsed = new Date(expires_at);
|
|
if (isNaN(parsed.getTime())) {
|
|
return res.status(400).json({ error: 'Неверный формат даты истечения' });
|
|
}
|
|
expiresAt = parsed.toISOString();
|
|
} else {
|
|
expiresAt = null; // Clear expiry if null sent
|
|
}
|
|
}
|
|
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'show_student_names = $6', 'message = $7', 'link_url = $8', 'show_student_message = $9', 'show_entry_date = $10', 'show_group_photos = $11'];
|
|
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.body.show_student_names == null ? null : !!req.body.show_student_names, message, linkUrl, req.body.show_student_message == null ? null : !!req.body.show_student_message, req.body.show_entry_date == null ? null : !!req.body.show_entry_date, req.body.show_group_photos == null ? null : !!req.body.show_group_photos];
|
|
let paramIdx = 12;
|
|
if (passwordHash !== undefined) {
|
|
fields.push(`access_password_hash = $${paramIdx++}`);
|
|
values.push(passwordHash);
|
|
}
|
|
if (expiresAt !== undefined) {
|
|
fields.push(`expires_at = $${paramIdx++}`);
|
|
values.push(expiresAt);
|
|
}
|
|
values.push(req.params.id);
|
|
const { rows } = await pool.query(
|
|
`UPDATE share_links SET ${fields.join(', ')} WHERE id = $${paramIdx} RETURNING *`,
|
|
values
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
await logAudit(req, 'link.update', { id: req.params.id, name: name.trim() });
|
|
invalidateShare();
|
|
res.json(normDates(rows[0]));
|
|
});
|
|
|
|
app.delete('/api/links/:id', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const { rows: lr } = await pool.query('SELECT group_id FROM share_links WHERE id = $1', [req.params.id]);
|
|
if (!lr.length) return res.status(404).json({ error: 'Не найдено' });
|
|
if (lr[0].group_id && !(await groupBelongsToBranches(req.user, lr[0].group_id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой ссылке' });
|
|
}
|
|
}
|
|
await pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]);
|
|
await logAudit(req, 'link.delete', { id: req.params.id });
|
|
invalidateShare();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
|
const { rows } = await pool.query(
|
|
`SELECT l.*, g.name AS group_name FROM share_links l
|
|
LEFT JOIN groups g ON g.id = l.group_id WHERE l.token = $1`,
|
|
[req.params.token]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
|
|
normDates(rows[0]);
|
|
const l = rows[0];
|
|
|
|
// Check expiry
|
|
if (l.expires_at && new Date(l.expires_at) < new Date()) {
|
|
return res.status(410).json({ error: 'Срок действия ссылки истёк' });
|
|
}
|
|
|
|
// Check password
|
|
if (l.access_password_hash) {
|
|
const providedPassword = req.headers['x-share-password'] || req.query.password;
|
|
if (!providedPassword) {
|
|
return res.status(401).json({ error: 'Требуется пароль', passwordRequired: true });
|
|
}
|
|
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
|
if (!valid) {
|
|
await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS);
|
|
return res.status(401).json({ error: 'Неверный пароль' });
|
|
}
|
|
}
|
|
|
|
const payload = await cacheWrap('share:payload:' + req.params.token, SHARE_TTL_MS, async () => {
|
|
const conditions = [];
|
|
const params = [];
|
|
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
|
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
|
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
|
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
|
conditions.push('e.deleted_at IS NULL');
|
|
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
|
const { rows: entries } = await pool.query(
|
|
`SELECT e.*, g.name AS group_name FROM entries e
|
|
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`,
|
|
params
|
|
);
|
|
let files = {};
|
|
if (entries.length) {
|
|
const fRes = await pool.query(
|
|
'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
|
[entries.map(r => r.id)]
|
|
);
|
|
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push({ token: f.token, name: f.name }); });
|
|
}
|
|
entries.forEach(r => { r.files = files[r.id] || []; });
|
|
|
|
const showStudentNames = l.show_student_names != null ? l.show_student_names : (await getSetting('share_show_student_names', 'true')) !== 'false';
|
|
|
|
if (!showStudentNames) {
|
|
const nameMap = new Map();
|
|
let counter = 1;
|
|
entries.forEach(e => {
|
|
if (!nameMap.has(e.student_name)) {
|
|
nameMap.set(e.student_name, `Ученик ${counter++}`);
|
|
}
|
|
e.student_name = nameMap.get(e.student_name);
|
|
});
|
|
}
|
|
|
|
let photos = [];
|
|
if (l.group_id) {
|
|
const pRes = await pool.query(
|
|
`SELECT id, photo_path, caption, taken_at, created_at FROM group_photos
|
|
WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC LIMIT 12`,
|
|
[l.group_id]
|
|
);
|
|
photos = pRes.rows.map(r => ({ id: r.id, photo_path: r.photo_path, caption: r.caption, taken_at: r.taken_at, created_at: r.created_at }));
|
|
}
|
|
return {
|
|
name: l.name,
|
|
group_name: l.group_name,
|
|
student_name: l.student_name,
|
|
group_id: l.group_id,
|
|
date_from: l.date_from,
|
|
date_to: l.date_to,
|
|
message: l.message,
|
|
link_url: l.link_url,
|
|
created_at: l.created_at,
|
|
expires_at: l.expires_at,
|
|
show_student_names: showStudentNames,
|
|
show_student_message: l.show_student_message != null ? l.show_student_message : (await getSetting('share_show_student_message', 'false')) === 'true',
|
|
show_entry_date: l.show_entry_date != null ? l.show_entry_date : (await getSetting('share_show_entry_date', 'false')) === 'true',
|
|
show_group_photos: l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false',
|
|
entries,
|
|
photos: (l.show_group_photos != null ? l.show_group_photos : (await getSetting('share_show_group_photos', 'true')) !== 'false') ? photos : [],
|
|
};
|
|
});
|
|
res.json(payload);
|
|
});
|
|
|
|
app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res) => {
|
|
const { shareToken, fileToken } = req.params;
|
|
const { rows: shareRows } = await pool.query(
|
|
`SELECT l.* FROM share_links l WHERE l.token = $1`, [shareToken]
|
|
);
|
|
if (!shareRows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
|
|
const l = shareRows[0];
|
|
if (l.expires_at && new Date(l.expires_at) < new Date()) {
|
|
return res.status(410).json({ error: 'Срок действия ссылки истёк' });
|
|
}
|
|
if (l.access_password_hash) {
|
|
const providedPassword = req.headers['x-share-password'] || req.query.password;
|
|
if (!providedPassword) return res.status(401).json({ error: 'Требуется пароль' });
|
|
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
|
if (!valid) {
|
|
await recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS);
|
|
return res.status(401).json({ error: 'Неверный пароль' });
|
|
}
|
|
}
|
|
const conditions = ['e.deleted_at IS NULL'];
|
|
const params = [];
|
|
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
|
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
|
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
|
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
|
params.push(fileToken);
|
|
const { rows } = await pool.query(
|
|
`SELECT pf.path, pf.name FROM project_files pf
|
|
JOIN entries e ON e.id = pf.entry_id
|
|
WHERE pf.token = $${params.length} AND ${conditions.join(' AND ')}`,
|
|
params
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
|
const r = rows[0];
|
|
const fp = path.join(__dirname, r.path);
|
|
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' });
|
|
if (isImageName(r.name)) {
|
|
if (req.query.thumb) return sendImageThumb(res, fp);
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
return res.sendFile(fp);
|
|
}
|
|
return res.download(fp, r.name);
|
|
});
|
|
|
|
app.get('/s/:token', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'share.html'));
|
|
});
|
|
|
|
// --- Groups CRUD ---
|
|
app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => {
|
|
const rows = await cacheWrap('groups:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
|
|
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
|
const { rows } = await pool.query(
|
|
`SELECT g.*,
|
|
COALESCE(g.cover_path,
|
|
(SELECT photo_path FROM group_photos
|
|
WHERE group_id = g.id
|
|
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
|
|
LIMIT 1)) AS cover_path,
|
|
b.name AS branch_name
|
|
FROM groups g
|
|
LEFT JOIN branches b ON b.id = g.branch_id
|
|
WHERE 1=1${bw.where}
|
|
ORDER BY g.id`,
|
|
bw.params
|
|
);
|
|
return rows;
|
|
});
|
|
res.json(rows);
|
|
});
|
|
|
|
app.get('/api/groups/active', apiLimiter, async (_, res) => {
|
|
const rows = await cacheWrap('groups:active', PUBLIC_TTL_MS, async () => {
|
|
const { rows } = await pool.query(`
|
|
SELECT * FROM groups
|
|
WHERE day_of_week IS NOT NULL
|
|
AND time_start IS NOT NULL
|
|
AND time_end IS NOT NULL
|
|
AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
|
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end
|
|
ORDER BY id
|
|
`);
|
|
return rows;
|
|
});
|
|
res.json(rows);
|
|
});
|
|
|
|
app.put('/api/groups/:id', requireAuth, async (req, res) => {
|
|
const { name, day_of_week, time_start, time_end, branch_id } = req.body;
|
|
if (!(await groupBelongsToBranches(req.user, req.params.id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const isAdmin = req.user.role === 'admin';
|
|
if (!isAdmin && branch_id !== undefined) {
|
|
return res.status(403).json({ error: 'Назначение филиала — только для администратора' });
|
|
}
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`UPDATE groups SET
|
|
name = COALESCE($1, name),
|
|
day_of_week = $2,
|
|
time_start = $3,
|
|
time_end = $4,
|
|
branch_id = $5
|
|
WHERE id = $6 RETURNING *`,
|
|
[name,
|
|
day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week,
|
|
time_start || null, time_end || null,
|
|
branch_id === undefined || branch_id === null || branch_id === '' ? null : branch_id,
|
|
req.params.id]
|
|
);
|
|
await logAudit(req, 'group.update', { id: req.params.id, ...req.body });
|
|
invalidateGroups();
|
|
invalidateStats();
|
|
res.json(rows[0]);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
app.post('/api/groups', requireAuth, async (req, res) => {
|
|
const { name, branch_id } = req.body;
|
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
|
const isAdmin = req.user.role === 'admin';
|
|
const effectiveBranch = branch_id === undefined || branch_id === null || branch_id === '' ? null : Number(branch_id);
|
|
if (!isAdmin && branch_id !== undefined && branch_id !== null && branch_id !== '') {
|
|
return res.status(403).json({ error: 'Назначение филиала — только для администратора' });
|
|
}
|
|
try {
|
|
const { rows } = await pool.query(
|
|
'INSERT INTO groups (name, branch_id) VALUES ($1, $2) RETURNING *',
|
|
[name.trim(), isAdmin ? effectiveBranch : null]
|
|
);
|
|
await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id });
|
|
invalidateGroups();
|
|
invalidateStats();
|
|
res.status(201).json(rows[0]);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
app.delete('/api/groups/:id', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const { rows } = await pool.query(
|
|
'SELECT photo_path FROM group_photos WHERE group_id = $1',
|
|
[req.params.id]
|
|
);
|
|
rows.forEach(r => safeUnlink(r.photo_path));
|
|
await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
|
|
await logAudit(req, 'group.delete', { id: req.params.id });
|
|
invalidateGroups();
|
|
invalidateStats();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Branches CRUD ---
|
|
app.get('/api/branches', requireAuth, async (req, res) => {
|
|
const bw = branchScope(req.user);
|
|
let where = '';
|
|
const params = [];
|
|
if (!bw.admin) {
|
|
if (bw.ids.length) {
|
|
where = ` WHERE b.id IN (${bw.ids.map(id => `$${params.push(id)}`).join(',')})`;
|
|
} else {
|
|
where = ' WHERE 1 = 0';
|
|
}
|
|
}
|
|
const { rows } = await pool.query(
|
|
`SELECT b.*, count(g.id)::int AS groups_count
|
|
FROM branches b
|
|
LEFT JOIN groups g ON g.branch_id = b.id
|
|
${where}
|
|
GROUP BY b.id
|
|
ORDER BY b.id`,
|
|
params
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
app.post('/api/branches', requireAdmin, async (req, res) => {
|
|
const { name, address, phone } = req.body;
|
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
|
try {
|
|
const { rows } = await pool.query(
|
|
'INSERT INTO branches (name, address, phone) VALUES ($1, $2, $3) RETURNING *',
|
|
[name.trim(), address?.trim() || null, phone?.trim() || null]
|
|
);
|
|
await logAudit(req, 'branch.create', { id: rows[0].id, name: name.trim() });
|
|
invalidateGroups();
|
|
res.status(201).json(rows[0]);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
app.put('/api/branches/:id', requireAdmin, async (req, res) => {
|
|
const { name, address, phone } = req.body;
|
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`UPDATE branches SET
|
|
name = $1,
|
|
address = $2,
|
|
phone = $3
|
|
WHERE id = $4 RETURNING *`,
|
|
[name.trim(), address?.trim() || null, phone?.trim() || null, req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
await logAudit(req, 'branch.update', { id: req.params.id, ...req.body });
|
|
invalidateGroups();
|
|
res.json(rows[0]);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'Филиал с таким названием уже существует' });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
app.delete('/api/branches/:id', requireAdmin, async (req, res) => {
|
|
const { rows } = await pool.query('SELECT id FROM groups WHERE branch_id = $1', [req.params.id]);
|
|
if (rows.length) return res.status(400).json({ error: 'Нельзя удалить филиал: есть привязанные группы' });
|
|
await pool.query('DELETE FROM branches WHERE id = $1', [req.params.id]);
|
|
await logAudit(req, 'branch.delete', { id: req.params.id });
|
|
invalidateGroups();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/groups/:id/photos', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const { limit, offset } = req.query;
|
|
const { rows: crows } = await pool.query(
|
|
'SELECT count(*)::int AS n FROM group_photos WHERE group_id = $1',
|
|
[req.params.id]
|
|
);
|
|
const total = crows[0].n;
|
|
let q = `SELECT * FROM group_photos WHERE group_id = $1
|
|
ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC`;
|
|
const qparams = [req.params.id];
|
|
const lim = parseInt(limit, 10);
|
|
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
|
const off = parseInt(offset, 10);
|
|
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
|
const { rows } = await pool.query(q, qparams);
|
|
res.json({ photos: rows, total });
|
|
});
|
|
|
|
const groupPhotoUpload = upload.single('photo');
|
|
app.post('/api/groups/:id/photos', requireAuth, (req, res, next) => {
|
|
groupPhotoUpload(req, res, async (err) => {
|
|
if (err) {
|
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
|
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
|
}
|
|
try {
|
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
|
removeUpload(req.file);
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
next();
|
|
} catch (e) {
|
|
removeUpload(req.file);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
}, async (req, res) => {
|
|
const { caption, taken_at } = req.body;
|
|
if (!req.file) return res.status(400).json({ error: 'Файл обязателен' });
|
|
try {
|
|
await convertPhoto(req.file);
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO group_photos (group_id, photo_path, caption, taken_at, sort_order)
|
|
VALUES ($1, $2, $3, $4,
|
|
COALESCE((SELECT MIN(sort_order) - 1 FROM group_photos WHERE group_id = $1), 0))
|
|
RETURNING *`,
|
|
[req.params.id, `/uploads/${req.file.filename}`, caption?.trim() || null, taken_at || null]
|
|
);
|
|
await logAudit(req, 'group.photo.create', { group_id: req.params.id, photo_path: rows[0].photo_path });
|
|
invalidateShare();
|
|
invalidateGroups();
|
|
invalidateStats();
|
|
res.status(201).json(rows[0]);
|
|
} catch (e) {
|
|
safeUnlink(`uploads/${req.file.filename}`);
|
|
console.error('POST /api/groups/:id/photos:', e);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/groups/:id/photos/reorder', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const { order } = req.body;
|
|
if (!Array.isArray(order) || order.some(id => !Number.isInteger(Number(id)))) {
|
|
return res.status(400).json({ error: 'Некорректный порядок фото' });
|
|
}
|
|
const ids = order.map(id => Number(id));
|
|
if (new Set(ids).size !== ids.length) {
|
|
return res.status(400).json({ error: 'Порядок фото содержит дубликаты' });
|
|
}
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const { rows: owned } = await client.query(
|
|
'SELECT id FROM group_photos WHERE group_id = $1 ORDER BY sort_order ASC, taken_at DESC NULLS LAST, created_at DESC',
|
|
[req.params.id]
|
|
);
|
|
const ownedIds = owned.map(r => r.id);
|
|
if (ids.some(id => !ownedIds.includes(id))) {
|
|
throw { http: 404, message: 'Фото не найдено' };
|
|
}
|
|
const rest = ownedIds.filter(id => !ids.includes(id));
|
|
const allIds = [...ids, ...rest];
|
|
for (let i = 0; i < allIds.length; i++) {
|
|
await client.query(
|
|
'UPDATE group_photos SET sort_order = $1 WHERE id = $2',
|
|
[i + 1, allIds[i]]
|
|
);
|
|
}
|
|
await client.query('COMMIT');
|
|
await logAudit(req, 'group.photo.reorder', { group_id: req.params.id, order: ids });
|
|
invalidateShare();
|
|
invalidateGroups();
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
if (e.http) return res.status(e.http).json({ error: e.message });
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.put('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const { caption, taken_at } = req.body;
|
|
const { rows } = await pool.query(
|
|
`UPDATE group_photos SET
|
|
caption = $1,
|
|
taken_at = $2
|
|
WHERE id = $3 AND group_id = $4 RETURNING *`,
|
|
[caption?.trim() || null, taken_at || null, req.params.photoId, req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
await logAudit(req, 'group.photo.update', { group_id: req.params.id, photo_id: req.params.photoId });
|
|
invalidateShare();
|
|
res.json(rows[0]);
|
|
});
|
|
|
|
app.delete('/api/groups/:id/photos/:photoId', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const { rows } = await pool.query(
|
|
'SELECT photo_path FROM group_photos WHERE id = $1 AND group_id = $2',
|
|
[req.params.photoId, req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
safeUnlink(rows[0].photo_path);
|
|
await pool.query('UPDATE groups SET cover_path = NULL WHERE id = $1 AND cover_path = $2', [req.params.id, rows[0].photo_path]);
|
|
await pool.query('DELETE FROM group_photos WHERE id = $1', [req.params.photoId]);
|
|
await logAudit(req, 'group.photo.delete', { group_id: req.params.id, photo_id: req.params.photoId });
|
|
invalidateShare();
|
|
invalidateGroups();
|
|
invalidateStats();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, req.params.id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const { rows } = await pool.query(
|
|
'SELECT photo_path FROM group_photos WHERE id = $1 AND group_id = $2',
|
|
[req.params.photoId, req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
await pool.query('UPDATE groups SET cover_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
|
await logAudit(req, 'group.photo.set_cover', { group_id: req.params.id, photo_id: req.params.photoId });
|
|
invalidateShare();
|
|
invalidateGroups();
|
|
const { rows: gRows } = await pool.query('SELECT * FROM groups WHERE id = $1', [req.params.id]);
|
|
res.json(gRows[0]);
|
|
});
|
|
|
|
// --- Students CRUD ---
|
|
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
|
|
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
|
|
const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] };
|
|
const { rows } = await pool.query(
|
|
`SELECT s.*, g.name AS group_name FROM students s
|
|
LEFT JOIN groups g ON g.id = s.group_id
|
|
WHERE 1=1${bw.where} ORDER BY s.name`,
|
|
bw.params
|
|
);
|
|
return rows;
|
|
});
|
|
res.json(rows);
|
|
});
|
|
|
|
app.get('/api/students/names', requireAuth, async (req, res) => {
|
|
const bw = branchWhere(req.user, 'g');
|
|
const { rows } = await pool.query(
|
|
`SELECT DISTINCT e.student_name AS name FROM entries e
|
|
JOIN groups g ON g.id = e.group_id
|
|
WHERE e.student_name IS NOT NULL AND e.student_name <> ''${bw.where}
|
|
ORDER BY name`,
|
|
bw.params
|
|
);
|
|
res.json(rows.map(r => r.name));
|
|
});
|
|
|
|
app.post('/api/students', requireAuth, async (req, res) => {
|
|
const { name, group_id } = req.body;
|
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
|
const gid = group_id ? Number(group_id) : null;
|
|
if (req.user.role !== 'admin' && gid && !(await groupBelongsToBranches(req.user, gid))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
try {
|
|
const { rows } = await pool.query(
|
|
'INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *',
|
|
[name.trim(), gid]
|
|
);
|
|
await logAudit(req, 'student.create', { id: rows[0].id, name: name.trim() });
|
|
invalidateStudents();
|
|
invalidateStats();
|
|
res.status(201).json(rows[0]);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
app.put('/api/students/:id', requireAuth, async (req, res) => {
|
|
const { name, group_id } = req.body;
|
|
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
|
const newGid = group_id === undefined || group_id === null || group_id === '' ? null : Number(group_id);
|
|
if (req.user.role !== 'admin') {
|
|
const { rows: cur } = await pool.query(
|
|
`SELECT s.group_id FROM students s LEFT JOIN groups g ON g.id = s.group_id WHERE s.id = $1`,
|
|
[req.params.id]
|
|
);
|
|
if (!cur.length) return res.status(404).json({ error: 'Not found' });
|
|
const curGid = cur[0].group_id;
|
|
if (curGid && !(await groupBelongsToBranches(req.user, curGid))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этому ученику' });
|
|
}
|
|
if (newGid && !(await groupBelongsToBranches(req.user, newGid))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
}
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`UPDATE students SET
|
|
name = $1,
|
|
group_id = $2
|
|
WHERE id = $3 RETURNING *`,
|
|
[name.trim(), newGid, req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
|
await logAudit(req, 'student.update', { id: req.params.id, name: name.trim() });
|
|
invalidateStudents();
|
|
res.json(rows[0]);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
app.post('/api/students/batch-group', requireAuth, async (req, res) => {
|
|
const { group_id, student_ids } = req.body;
|
|
if (!group_id || !Array.isArray(student_ids) || !student_ids.length) {
|
|
return res.status(400).json({ error: 'group_id and student_ids required' });
|
|
}
|
|
if (req.user.role !== 'admin' && !(await groupBelongsToBranches(req.user, group_id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const ids = [...new Set(student_ids.map(Number).filter(Boolean))];
|
|
if (!ids.length) return res.status(400).json({ error: 'No valid students' });
|
|
const params = [group_id, ...ids];
|
|
const placeholders = ids.map((_, i) => `$${i + 2}`).join(',');
|
|
const { rows } = await pool.query(
|
|
`UPDATE students SET group_id = $1 WHERE id IN (${placeholders}) RETURNING id`,
|
|
params
|
|
);
|
|
await logAudit(req, 'student.batch-group', { group_id, count: rows.length });
|
|
invalidateStudents();
|
|
res.json({ ok: true, updated: rows.length });
|
|
});
|
|
|
|
app.delete('/api/students/:id', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const { rows: cur } = await pool.query(
|
|
'SELECT s.group_id FROM students s WHERE s.id = $1', [req.params.id]
|
|
);
|
|
if (!cur.length) return res.status(404).json({ error: 'Not found' });
|
|
const gid = cur[0].group_id;
|
|
if (gid && !(await groupBelongsToBranches(req.user, gid))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этому ученику' });
|
|
}
|
|
}
|
|
await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]);
|
|
await logAudit(req, 'student.delete', { id: req.params.id });
|
|
invalidateStudents();
|
|
invalidateStats();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Student portfolio export (ZIP: HTML report + photos + files) ---
|
|
const CRC_TABLE = (() => {
|
|
const table = new Int32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
table[n] = c;
|
|
}
|
|
return table;
|
|
})();
|
|
|
|
function crc32(buf) {
|
|
let crc = 0xffffffff;
|
|
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function dosDateTime(d = new Date()) {
|
|
return {
|
|
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
|
|
date: ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
|
};
|
|
}
|
|
|
|
function createZipWriter() {
|
|
const parts = [];
|
|
const central = [];
|
|
let count = 0;
|
|
let offset = 0;
|
|
function buildEntry(nameBuf, method, crc, compressed, plain, dt) {
|
|
const local = Buffer.alloc(30);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(20, 4);
|
|
local.writeUInt16LE(0x0800, 6);
|
|
local.writeUInt16LE(method, 8);
|
|
local.writeUInt16LE(dt.time, 10);
|
|
local.writeUInt16LE(dt.date, 12);
|
|
local.writeUInt32LE(crc, 14);
|
|
local.writeUInt32LE(compressed, 18);
|
|
local.writeUInt32LE(plain, 22);
|
|
local.writeUInt16LE(nameBuf.length, 26);
|
|
local.writeUInt16LE(0, 28);
|
|
const cen = Buffer.alloc(46);
|
|
cen.writeUInt32LE(0x02014b50, 0);
|
|
cen.writeUInt16LE(20, 4);
|
|
cen.writeUInt16LE(20, 6);
|
|
cen.writeUInt16LE(0x0800, 8);
|
|
cen.writeUInt16LE(method, 10);
|
|
cen.writeUInt16LE(dt.time, 12);
|
|
cen.writeUInt16LE(dt.date, 14);
|
|
cen.writeUInt32LE(crc, 16);
|
|
cen.writeUInt32LE(compressed, 20);
|
|
cen.writeUInt32LE(plain, 24);
|
|
cen.writeUInt16LE(nameBuf.length, 28);
|
|
cen.writeUInt16LE(0, 30);
|
|
cen.writeUInt16LE(0, 32);
|
|
cen.writeUInt16LE(0, 34);
|
|
cen.writeUInt16LE(0, 36);
|
|
cen.writeUInt32LE(0, 38);
|
|
cen.writeUInt32LE(offset, 42);
|
|
return { local, cen, nameBuf };
|
|
}
|
|
return {
|
|
addFile(name, data, d) {
|
|
const nameBuf = Buffer.from(name, 'utf8');
|
|
const dt = dosDateTime(d);
|
|
const crc = crc32(data);
|
|
const compressed = zlib.deflateRawSync(data, { level: 9 });
|
|
const e = buildEntry(nameBuf, 8, crc, compressed.length, data.length, dt);
|
|
const chunk = Buffer.concat([e.local, e.nameBuf, compressed]);
|
|
parts.push(chunk);
|
|
central.push(Buffer.concat([e.cen, e.nameBuf]));
|
|
offset += chunk.length;
|
|
count++;
|
|
},
|
|
addDir(name) {
|
|
const nameBuf = Buffer.from(String(name).replace(/\/?$/, '/'), 'utf8');
|
|
const dt = dosDateTime();
|
|
const e = buildEntry(nameBuf, 0, 0, 0, 0, dt);
|
|
const chunk = Buffer.concat([e.local, e.nameBuf]);
|
|
parts.push(chunk);
|
|
central.push(Buffer.concat([e.cen, e.nameBuf]));
|
|
offset += chunk.length;
|
|
count++;
|
|
},
|
|
toBuffer() {
|
|
const centralStart = offset;
|
|
const centralBuf = Buffer.concat(central);
|
|
const eocd = Buffer.alloc(22);
|
|
eocd.writeUInt32LE(0x06054b50, 0);
|
|
eocd.writeUInt16LE(0, 4);
|
|
eocd.writeUInt16LE(0, 6);
|
|
eocd.writeUInt16LE(count, 8);
|
|
eocd.writeUInt16LE(count, 10);
|
|
eocd.writeUInt32LE(centralBuf.length, 12);
|
|
eocd.writeUInt32LE(centralStart, 16);
|
|
eocd.writeUInt16LE(0, 20);
|
|
return Buffer.concat([...parts, centralBuf, eocd]);
|
|
},
|
|
};
|
|
}
|
|
|
|
function fmtLongDate(iso) {
|
|
if (!iso) return '';
|
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
function fmtBytes(n) {
|
|
if (!Number.isFinite(n)) return '';
|
|
if (n < 1024) return n + ' Б';
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(/\.0$/, '') + ' КБ';
|
|
return (n / (1024 * 1024)).toFixed(1).replace(/\.0$/, '') + ' МБ';
|
|
}
|
|
|
|
function truncate(str, max) {
|
|
const s = String(str || '');
|
|
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
}
|
|
|
|
function renderStudentReport(data, opts) {
|
|
const o = opts || {};
|
|
const showEntries = o.includeEntries !== false;
|
|
const showPhotos = o.includePhotos !== false;
|
|
const showFiles = o.includeFiles !== false;
|
|
const showCaptions = o.includeCaptions !== false;
|
|
const showDates = o.showDates !== false;
|
|
const { name, groups, entries, photos, files, generatedAt, period } = data;
|
|
const IMG_EXT = new Set(['JPG','JPEG','PNG','GIF','WEBP','BMP','AVIF','SVG','ICO','JFIF']);
|
|
const VID_EXT = new Set(['MP4','WEBM','MOV','M4V','OGV','MKV','MPEG','MPG','3GP','AVI']);
|
|
const gallery = [];
|
|
const galIdx = new Map();
|
|
for (const p of photos) { gallery.push({ type: 'image', src: 'photos/' + p.stored }); galIdx.set('photos/' + p.stored, gallery.length - 1); }
|
|
for (const f of files) {
|
|
const fn = String(f.original || f.saved || '');
|
|
const ext = fn.indexOf('.') >= 0 ? fn.split('.').pop().toUpperCase() : '';
|
|
if (VID_EXT.has(ext)) { gallery.push({ type: 'video', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); }
|
|
else if (IMG_EXT.has(ext)) { gallery.push({ type: 'image', src: 'files/' + f.saved }); galIdx.set('files/' + f.saved, gallery.length - 1); }
|
|
}
|
|
const avatar = showPhotos && photos.length ? photos[0].stored : null;
|
|
const statsChips = [];
|
|
if (showEntries) statsChips.push(`<div class="chip"><b>${entries.length}</b><span>занятий</span></div>`);
|
|
if (showPhotos) statsChips.push(`<div class="chip"><b>${photos.length}</b><span>фотографий</span></div>`);
|
|
if (showFiles) statsChips.push(`<div class="chip"><b>${files.length}</b><span>файлов</span></div>`);
|
|
const photoCards = showPhotos ? photos.map(p => {
|
|
let caption = '';
|
|
if (showCaptions && p.caption) caption = truncate(p.caption, 120);
|
|
if (!caption && showDates && !p.caption) caption = fmtLongDate(p.createdAt);
|
|
const pg = galIdx.get('photos/' + p.stored);
|
|
return `
|
|
<figure class="ph" data-g="${pg}">
|
|
<img src="photos/${escapeHtml(p.stored)}" alt="${escapeHtml(name)} — фото" loading="lazy">
|
|
${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''}
|
|
</figure>`;
|
|
}).join('') : '';
|
|
const fileRows = showFiles ? files.map(f => {
|
|
const ext = f.original.indexOf('.') >= 0 ? f.original.split('.').pop().toUpperCase().slice(0, 8) : 'FILE';
|
|
const meta = showDates ? `${fmtLongDate(f.createdAt)} · ${fmtBytes(f.size)}` : fmtBytes(f.size);
|
|
const fg = galIdx.get('files/' + f.saved);
|
|
const gattr = fg !== undefined ? ` class="gfile" data-g="${fg}"` : ' target="_blank" rel="noopener"';
|
|
return `
|
|
<li>
|
|
<a href="files/${escapeHtml(f.saved)}"${gattr}>
|
|
<span class="badge">${escapeHtml(ext)}</span>
|
|
<span class="fname">${escapeHtml(f.original)}</span>
|
|
<span class="fmeta">${escapeHtml(meta)}</span>
|
|
</a>
|
|
</li>`;
|
|
}).join('') : '';
|
|
const photosByEntry = new Map();
|
|
for (const p of photos) {
|
|
if (!photosByEntry.has(p.entryId)) photosByEntry.set(p.entryId, []);
|
|
photosByEntry.get(p.entryId).push(p.stored);
|
|
}
|
|
const lessonRows = showEntries ? entries.map(e => {
|
|
const thumbs = showPhotos ? (photosByEntry.get(e.id) || []).slice(0, 4).map(t => `
|
|
<img class="tph" src="photos/${escapeHtml(t)}" alt="фото" data-g="${galIdx.get('photos/' + t)}" loading="lazy">`).join('') : '';
|
|
const entryFiles = showFiles ? files.filter(f => f.entryId === e.id) : [];
|
|
const fls = entryFiles.length ? `<div class="lfiles">${entryFiles.map(f => `<a href="files/${escapeHtml(f.saved)}" target="_blank" rel="noopener">${escapeHtml(f.original)}</a>`).join('')}</div>` : '';
|
|
const desc = e.description ? `<p class="ldesc">${escapeHtml(e.description)}</p>` : '';
|
|
const gr = e.group_name ? `<span class="lgroup">${escapeHtml(e.group_name)}</span>` : '';
|
|
const dt = showDates && e.created_at ? `<span class="ldate">${escapeHtml(fmtLongDate(e.created_at))}</span>` : '';
|
|
const head = dt + gr;
|
|
return `
|
|
<article class="lesson">
|
|
${head ? `<div class="lhead">${head}</div>` : ''}
|
|
${desc}
|
|
${thumbs ? `<div class="lthumbs">${thumbs}</div>` : ''}
|
|
${fls}
|
|
</article>`;
|
|
}).join('') : '';
|
|
const groupLine = groups.length ? escapeHtml(groups.join(' · ')) : '';
|
|
const genLabel = escapeHtml(fmtLongDate(generatedAt));
|
|
const metaBits = [];
|
|
if (groupLine) metaBits.push(groupLine);
|
|
if (period) metaBits.push(escapeHtml(period));
|
|
metaBits.push(`Сформировано ${genLabel}`);
|
|
const heroAvatar = avatar ? `<div class="avatar"><img src="photos/${escapeHtml(avatar)}" alt="${escapeHtml(name)}"></div>` : '';
|
|
const navItems = [];
|
|
if (showPhotos) navItems.push('<a class="navlink" href="#photos">Фотографии</a>');
|
|
if (showFiles) navItems.push('<a class="navlink" href="#files">Работы</a>');
|
|
if (showEntries) navItems.push('<a class="navlink" href="#lessons">Занятия</a>');
|
|
const nav = navItems.length >= 2 ? '<nav class="nav" id="topNav">' + navItems.join('') + '</nav>' : '';
|
|
return `<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>${escapeHtml(name)} — портфолио</title>
|
|
<style>
|
|
:root{--cream:#FFF7EC;--ink:#2B2B33;--muted:#98919B;--yellow:#F7C90E;--teal:#14B8A6;--violet:#8B5CF6;--coral:#FF6B6B;--sky:#38BDF8;--line:#FFEAD2}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
html{scroll-behavior:smooth}
|
|
body{font-family:"Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;background:var(--cream);color:var(--ink);line-height:1.55}
|
|
body::before{content:"";position:fixed;inset:0;z-index:-1;background:radial-gradient(520px 400px at 8% -2%,rgba(247,201,14,.18),transparent 62%),radial-gradient(620px 430px at 100% 3%,rgba(20,184,166,.15),transparent 58%)}
|
|
.wrap{max-width:960px;margin:0 auto;padding:28px 18px 58px}
|
|
|
|
.nav{position:sticky;top:12px;z-index:40;display:flex;justify-content:center;gap:8px;flex-wrap:wrap;background:rgba(255,255,255,.92);border:1px solid #F1E2C6;border-radius:999px;padding:8px 14px;box-shadow:0 12px 26px -16px rgba(20,30,50,.35);margin-top:26px}
|
|
.nav .navlink{text-decoration:none;color:var(--ink);background:#fff;border:1px solid #F1E2C6;border-radius:999px;padding:8px 18px;font-size:.9rem;font-weight:600;transition:all .15s}
|
|
.nav .navlink:hover{transform:translateY(-1px)}
|
|
.nav .navlink.on{background:var(--yellow);color:#231f12;border-color:transparent;box-shadow:0 4px 12px -4px rgba(247,201,14,.5)}
|
|
|
|
.hero{overflow:hidden;border-radius:34px;padding:36px 34px;background:linear-gradient(135deg,#FFF6DB,#FFEDBF 52%,#FFE19A);box-shadow:0 24px 50px -24px rgba(247,201,14,.5);position:relative}
|
|
.hero .deco{position:absolute;border-radius:50%;background:rgba(255,255,255,.55);pointer-events:none}
|
|
.hero .deco.d1{width:150px;height:150px;right:-40px;top:-46px}
|
|
.hero .deco.d2{width:80px;height:80px;left:40%;bottom:-30px;background:rgba(20,184,166,.14)}
|
|
.hero .deco.d3{width:44px;height:44px;left:-14px;top:30%}
|
|
.avatar-col{position:relative;display:flex;gap:24px;align-items:center}
|
|
.avatar{flex:0 0 auto;width:104px;height:104px;border-radius:50%;overflow:hidden;border:5px solid #fff;background:#fff;box-shadow:0 10px 24px -6px rgba(0,0,0,.26);transform:rotate(-4deg)}
|
|
.avatar img{width:100%;height:100%;object-fit:cover;display:block;border-radius:50%}
|
|
.avatar.aemp{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#F7C90E,#FFB34A);font-size:2.4rem;transform:rotate(-4deg)}
|
|
.kicker{font-size:.82rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#B07A00;margin-bottom:6px}
|
|
h1{font-size:clamp(1.65rem,4.4vw,2.35rem);font-weight:800;letter-spacing:-.02em;line-height:1.12;margin-bottom:6px}
|
|
.meta{font-size:.95rem;color:#7A6A33;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px}
|
|
.stats{display:flex;gap:12px;flex-wrap:wrap}
|
|
.chip{background:#fff;border:1px solid #F3DFC0;border-radius:18px;padding:10px 16px;display:flex;flex-direction:column;min-width:92px;box-shadow:0 8px 18px -12px rgba(20,30,50,.35)}
|
|
.chip b{font-size:1.5rem;line-height:1.05}
|
|
.chip span{font-size:.78rem;color:var(--muted)}
|
|
.stats .chip:nth-child(1) b{color:var(--coral)}
|
|
.stats .chip:nth-child(2) b{color:var(--teal)}
|
|
.stats .chip:nth-child(3) b{color:var(--violet)}
|
|
section{background:#fff;border:1px solid var(--line);border-radius:30px;padding:26px 26px 28px;margin-top:26px;box-shadow:0 16px 34px -22px rgba(20,30,50,.4)}
|
|
.p-sec{border-top:6px solid var(--teal)}
|
|
.f-sec{border-top:6px solid var(--violet)}
|
|
.l-sec{border-top:6px solid var(--coral)}
|
|
h2{font-size:1.25rem;font-weight:800;display:flex;align-items:center;gap:10px;margin-bottom:18px}
|
|
h2 .sico{width:38px;height:38px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;font-size:1.25rem;flex:0 0 auto}
|
|
.p-sec h2 .sico{background:#E3FAF7}
|
|
.f-sec h2 .sico{background:#EFE9FF}
|
|
.l-sec h2 .sico{background:#FFE9EE}
|
|
.empty{color:var(--muted);border:2px dashed #F0DDBE;border-radius:20px;padding:24px 18px;text-align:center;font-size:.95rem}
|
|
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px}
|
|
.ph{background:#fff;border-radius:20px;overflow:hidden;cursor:zoom-in;border:3px solid #fff;box-shadow:0 10px 22px -14px rgba(20,30,50,.4);transition:transform .16s ease,box-shadow .16s ease}
|
|
.ph:hover{transform:translateY(-4px) rotate(-1deg);box-shadow:0 18px 30px -16px rgba(20,30,50,.45)}
|
|
.ph img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block}
|
|
.ph figcaption{background:rgba(255,247,236,.92);padding:9px 12px;font-size:.82rem;color:#6A6155;line-height:1.35}
|
|
|
|
.filelist{list-style:none;display:flex;flex-direction:column;gap:9px}
|
|
.filelist li a{display:flex;align-items:center;gap:14px;background:#FDFAF4;border:1px solid #F1E2C6;border-radius:18px;padding:12px 16px;text-decoration:none;color:var(--ink);transition:transform .15s,box-shadow .15s}
|
|
.filelist li a:hover{transform:translateX(4px);box-shadow:0 10px 20px -12px rgba(20,30,50,.35)}
|
|
.badge{flex:0 0 auto;min-width:52px;text-align:center;font-size:.68rem;font-weight:800;letter-spacing:.04em;padding:6px 9px;border-radius:10px;background:linear-gradient(135deg,#F7C90E,#FFB34A);color:#231f12}
|
|
.fname{flex:1;min-width:0;font-weight:700;word-break:break-word}
|
|
.fmeta{flex:0 0 auto;color:var(--muted);font-size:.8rem;white-space:nowrap}
|
|
.gfile{cursor:pointer}
|
|
|
|
.lessons{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:stretch}
|
|
.lesson{background:#FFFCF6;border:1px solid #F3E4CA;border-left:6px solid var(--coral);border-radius:20px;padding:16px 20px}
|
|
.lhead{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
|
|
.ldate{background:var(--coral);color:#fff;border-radius:999px;padding:3px 12px;font-size:.74rem;font-weight:700}
|
|
.lgroup{background:rgba(20,184,166,.14);color:#0C9488;font-weight:700;border-radius:999px;padding:3px 12px;font-size:.74rem}
|
|
.ldesc{white-space:pre-wrap;color:#3A3733;font-size:.95rem}
|
|
.lthumbs{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
|
.tph{width:80px;height:80px;object-fit:cover;border-radius:14px;cursor:zoom-in;border:2px solid #fff;box-shadow:0 6px 12px -8px rgba(20,30,50,.35)}
|
|
.lfiles{margin-top:10px;display:flex;flex-wrap:wrap;gap:8px}
|
|
.lfiles a{font-size:.8rem;color:#0C9488;background:rgba(20,184,166,.1);border-radius:999px;padding:5px 12px;text-decoration:none;word-break:break-word}
|
|
.lfiles a:hover{background:rgba(20,184,166,.18)}
|
|
|
|
footer{margin-top:44px;color:#B9B4BE;font-size:.82rem;text-align:center}
|
|
.lightbox{position:fixed;inset:0;background:rgba(22,20,40,.9);display:none;flex-direction:column;z-index:60;padding:16px 92px 48px;cursor:zoom-out;overflow:hidden}
|
|
.lightbox.open{display:flex}
|
|
.lightbox .lb-media{display:flex;align-items:center;justify-content:center;flex:1;min-height:0}
|
|
.lightbox .lb-media img,.lightbox .lb-media video{display:block;max-width:100%;max-height:100%;object-fit:contain;border-radius:14px}
|
|
.lightbox .lb-media video{background:#000;max-height:calc(100% - 16px)}
|
|
.lightbox .lb-btn{position:absolute;top:50%;transform:translateY(-50%);z-index:5;width:52px;height:52px;border-radius:50%;border:none;background:rgba(247,201,14,.94);color:#231f12;font-size:1.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 22px -8px rgba(0,0,0,.5)}
|
|
.lightbox .lb-btn:hover{background:#F7C90E}
|
|
.lightbox .lb-prev{left:20px}
|
|
.lightbox .lb-next{right:20px}
|
|
.lightbox .lb-count{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);background:rgba(22,20,40,.72);color:#fff;border-radius:999px;padding:6px 16px;font-size:.85rem;letter-spacing:.03em}
|
|
.lightbox .lb-close{position:absolute;top:16px;right:20px;z-index:6;width:44px;height:44px;border-radius:50%;border:none;background:rgba(22,20,40,.6);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 18px -8px rgba(0,0,0,.5)}
|
|
.lightbox .lb-close:hover{background:rgba(247,201,14,.92);color:#231f12}
|
|
|
|
@media print{body::before{display:none}.nav{display:none}.wrap{max-width:none;padding:0;-webkit-print-color-adjust:exact}body{background:#fff}.hero,section{box-shadow:none}section,.lesson,.ph{break-inside:avoid}}
|
|
@media (max-width:560px){.avatar-col{flex-direction:column;align-items:flex-start;gap:16px}.hero{padding:26px 22px}.grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}.stats{gap:8px}.fmeta{display:none}.chip{min-width:74px;padding:8px 12px}.lessons{grid-template-columns:1fr}.lightbox{padding:8px 6px 44px}.lb-prev{left:6px}.lb-next{right:6px}.lb-btn{width:44px;height:44px;font-size:1.4rem}.lb-close{width:38px;height:38px;top:10px;right:10px;font-size:1.15rem}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header class="hero">
|
|
<span class="deco d1"></span>
|
|
<span class="deco d2"></span>
|
|
<span class="deco d3"></span>
|
|
<div class="avatar-col">
|
|
${heroAvatar || '<div class="avatar aemp"><span>🎨</span></div>'}
|
|
<div>
|
|
<p class="kicker">✦ Портфолио ученика</p>
|
|
<h1>${escapeHtml(name)}</h1>
|
|
<p class="meta">${metaBits.join(' · ')}</p>
|
|
<div class="stats">
|
|
${statsChips.join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
${nav}
|
|
|
|
${showPhotos ? `<section id="photos" class="p-sec">
|
|
<h2><span class="sico">🏞️</span>Фотографии</h2>
|
|
${photoCards ? `<div class="grid">${photoCards}</div>` : '<p class="empty">🎈 Фотографий пока нет</p>'}
|
|
</section>` : ''}
|
|
|
|
${showFiles ? `<section id="files" class="f-sec">
|
|
<h2><span class="sico">📁</span>Работы и файлы</h2>
|
|
${fileRows ? `<ul class="filelist">${fileRows}</ul>` : '<p class="empty">🎈 Файлов пока нет</p>'}
|
|
</section>` : ''}
|
|
|
|
${showEntries ? `<section id="lessons" class="l-sec">
|
|
<h2><span class="sico">🎒</span>Журнал занятий</h2>
|
|
${lessonRows ? `<div class="lessons">${lessonRows}</div>` : '<p class="empty">🎈 Записей о занятиях пока нет</p>'}
|
|
</section>` : ''}
|
|
|
|
<footer>Сформировано ${genLabel} · WhatIDo</footer>
|
|
</div>
|
|
<div class="lightbox" id="lightbox">
|
|
<button type="button" class="lb-btn lb-prev" id="lbPrev" aria-label="Назад">❮</button>
|
|
<div class="lb-media" id="lbMedia"></div>
|
|
<button type="button" class="lb-btn lb-next" id="lbNext" aria-label="Вперёд">❯</button>
|
|
<button type="button" class="lb-close" id="lbClose" aria-label="Закрыть">✕</button>
|
|
<div class="lb-count" id="lbCount"></div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
var GAL = ${JSON.stringify(gallery)};
|
|
var box = document.getElementById('lightbox');
|
|
var media = document.getElementById('lbMedia');
|
|
var count = document.getElementById('lbCount');
|
|
var current = 0;
|
|
function isVid(m) { return m && m.type === 'video'; }
|
|
function render(i) {
|
|
if (!GAL.length) return;
|
|
var m = GAL[i];
|
|
current = i;
|
|
count.textContent = (i + 1) + ' / ' + GAL.length;
|
|
media.innerHTML = '';
|
|
if (isVid(m)) {
|
|
var v = document.createElement('video');
|
|
v.src = m.src;
|
|
v.controls = true;
|
|
v.autoplay = true;
|
|
media.appendChild(v);
|
|
} else {
|
|
var im = document.createElement('img');
|
|
im.src = m.src;
|
|
im.alt = '';
|
|
media.appendChild(im);
|
|
}
|
|
}
|
|
function open(i) { if (!GAL.length) return; render(i); box.classList.add('open'); }
|
|
function close() { box.classList.remove('open'); media.innerHTML = ''; }
|
|
function step(d) { if (!GAL.length) return; render((current + d + GAL.length) % GAL.length); }
|
|
document.addEventListener('click', function (e) {
|
|
var t = e.target.closest('[data-g]');
|
|
if (t) { e.preventDefault(); var gi = parseInt(t.getAttribute('data-g'), 10); if (!isNaN(gi) && gi >= 0 && GAL[gi]) open(gi); return; }
|
|
if (e.target === box) close();
|
|
});
|
|
document.getElementById('lbPrev').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); step(-1); });
|
|
document.getElementById('lbNext').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); step(1); });
|
|
document.getElementById('lbClose').addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); close(); });
|
|
document.addEventListener('keydown', function (e) {
|
|
if (!box.classList.contains('open')) return;
|
|
if (e.key === 'Escape') close();
|
|
else if (e.key === 'ArrowRight') step(1);
|
|
else if (e.key === 'ArrowLeft') step(-1);
|
|
});
|
|
var links = Array.prototype.slice.call(document.querySelectorAll('.navlink'));
|
|
function spy() {
|
|
if (!links.length) return;
|
|
var hit = null;
|
|
for (var i = 0; i < links.length; i++) {
|
|
var el = document.getElementById(links[i].getAttribute('href').slice(1));
|
|
if (el && el.getBoundingClientRect().top <= 140) hit = links[i];
|
|
}
|
|
for (var j = 0; j < links.length; j++) {
|
|
if (links[j] === hit) links[j].classList.add('on');
|
|
else links[j].classList.remove('on');
|
|
}
|
|
}
|
|
if (links.length) window.addEventListener('scroll', spy, { passive: true });
|
|
spy();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
app.get('/api/export/student', requireAuth, async (req, res) => {
|
|
let name;
|
|
try {
|
|
name = reqStr(req.query.name, 150);
|
|
} catch {
|
|
return res.status(400).json({ error: 'Укажите имя ученика' });
|
|
}
|
|
const opts = {
|
|
includeEntries: req.query.include_entries !== '0',
|
|
includePhotos: req.query.include_photos !== '0',
|
|
includeFiles: req.query.include_files !== '0',
|
|
includeCaptions: req.query.include_captions !== '0',
|
|
showDates: req.query.show_dates !== '0',
|
|
};
|
|
if (!opts.includeEntries && !opts.includePhotos && !opts.includeFiles) {
|
|
return res.status(400).json({ error: 'Выберите, что включать в отчёт' });
|
|
}
|
|
let dateFrom = null;
|
|
let dateTo = null;
|
|
try {
|
|
if (req.query.date_from) dateFrom = optDate(req.query.date_from);
|
|
if (req.query.date_to) dateTo = optDate(req.query.date_to);
|
|
} catch {
|
|
return res.status(400).json({ error: 'Неверный период' });
|
|
}
|
|
if (dateFrom && dateTo && dateFrom > dateTo) {
|
|
return res.status(400).json({ error: 'Дата «С» позже даты «По»' });
|
|
}
|
|
const hasPeriod = !!(dateFrom || dateTo);
|
|
try {
|
|
const conds = ['e.student_name = $1', 'e.deleted_at IS NULL'];
|
|
const params = [name];
|
|
const bw = branchWhere(req.user, 'g');
|
|
if (dateFrom) {
|
|
params.push(dateFrom);
|
|
conds.push(`e.created_at >= $${params.length}::date`);
|
|
}
|
|
if (dateTo) {
|
|
params.push(dateTo);
|
|
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
|
|
}
|
|
if (bw.params.length) {
|
|
const start = params.length + 1;
|
|
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
|
|
params.push(...bw.params);
|
|
}
|
|
const condStr = conds.join(' AND ');
|
|
const whereStr = ' WHERE ' + condStr;
|
|
const [studRes, entriesRes, photosRes, mainsRes, filesRes] = await Promise.all([
|
|
pool.query(`SELECT s.name, g.name AS group_name FROM students s LEFT JOIN groups g ON g.id = s.group_id WHERE s.name = $1`, [name]),
|
|
pool.query(`SELECT e.id, e.description, e.created_at, g.name AS group_name
|
|
FROM entries e JOIN groups g ON g.id = e.group_id${whereStr}
|
|
ORDER BY e.created_at DESC`, params),
|
|
pool.query(`SELECT ep.photo_path, ep.caption, ep.entry_id, e.description, e.created_at
|
|
FROM entry_photos ep
|
|
JOIN entries e ON e.id = ep.entry_id
|
|
JOIN groups g ON g.id = e.group_id${whereStr}
|
|
ORDER BY e.created_at DESC, ep.sort_order ASC, ep.id ASC`, params),
|
|
pool.query(`SELECT e.photo_path, e.description, e.created_at, e.id AS entry_id
|
|
FROM entries e JOIN groups g ON g.id = e.group_id
|
|
WHERE e.photo_path IS NOT NULL AND ${condStr}
|
|
ORDER BY e.created_at DESC`, params),
|
|
pool.query(`SELECT pf.path, pf.name, pf.entry_id, e.created_at
|
|
FROM project_files pf
|
|
JOIN entries e ON e.id = pf.entry_id
|
|
JOIN groups g ON g.id = e.group_id
|
|
WHERE ${condStr} AND pf.detached_at IS NULL
|
|
ORDER BY e.created_at DESC, pf.id DESC`, params),
|
|
]);
|
|
const entryRows = entriesRes.rows;
|
|
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
|
|
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
|
|
}
|
|
const zip = createZipWriter();
|
|
if (opts.includePhotos) zip.addDir('photos');
|
|
if (opts.includeFiles) zip.addDir('files');
|
|
|
|
const seenPhotos = new Set();
|
|
const photosBuilt = [];
|
|
function addPhoto(p) {
|
|
if (!isSafeUploadPath(p.photo_path)) return;
|
|
const stored = p.photo_path.slice('/uploads/'.length);
|
|
if (seenPhotos.has(stored)) return;
|
|
seenPhotos.add(stored);
|
|
const src = path.join(UPLOADS_DIR, stored);
|
|
if (!fs.existsSync(src)) return;
|
|
const data = fs.readFileSync(src);
|
|
zip.addFile('photos/' + stored, data, new Date(p.created_at));
|
|
photosBuilt.push({ stored, caption: p.caption, createdAt: p.created_at, desc: p.description, entryId: p.entry_id });
|
|
}
|
|
if (opts.includePhotos) {
|
|
for (const p of photosRes.rows) addPhoto(p);
|
|
for (const m of mainsRes.rows) addPhoto(m);
|
|
photosBuilt.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
}
|
|
|
|
const seenFiles = new Map();
|
|
const filesBuilt = [];
|
|
if (opts.includeFiles) {
|
|
for (const f of filesRes.rows) {
|
|
if (!isSafeUploadPath(f.path)) continue;
|
|
const stored = f.path.slice('/uploads/'.length);
|
|
const src = path.join(UPLOADS_DIR, stored);
|
|
if (!fs.existsSync(src)) continue;
|
|
const data = fs.readFileSync(src);
|
|
let base = String(f.name || 'file').replace(/[\\/:*?"<>|]/g, '_').replace(/^[.\s]+/, '').slice(0, 120) || 'file';
|
|
const ext = path.extname(base);
|
|
const stem = ext ? base.slice(0, -ext.length) : base;
|
|
let saved = base;
|
|
let n = 1;
|
|
while (seenFiles.has(saved)) {
|
|
n++;
|
|
saved = `${stem}(${n})${ext}`;
|
|
}
|
|
seenFiles.set(saved, true);
|
|
zip.addFile('files/' + saved, data, new Date(f.created_at));
|
|
filesBuilt.push({ saved, original: f.name, size: data.length, createdAt: f.created_at, entryId: f.entry_id });
|
|
}
|
|
}
|
|
|
|
const groupSet = new Set();
|
|
if (studRes.rows[0]?.group_name) groupSet.add(studRes.rows[0].group_name);
|
|
for (const e of entryRows) if (e.group_name) groupSet.add(e.group_name);
|
|
const groups = [...groupSet];
|
|
|
|
let period = null;
|
|
if (hasPeriod) {
|
|
period = `Период: ${dateFrom ? fmtLongDate(dateFrom + 'T00:00:00') : 'начало'} — ${dateTo ? fmtLongDate(dateTo + 'T00:00:00') : 'сегодня'}`;
|
|
}
|
|
|
|
const html = renderStudentReport({
|
|
name,
|
|
groups,
|
|
entries: entryRows,
|
|
photos: photosBuilt,
|
|
files: filesBuilt,
|
|
generatedAt: new Date(),
|
|
period,
|
|
}, opts);
|
|
zip.addFile('index.html', Buffer.from(html, 'utf8'));
|
|
|
|
const buf = zip.toBuffer();
|
|
await logAudit(req, 'export.student', {
|
|
student: name,
|
|
entries: entryRows.length,
|
|
photos: photosBuilt.length,
|
|
files: filesBuilt.length,
|
|
opts,
|
|
date_from: dateFrom,
|
|
date_to: dateTo,
|
|
});
|
|
const safeName = name.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'student';
|
|
const zipDate = new Date().toISOString().slice(0, 10);
|
|
const zipFname = `student_${safeName}_${zipDate}.zip`;
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${zipFname}"; filename*=UTF-8''${encodeURIComponent(`student_${name}_${zipDate}.zip`)}`);
|
|
res.send(buf);
|
|
} catch (err) {
|
|
console.error('export student:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// --- Entries ---
|
|
app.get('/api/entries', requireAuth, async (req, res) => {
|
|
const { group_id, 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})`); }
|
|
if (req.user.role !== 'admin') {
|
|
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const s = branchScope(req.user);
|
|
if (!s.ids.length) {
|
|
conditions.push('1 = 0');
|
|
} else {
|
|
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
|
|
conditions.push(`g.branch_id IN (${ph})`);
|
|
}
|
|
}
|
|
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
|
const { rows: crows } = await pool.query(
|
|
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`,
|
|
params
|
|
);
|
|
const total = crows[0].n;
|
|
let q = `SELECT e.*, g.name AS group_name 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] || []; });
|
|
|
|
if (rows.length) {
|
|
const ids = rows.map(r => r.id);
|
|
const pRes = await pool.query(
|
|
'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = ANY($1) ORDER BY sort_order, id',
|
|
[ids]
|
|
);
|
|
const photos = {};
|
|
pRes.rows.forEach(p => { (photos[p.entry_id] = photos[p.entry_id] || []).push(p); });
|
|
rows.forEach(r => { r.photos = photos[r.id] || []; });
|
|
} else {
|
|
rows.forEach(r => { r.photos = []; });
|
|
}
|
|
|
|
res.json({ entries: rows, total });
|
|
});
|
|
|
|
app.get('/api/entries/:id/files', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const { rows } = await pool.query(`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`, [req.params.id]);
|
|
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
|
if (rows[0].group_id && !(await groupBelongsToBranches(req.user, rows[0].group_id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
}
|
|
const { rows } = await pool.query(
|
|
'SELECT id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
|
|
[req.params.id]
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
const entryFilesUpload = adminUpload.array('files', 10);
|
|
app.post('/api/entries/:id/files', requireAuth, (req, res, next) => {
|
|
entryFilesUpload(req, res, (err) => {
|
|
if (!err) return next();
|
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла' });
|
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
|
});
|
|
}, async (req, res) => {
|
|
const entryId = req.params.id;
|
|
const files = req.files || [];
|
|
if (!files.length) return res.status(400).json({ error: 'Файлы не выбраны' });
|
|
|
|
if (req.user.role !== 'admin') {
|
|
const { rows } = await pool.query(`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`, [entryId]);
|
|
if (!rows.length) {
|
|
files.forEach(removeUpload);
|
|
return res.status(404).json({ error: 'Запись не найдена' });
|
|
}
|
|
if (rows[0].group_id && !(await groupBelongsToBranches(req.user, rows[0].group_id))) {
|
|
files.forEach(removeUpload);
|
|
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
}
|
|
|
|
const entryCheck = await pool.query('SELECT id FROM entries WHERE id = $1', [entryId]);
|
|
if (!entryCheck.rows.length) {
|
|
files.forEach(removeUpload);
|
|
return res.status(404).json({ error: 'Запись не найдена' });
|
|
}
|
|
|
|
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
|
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
|
files.forEach(removeUpload);
|
|
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
|
|
}
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
for (const f of files) {
|
|
const token = crypto.randomBytes(16).toString('hex');
|
|
await client.query(
|
|
'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)',
|
|
[entryId, token, `/uploads/${f.filename}`, f.originalname]
|
|
);
|
|
}
|
|
await client.query('COMMIT');
|
|
invalidateShare();
|
|
invalidateEntries();
|
|
res.status(201).json({ ok: true, count: files.length });
|
|
} catch (e) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
files.forEach(removeUpload);
|
|
console.error('POST /api/entries/:id/files:', e);
|
|
res.status(500).json({ error: e.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
function isImageName(name) {
|
|
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i.test(name || '');
|
|
}
|
|
|
|
app.get('/api/files', requireAuth, async (req, res) => {
|
|
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
|
|
const conditions = [];
|
|
const params = [];
|
|
conditions.push('e.deleted_at IS NULL');
|
|
if (search) { params.push(`%${search}%`); conditions.push(`pf.name ILIKE $${params.length}`); }
|
|
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
|
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
|
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
|
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
|
if (req.user.role !== 'admin') {
|
|
if (group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
|
}
|
|
const s = branchScope(req.user);
|
|
if (!s.ids.length) {
|
|
conditions.push('1 = 0');
|
|
} else {
|
|
const ph = s.ids.map(id => `$${params.push(id)}`).join(',');
|
|
conditions.push(`g.branch_id IN (${ph})`);
|
|
}
|
|
}
|
|
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
|
const { rows: crows } = await pool.query(
|
|
`SELECT count(*)::int AS n FROM project_files pf JOIN entries e ON e.id = pf.entry_id JOIN groups g ON g.id = e.group_id${where}`,
|
|
params
|
|
);
|
|
const total = crows[0].n;
|
|
let q = `SELECT pf.id, pf.token, pf.name, pf.path, e.student_name, e.created_at, g.name AS group_name
|
|
FROM project_files pf
|
|
JOIN entries e ON e.id = pf.entry_id
|
|
JOIN groups g ON g.id = e.group_id${where}
|
|
ORDER BY pf.id DESC`;
|
|
const qparams = params.slice();
|
|
const lim = parseInt(limit, 10);
|
|
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
|
const off = parseInt(offset, 10);
|
|
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
|
const { rows } = await pool.query(q, qparams);
|
|
const files = rows.map(r => {
|
|
let size = 0;
|
|
try { size = fs.statSync(path.join(__dirname, r.path)).size; } catch {}
|
|
return { id: r.id, token: r.token, name: r.name, student_name: r.student_name, group_name: r.group_name, created_at: r.created_at, size };
|
|
});
|
|
res.json({ files, total });
|
|
});
|
|
|
|
app.get('/api/files/detached', requireAdmin, async (req, res) => {
|
|
const { search, limit, offset } = req.query;
|
|
const conditions = [];
|
|
const params = [];
|
|
conditions.push('entry_id IS NULL');
|
|
if (search) { params.push(`%${search}%`); conditions.push(`name ILIKE $${params.length}`); }
|
|
const where = conditions.join(' AND ');
|
|
const { rows: crows } = await pool.query(
|
|
`SELECT count(*)::int AS n FROM project_files WHERE ${where}`,
|
|
params
|
|
);
|
|
const total = crows[0].n;
|
|
let q = `SELECT id, token, name, path, created_at FROM project_files
|
|
WHERE ${where} ORDER BY created_at DESC`;
|
|
const qparams = params.slice();
|
|
const lim = parseInt(limit, 10);
|
|
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
|
const off = parseInt(offset, 10);
|
|
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
|
const { rows } = await pool.query(q, qparams);
|
|
const files = rows.map(r => {
|
|
let size = 0;
|
|
try { size = fs.statSync(path.join(__dirname, r.path)).size; } catch {}
|
|
return { id: r.id, token: r.token, name: r.name, created_at: r.created_at, size };
|
|
});
|
|
res.json({ files, total });
|
|
});
|
|
|
|
app.post('/api/files/:id/detach', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const { rows } = await pool.query(
|
|
`SELECT pf.entry_id, e.group_id FROM project_files pf LEFT JOIN entries e ON e.id = pf.entry_id WHERE pf.id = $1`,
|
|
[req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
const { entry_id, group_id } = rows[0];
|
|
if (!entry_id || (group_id && !(await groupBelongsToBranches(req.user, group_id)))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
|
|
}
|
|
}
|
|
const { rows } = await pool.query(
|
|
'UPDATE project_files SET entry_id = NULL, detached_at = now() WHERE id = $1 RETURNING *',
|
|
[req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
invalidateShare();
|
|
invalidateEntries();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/files/:id', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const { rows } = await pool.query(
|
|
`SELECT pf.entry_id, e.group_id FROM project_files pf LEFT JOIN entries e ON e.id = pf.entry_id WHERE pf.id = $1`,
|
|
[req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
const { entry_id, group_id } = rows[0];
|
|
if (!entry_id || (group_id && !(await groupBelongsToBranches(req.user, group_id)))) {
|
|
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
|
|
}
|
|
}
|
|
const { rows } = await pool.query('SELECT path FROM project_files WHERE id = $1', [req.params.id]);
|
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
|
safeUnlink(rows[0].path);
|
|
await pool.query('DELETE FROM project_files WHERE id = $1', [req.params.id]);
|
|
invalidateShare();
|
|
invalidateEntries();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/files/:token', fileLimiter, async (req, res) => {
|
|
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]);
|
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
|
const r = rows[0];
|
|
const fp = path.join(__dirname, r.path);
|
|
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' });
|
|
if (isImageName(r.name)) {
|
|
if (req.query.thumb) return sendImageThumb(res, fp);
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
return res.sendFile(fp);
|
|
}
|
|
return res.download(fp, r.name);
|
|
});
|
|
|
|
app.get('/api/stats', requireAuth, async (req, res) => {
|
|
const payload = await cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
|
const isAdmin = req.user.role === 'admin';
|
|
const s = branchScope(req.user);
|
|
let groupFilter;
|
|
if (isAdmin) {
|
|
groupFilter = { where: '', params: [] };
|
|
} else if (!s.ids.length) {
|
|
groupFilter = { where: ' AND 1 = 0', params: [] };
|
|
} else {
|
|
const ph = s.ids.map(id => `$${s.ids.indexOf(id) + 1}`).join(',');
|
|
groupFilter = { where: ` AND g.branch_id IN (${ph})`, params: s.ids };
|
|
}
|
|
const groupsParams = isAdmin ? [] : (s.ids.length ? s.ids : [0]);
|
|
const groupsWhere = isAdmin ? '' : (s.ids.length ? ` WHERE g.branch_id IN (${groupsParams.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0');
|
|
const [entries, trash, groups, students, today] = await Promise.all([
|
|
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params),
|
|
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${groupFilter.where}`, groupFilter.params),
|
|
pool.query(`SELECT count(*)::int AS n FROM groups g${groupsWhere}`, groupsParams),
|
|
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${groupFilter.where}`, groupFilter.params),
|
|
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${groupFilter.where}`, groupFilter.params),
|
|
]);
|
|
return {
|
|
entries: entries.rows[0].n,
|
|
trash: trash.rows[0].n,
|
|
groups: groups.rows[0].n,
|
|
students: students.rows[0].n,
|
|
today: today.rows[0].n,
|
|
};
|
|
});
|
|
res.json(payload);
|
|
});
|
|
|
|
app.get('/api/system-info', requireAdmin, async (_, res) => {
|
|
try {
|
|
const payload = await cacheWrap('system-info', SYSTEM_TTL_MS, async () => {
|
|
const db = await pool.query(`
|
|
SELECT
|
|
pg_size_pretty(pg_database_size(current_database())) AS db_size,
|
|
pg_database_size(current_database()) AS db_size_bytes
|
|
`);
|
|
|
|
const tables = await pool.query(`
|
|
SELECT
|
|
schemaname,
|
|
relname,
|
|
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS size,
|
|
pg_total_relation_size(schemaname || '.' || relname) AS size_bytes
|
|
FROM pg_stat_user_tables
|
|
ORDER BY pg_total_relation_size(schemaname || '.' || relname) DESC
|
|
`);
|
|
|
|
const photoStats = await pool.query(`
|
|
SELECT count(*)::int AS count,
|
|
sum(pg_column_size(photo_path))::bigint AS path_size_bytes
|
|
FROM group_photos
|
|
`);
|
|
|
|
const fileStats = await pool.query(`
|
|
SELECT count(*)::int AS count,
|
|
sum(pg_column_size(path))::bigint AS path_size_bytes
|
|
FROM project_files
|
|
`);
|
|
|
|
const entryPhotoStats = await pool.query(`
|
|
SELECT count(*)::int AS count
|
|
FROM entries
|
|
WHERE photo_path IS NOT NULL AND deleted_at IS NULL
|
|
`);
|
|
|
|
function sumPhotoSizes(paths) {
|
|
let bytes = 0;
|
|
for (const p of paths) {
|
|
if (!p) continue;
|
|
const fp = path.join(__dirname, p);
|
|
try {
|
|
const st = fs.statSync(fp);
|
|
if (st.isFile()) bytes += st.size;
|
|
} catch {}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
const groupPhotoSizes = await pool.query('SELECT photo_path FROM group_photos');
|
|
const entryPhotoSizes = await pool.query('SELECT photo_path FROM entries WHERE photo_path IS NOT NULL AND deleted_at IS NULL');
|
|
const groupPhotoBytes = sumPhotoSizes(groupPhotoSizes.rows.map(r => r.photo_path));
|
|
const entryPhotoBytes = sumPhotoSizes(entryPhotoSizes.rows.map(r => r.photo_path));
|
|
|
|
const uploadsDir = path.join(__dirname, 'uploads');
|
|
let uploadsSize = 0;
|
|
let uploadsCount = 0;
|
|
if (fs.existsSync(uploadsDir)) {
|
|
for (const f of fs.readdirSync(uploadsDir)) {
|
|
const fp = path.join(uploadsDir, f);
|
|
if (fs.statSync(fp).isFile()) {
|
|
uploadsCount++;
|
|
uploadsSize += fs.statSync(fp).size;
|
|
}
|
|
}
|
|
}
|
|
|
|
const diskInfo = getDiskInfo();
|
|
|
|
return {
|
|
database: {
|
|
size: db.rows[0].db_size,
|
|
size_bytes: parseInt(db.rows[0].db_size_bytes, 10),
|
|
tables: tables.rows.map(t => ({
|
|
name: t.relname,
|
|
size: t.size,
|
|
size_bytes: parseInt(t.size_bytes, 10),
|
|
})),
|
|
},
|
|
photos: {
|
|
group_photos: { count: photoStats.rows[0].count || 0, size: formatBytes(groupPhotoBytes), size_bytes: groupPhotoBytes },
|
|
entry_photos: { count: entryPhotoStats.rows[0].count || 0, size: formatBytes(entryPhotoBytes), size_bytes: entryPhotoBytes },
|
|
total_count: (photoStats.rows[0].count || 0) + (entryPhotoStats.rows[0].count || 0),
|
|
total_size: formatBytes(groupPhotoBytes + entryPhotoBytes),
|
|
total_size_bytes: groupPhotoBytes + entryPhotoBytes,
|
|
},
|
|
files: {
|
|
project_files: { count: fileStats.rows[0].count || 0 },
|
|
},
|
|
uploads: {
|
|
count: uploadsCount,
|
|
size: formatBytes(uploadsSize),
|
|
size_bytes: uploadsSize,
|
|
},
|
|
disk: diskInfo,
|
|
};
|
|
});
|
|
res.json(payload);
|
|
} catch (e) {
|
|
console.error('System info error:', e);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/dashboard', requireAuth, async (req, res) => {
|
|
const payload = await cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS, async () => {
|
|
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
|
const isAdmin = req.user.role === 'admin';
|
|
const s = branchScope(req.user);
|
|
const branchIds = isAdmin ? [] : s.ids;
|
|
const whereGroup = isAdmin ? '' : (branchIds.length ? ` AND g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' AND 1 = 0');
|
|
const whereGroupParams = isAdmin ? [] : branchIds;
|
|
const whereEntry = isAdmin ? '' : (branchIds.length ? ` AND g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' AND 1 = 0');
|
|
|
|
const [stats, activity, active, recent, top, photos, latestPhotos] = await Promise.all([
|
|
(async () => {
|
|
const ew = !!whereEntry;
|
|
const entriesP = `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`;
|
|
const [entries, trash, groups, students, today] = await Promise.all([
|
|
pool.query(entriesP, ew ? whereGroupParams : []),
|
|
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
|
pool.query(`SELECT count(*)::int AS n FROM groups g${isAdmin ? '' : (branchIds.length ? ` WHERE g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' WHERE 1 = 0')}`, isAdmin ? [] : branchIds),
|
|
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
|
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL AND e.created_at >= now()::date${ew ? whereEntry : ''}`, ew ? whereGroupParams : []),
|
|
]);
|
|
return { entries: entries.rows[0].n, trash: trash.rows[0].n, groups: groups.rows[0].n, students: students.rows[0].n, today: today.rows[0].n };
|
|
})(),
|
|
pool.query(
|
|
`SELECT to_char(e.created_at, 'YYYY-MM-DD') AS d, count(*)::int AS n
|
|
FROM entries e JOIN groups g ON g.id = e.group_id
|
|
WHERE e.deleted_at IS NULL AND e.created_at >= (now() - interval '13 days')::date${whereEntry}
|
|
GROUP BY 1 ORDER BY 1`,
|
|
whereEntry ? whereGroupParams : []
|
|
),
|
|
pool.query(
|
|
`SELECT g.* FROM groups g
|
|
WHERE g.day_of_week IS NOT NULL AND g.time_start IS NOT NULL AND g.time_end IS NOT NULL
|
|
AND g.day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int
|
|
AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN g.time_start AND g.time_end${whereGroup}
|
|
ORDER BY g.id`,
|
|
whereGroup ? whereGroupParams : []
|
|
),
|
|
pool.query(
|
|
`SELECT e.id, e.student_name, e.photo_path, e.created_at, g.name AS group_name
|
|
FROM entries e JOIN groups g ON g.id = e.group_id
|
|
WHERE e.deleted_at IS NULL${whereEntry}
|
|
ORDER BY e.created_at DESC LIMIT 7`,
|
|
whereEntry ? whereGroupParams : []
|
|
),
|
|
pool.query(
|
|
`SELECT e.student_name, count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id
|
|
WHERE e.deleted_at IS NULL${whereEntry} GROUP BY e.student_name ORDER BY n DESC, e.student_name LIMIT 5`,
|
|
whereEntry ? whereGroupParams : []
|
|
),
|
|
pool.query(
|
|
`SELECT gp.id, gp.photo_path, gp.caption, gp.taken_at, g.name AS group_name
|
|
FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
|
|
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`,
|
|
whereGroup ? whereGroupParams : []
|
|
),
|
|
pool.query(
|
|
`SELECT gp.photo_path, gp.taken_at FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup}
|
|
ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
|
|
whereGroup ? whereGroupParams : []
|
|
),
|
|
]);
|
|
const activeGroups = active.rows.map(g => ({
|
|
id: g.id,
|
|
name: g.name,
|
|
day_label: DAYS[g.day_of_week],
|
|
time_start: (g.time_start || '').slice(0, 5),
|
|
time_end: (g.time_end || '').slice(0, 5),
|
|
}));
|
|
return {
|
|
stats: stats,
|
|
activity: activity.rows,
|
|
active_groups: activeGroups,
|
|
recent_entries: recent.rows,
|
|
top_students: top.rows,
|
|
photos: photos.rows,
|
|
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
|
|
disk: getDiskInfo(),
|
|
};
|
|
});
|
|
res.json(payload);
|
|
});
|
|
|
|
const entryFields = upload.fields([{ name: 'photo', maxCount: 10 }, { name: 'files', maxCount: 10 }]);
|
|
app.post('/api/entries', entryLimiter, (req, res, next) => {
|
|
entryFields(req, res, (err) => {
|
|
if (!err) return next();
|
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' });
|
|
if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)' });
|
|
if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' });
|
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
|
});
|
|
}, async (req, res) => {
|
|
const { student_name, group_id, description, website } = req.body;
|
|
const photos = req.files?.photo || [];
|
|
const projectFiles = req.files?.files || [];
|
|
if (website) {
|
|
photos.forEach(removeUpload);
|
|
projectFiles.forEach(removeUpload);
|
|
await recordFailure(req, 'honeypot', 1, BAN_TTL_MS);
|
|
return res.status(400).json({ error: 'Spam detected' });
|
|
}
|
|
if (!student_name?.trim() || !group_id || !description?.trim()) {
|
|
photos.forEach(removeUpload);
|
|
projectFiles.forEach(removeUpload);
|
|
return res.status(400).json({ error: 'All fields required' });
|
|
}
|
|
if (!photos.length) {
|
|
projectFiles.forEach(removeUpload);
|
|
return res.status(400).json({ error: 'Фото обязательно' });
|
|
}
|
|
const totalBytes = photos.reduce((s, f) => s + (f.size || 0), 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0);
|
|
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
|
photos.forEach(removeUpload);
|
|
projectFiles.forEach(removeUpload);
|
|
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
|
|
}
|
|
const gid = Number.parseInt(group_id, 10);
|
|
if (!Number.isInteger(gid)) {
|
|
photos.forEach(removeUpload);
|
|
projectFiles.forEach(removeUpload);
|
|
return res.status(400).json({ error: 'Группа не найдена' });
|
|
}
|
|
const grpCheck = await pool.query('SELECT id FROM groups WHERE id = $1', [gid]);
|
|
if (!grpCheck.rows.length) {
|
|
photos.forEach(removeUpload);
|
|
projectFiles.forEach(removeUpload);
|
|
return res.status(400).json({ error: 'Группа не найдена' });
|
|
}
|
|
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
|
|
if (intervalMin > 0) {
|
|
const dup = await pool.query(
|
|
'SELECT count(*)::int AS n FROM entries WHERE student_name = $1 AND created_at >= now() - ($2 || \' minutes\')::interval',
|
|
[student_name.trim(), intervalMin]
|
|
);
|
|
if (dup.rows[0].n > 0) {
|
|
photos.forEach(removeUpload);
|
|
projectFiles.forEach(removeUpload);
|
|
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
|
|
}
|
|
}
|
|
for (const p of photos) {
|
|
await convertPhoto(p);
|
|
}
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
await client.query(
|
|
'INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
|
[student_name.trim()]
|
|
);
|
|
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
|
|
const { rows } = await client.query(
|
|
`INSERT INTO entries (student_name, group_id, description, description_original, photo_path)
|
|
VALUES ($1, $2, $3, $3, $4) RETURNING *`,
|
|
[student_name.trim(), gid, description.trim(), mainPhotoPath]
|
|
);
|
|
for (let i = 0; i < photos.length; i++) {
|
|
const p = photos[i];
|
|
await client.query(
|
|
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
|
|
[rows[0].id, `/uploads/${p.filename}`, i]
|
|
);
|
|
}
|
|
for (const f of projectFiles) {
|
|
const token = crypto.randomBytes(16).toString('hex');
|
|
await client.query(
|
|
'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)',
|
|
[rows[0].id, token, `/uploads/${f.filename}`, f.originalname]
|
|
);
|
|
}
|
|
await client.query('COMMIT');
|
|
if (entryAutoChecker) entryAutoChecker.notify();
|
|
invalidateEntries();
|
|
invalidateStats();
|
|
broadcastEntryChanged();
|
|
res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length });
|
|
} catch (e) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
photos.forEach(removeUpload);
|
|
projectFiles.forEach(removeUpload);
|
|
console.error('POST /api/entries:', e);
|
|
res.status(500).json({ error: e.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
const { student_name, group_id, description } = req.body;
|
|
const newPhotos = req.files || [];
|
|
for (const p of newPhotos) {
|
|
await convertPhoto(p);
|
|
}
|
|
|
|
const { rows } = await pool.query(
|
|
`UPDATE entries SET
|
|
student_name = COALESCE($1, student_name),
|
|
group_id = COALESCE($2, group_id),
|
|
description = COALESCE($3, description),
|
|
description_original = COALESCE($3, description_original),
|
|
description_ai = CASE WHEN $3 IS NULL THEN description_ai ELSE NULL END,
|
|
ai_status = CASE WHEN $3 IS NULL THEN ai_status ELSE 'pending' END,
|
|
ai_error = CASE WHEN $3 IS NULL THEN ai_error ELSE NULL END,
|
|
ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END
|
|
WHERE id = $4 RETURNING *`,
|
|
[
|
|
student_name ? student_name.trim() : null,
|
|
group_id || null,
|
|
description ? description.trim() : null,
|
|
req.params.id,
|
|
]
|
|
);
|
|
if (!rows.length) {
|
|
newPhotos.forEach(p => safeUnlink(p.path));
|
|
return res.status(404).json({ error: 'Not found' });
|
|
}
|
|
if (description && entryAutoChecker) entryAutoChecker.notify();
|
|
|
|
const { rows: existingPhotos } = await pool.query('SELECT id, photo_path FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id', [req.params.id]);
|
|
const nextSortOrder = existingPhotos.length;
|
|
|
|
for (let i = 0; i < newPhotos.length; i++) {
|
|
const p = newPhotos[i];
|
|
await pool.query(
|
|
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
|
|
[req.params.id, `/uploads/${p.filename}`, nextSortOrder + i]
|
|
);
|
|
}
|
|
if (!rows[0].photo_path && newPhotos.length) {
|
|
rows[0].photo_path = `/uploads/${newPhotos[0].filename}`;
|
|
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
|
}
|
|
|
|
await logAudit(req, 'entry.update', { id: req.params.id });
|
|
invalidateEntries();
|
|
invalidateStats();
|
|
res.json(rows[0]);
|
|
});
|
|
|
|
app.get('/api/entries/:id/photos', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
const { rows } = await pool.query(
|
|
'SELECT id, photo_path, caption, sort_order, created_at FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id',
|
|
[req.params.id]
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
app.delete('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
const { rows } = await pool.query('SELECT photo_path, sort_order FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
|
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
|
const { photo_path: photoPath, sort_order: photoSort } = rows[0];
|
|
await pool.query('DELETE FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
|
safeUnlink(photoPath);
|
|
await pool.query('UPDATE entry_photos SET sort_order = sort_order - 1 WHERE entry_id = $1 AND sort_order > $2', [req.params.id, photoSort]);
|
|
const { rows: mainRows } = await pool.query('SELECT id FROM entries WHERE id = $1 AND photo_path = $2', [req.params.id, photoPath]);
|
|
if (mainRows.length) {
|
|
const { rows: nextRows } = await pool.query('SELECT photo_path FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id LIMIT 1', [req.params.id]);
|
|
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [nextRows.length ? nextRows[0].photo_path : null, req.params.id]);
|
|
}
|
|
await logAudit(req, 'entry.photo.delete', { entry_id: req.params.id, photo_id: req.params.photoId });
|
|
invalidateEntries();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.put('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
const { caption, sort_order } = req.body;
|
|
const { rows } = await pool.query(
|
|
'UPDATE entry_photos SET caption = COALESCE($1, caption), sort_order = COALESCE($2, sort_order) WHERE id = $3 AND entry_id = $4 RETURNING *',
|
|
[caption ?? null, sort_order !== undefined ? sort_order : null, req.params.photoId, req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
|
await logAudit(req, 'entry.photo.update', { entry_id: req.params.id, photo_id: req.params.photoId });
|
|
invalidateEntries();
|
|
res.json(rows[0]);
|
|
});
|
|
|
|
app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
const { rows } = await pool.query('SELECT photo_path FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
|
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
|
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
|
await logAudit(req, 'entry.photo.set_main', { entry_id: req.params.id, photo_id: req.params.photoId });
|
|
invalidateEntries();
|
|
res.json({ ok: true, photo_path: rows[0].photo_path });
|
|
});
|
|
|
|
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
await pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1', [req.params.id]);
|
|
await logAudit(req, 'entry.soft-delete', { id: req.params.id });
|
|
invalidateEntries();
|
|
invalidateStats();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.put('/api/entries/:id/restore', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) {
|
|
console.warn(`[RESTORE DENIED] User ${req.user.id} (${req.user.username}) role=${req.user.role} branches=${JSON.stringify(req.user.branch_ids)} tried to restore entry ${req.params.id} (group_id=${acc.group_id})`);
|
|
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
}
|
|
const result = await pool.query('UPDATE entries SET deleted_at = NULL WHERE id = $1', [req.params.id]);
|
|
console.log(`[RESTORE] User ${req.user.id} (${req.user.username}) restored entry ${req.params.id}, rowCount=${result.rowCount}`);
|
|
if (result.rowCount === 0) {
|
|
return res.status(404).json({ error: 'Запись не найдена или уже восстановлена' });
|
|
}
|
|
await logAudit(req, 'entry.restore', { id: req.params.id });
|
|
invalidateEntries();
|
|
invalidateStats();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/entries/:id/permanent', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) {
|
|
console.warn(`[PERM DELETE DENIED] User ${req.user.id} (${req.user.username}) role=${req.user.role} branches=${JSON.stringify(req.user.branch_ids)} tried to perm delete entry ${req.params.id} (group_id=${acc.group_id})`);
|
|
return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
}
|
|
await removeEntryFiles(req.params.id);
|
|
await pool.query('DELETE FROM entries WHERE id = $1', [req.params.id]);
|
|
await logAudit(req, 'entry.permanent-delete', { id: req.params.id });
|
|
invalidateEntries();
|
|
invalidateStats();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/ai/correct', requireAuth, async (req, res) => {
|
|
const text = reqStr(req.body?.text, 5000);
|
|
if (!text) return res.status(400).json({ error: 'Текст не указан' });
|
|
try {
|
|
const corrected = await aiCorrectText(text);
|
|
res.json({ suggestion: corrected });
|
|
} catch (e) {
|
|
res.status(502).json({ error: 'Сервис ИИ недоступен: ' + e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/ai/queue', requireAdmin, async (_, res) => {
|
|
const { rows } = await pool.query(
|
|
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
|
);
|
|
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
|
|
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
|
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
|
res.json({
|
|
enabled,
|
|
counts,
|
|
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
|
|
model: AI_MODEL,
|
|
});
|
|
});
|
|
|
|
async function aiHealthCheck() {
|
|
const startedAt = Date.now();
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 1500);
|
|
try {
|
|
const r = await fetch(`${AI_URL}/health`, { signal: controller.signal });
|
|
const latency_ms = Date.now() - startedAt;
|
|
if (!r.ok) return { reachable: false, latency_ms, error: `HTTP ${r.status}` };
|
|
return { reachable: true, latency_ms, error: null };
|
|
} catch (e) {
|
|
const latency_ms = Date.now() - startedAt;
|
|
const error = e && e.name === 'AbortError' ? 'timeout' : (e && e.message ? e.message : 'unreachable');
|
|
return { reachable: false, latency_ms, error };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
app.get('/api/ai/status', requireAdmin, async (_, res) => {
|
|
const { rows } = await pool.query(
|
|
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
|
);
|
|
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
|
|
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
|
const [pending, recent, errors] = await Promise.all([
|
|
pool.query(
|
|
`SELECT e.id, e.student_name, e.created_at, g.name AS group_name
|
|
FROM entries e JOIN groups g ON g.id = e.group_id
|
|
WHERE e.ai_status IN ('pending', 'processing') AND e.deleted_at IS NULL
|
|
ORDER BY e.id ASC LIMIT 20`
|
|
),
|
|
pool.query(
|
|
`SELECT e.id, e.student_name, e.ai_status, e.ai_checked_at, e.ai_error, g.name AS group_name,
|
|
e.description_original, e.description_ai,
|
|
(e.description_ai IS NOT NULL AND e.description_original IS NOT NULL AND e.description_ai <> e.description_original) AS changed
|
|
FROM entries e JOIN groups g ON g.id = e.group_id
|
|
WHERE e.ai_checked_at IS NOT NULL AND e.deleted_at IS NULL
|
|
ORDER BY e.ai_checked_at DESC LIMIT 30`
|
|
),
|
|
pool.query(
|
|
`SELECT e.id, e.student_name, e.ai_error, e.ai_checked_at, g.name AS group_name
|
|
FROM entries e JOIN groups g ON g.id = e.group_id
|
|
WHERE e.ai_status = 'error' AND e.deleted_at IS NULL
|
|
ORDER BY e.ai_checked_at DESC NULLS LAST, e.id DESC LIMIT 20`
|
|
),
|
|
]);
|
|
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
|
const service = await aiHealthCheck();
|
|
res.json({
|
|
enabled,
|
|
model: AI_MODEL,
|
|
ai_url: AI_URL,
|
|
service,
|
|
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
|
|
counts,
|
|
pending: pending.rows,
|
|
recent: recent.rows,
|
|
errors: errors.rows,
|
|
});
|
|
});
|
|
|
|
app.post('/api/ai/wake', requireAdmin, async (req, res) => {
|
|
if (entryAutoChecker) entryAutoChecker.notify();
|
|
await logAudit(req, 'ai.wake', {});
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/ai/enabled', requireAdmin, async (req, res) => {
|
|
const enabled = !!req.body?.enabled;
|
|
await pool.query(
|
|
`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', $1)
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
|
[enabled ? 'true' : 'false']
|
|
);
|
|
if (enabled && entryAutoChecker) entryAutoChecker.notify();
|
|
await logAudit(req, 'ai.enabled', { enabled });
|
|
res.json({ ok: true, enabled });
|
|
});
|
|
|
|
app.post('/api/ai/requeue-failed', requireAdmin, async (req, res) => {
|
|
const { rowCount } = await pool.query(
|
|
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL
|
|
WHERE ai_status = 'error' AND deleted_at IS NULL`
|
|
);
|
|
if (entryAutoChecker) entryAutoChecker.notify();
|
|
await logAudit(req, 'ai.requeue-failed', { count: rowCount });
|
|
invalidateEntries();
|
|
res.json({ ok: true, count: rowCount });
|
|
});
|
|
|
|
app.post('/api/entries/:id/ai/recheck', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
const { rows } = await pool.query(
|
|
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE id = $1 RETURNING id`,
|
|
[req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
|
if (entryAutoChecker) entryAutoChecker.notify();
|
|
await logAudit(req, 'entry.ai.recheck', { id: req.params.id });
|
|
invalidateEntries();
|
|
invalidateStats();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/entries/:id/ai/revert', requireAuth, async (req, res) => {
|
|
if (req.user.role !== 'admin') {
|
|
const acc = await entryAccessible(req.user, req.params.id);
|
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
|
}
|
|
const { rows } = await pool.query(
|
|
`UPDATE entries SET description = description_original, description_ai = NULL,
|
|
ai_status = 'reverted', ai_error = NULL, ai_checked_at = now()
|
|
WHERE id = $1 AND description_original IS NOT NULL RETURNING id`,
|
|
[req.params.id]
|
|
);
|
|
if (!rows.length) return res.status(400).json({ error: 'Оригинал текста недоступен' });
|
|
await logAudit(req, 'entry.ai.revert', { id: req.params.id });
|
|
invalidateEntries();
|
|
invalidateStats();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/trash', requireAuth, async (req, res) => {
|
|
const { limit, offset } = req.query;
|
|
const bw = branchScope(req.user);
|
|
const scoped = req.user.role !== 'admin';
|
|
let where = ' WHERE e.deleted_at IS NOT NULL';
|
|
const params = [];
|
|
if (scoped) {
|
|
if (bw.ids.length) {
|
|
where += ` AND g.branch_id IN (${bw.ids.map(id => `$${params.push(id)}`).join(',')})`;
|
|
} else {
|
|
where += ' AND 1 = 0';
|
|
}
|
|
}
|
|
console.log(`[TRASH] User ${req.user.id} (${req.user.username}) role=${req.user.role} branch_ids=${JSON.stringify(bw.ids)} scoped=${scoped} where=${where} params=${JSON.stringify(params)}`);
|
|
const { rows: crows } = await pool.query(
|
|
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id ${where}`,
|
|
params
|
|
);
|
|
const total = crows[0].n;
|
|
let q = `SELECT e.*, g.name AS group_name FROM entries e
|
|
JOIN groups g ON g.id = e.group_id
|
|
${where} ORDER BY e.deleted_at DESC`;
|
|
const qparams = params.slice();
|
|
const lim = parseInt(limit, 10);
|
|
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
|
const off = parseInt(offset, 10);
|
|
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
|
const { rows } = await pool.query(q, qparams);
|
|
console.log(`[TRASH] Found ${rows.length} entries for user ${req.user.id}`);
|
|
let files = {};
|
|
if (rows.length) {
|
|
const fRes = await pool.query(
|
|
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
|
[rows.map(r => r.id)]
|
|
);
|
|
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
|
|
}
|
|
rows.forEach(r => { r.files = files[r.id] || []; });
|
|
res.json({ entries: rows, total });
|
|
});
|
|
|
|
app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => {
|
|
const { rows } = await pool.query(
|
|
`SELECT photo_path AS p FROM entries WHERE deleted_at IS NOT NULL
|
|
UNION ALL
|
|
SELECT pf.path AS p FROM project_files pf
|
|
JOIN entries e ON e.id = pf.entry_id WHERE e.deleted_at IS NOT NULL`
|
|
);
|
|
rows.forEach(r => safeUnlink(r.p));
|
|
const d = await pool.query('DELETE FROM entries WHERE deleted_at IS NOT NULL');
|
|
invalidateEntries();
|
|
invalidateStats();
|
|
res.json({ ok: true, deleted: d.rowCount });
|
|
});
|
|
|
|
// --- Error handlers ---
|
|
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
|
|
|
|
function isApiRoute(req) {
|
|
return req.path.startsWith('/api/') || req.path.startsWith('/s/');
|
|
}
|
|
|
|
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)`));
|
|
}
|
|
|
|
(async () => {
|
|
try { await ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); }
|
|
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
|
|
try { await ensureAuditTable(); } catch (err) { console.error('Audit table:', err); }
|
|
try { await ensureBannedIpsTable(); } catch (err) { console.error('Banned IPs table:', err); }
|
|
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
|
|
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
|
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
|
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
|
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
|
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
|
entryAutoChecker.start();
|
|
console.log('AI auto-check worker started');
|
|
})();
|