diff --git a/public/js/trash.js b/public/js/trash.js index ce0a504..1097941 100644 --- a/public/js/trash.js +++ b/public/js/trash.js @@ -61,7 +61,7 @@ function goPage(p) { page = p; loadTrash(); } async function restoreEntry(id) { const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() }); if (res.ok) { showToast('Запись восстановлена'); loadTrash(); } - else { const err = await res.json(); alert(err.error || 'Ошибка'); } + else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); } } async function hardDelete(id) { diff --git a/server.js b/server.js index 3c4fa08..826c1f5 100644 --- a/server.js +++ b/server.js @@ -40,10 +40,10 @@ const fileLimiter = rateLimit({ message: { error: 'Слишком много запросов. Попробуйте позже.' }, }); +const ADMIN_USERNAME = (process.env.ADMIN_USERNAME || 'admin').toLowerCase().trim(); const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; if (!ADMIN_PASSWORD) { - console.error('FATAL: ADMIN_PASSWORD environment variable is not set. Refusing to start.'); - process.exit(1); + console.warn('ADMIN_PASSWORD не задан. Первый админ не будет создан автоматически.'); } app.use(helmet({ @@ -66,12 +66,117 @@ app.use(express.json({ limit: '1mb' })); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use(express.static(path.join(__dirname, 'public'))); +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +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) { - const token = req.headers['x-admin-token']; - if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' }); + 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 }; + return { found: true, group_id: groupId, allowed: groupId && (await groupBelongsToBranches(user, groupId)) }; +} + function fixFilename(str) { try { return Buffer.from(str, 'latin1').toString('utf8'); @@ -211,6 +316,7 @@ async function ensureAuditTable() { 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() { @@ -224,17 +330,227 @@ async function ensureBranchesTable() { await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`); } +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 (action, target, ip) VALUES ($1, $2, $3)', - [action, target ?? null, req?.ip?.slice(0, 45) || null] + '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 (!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) { + return res.status(401).json({ error: 'Неверный логин или пароль' }); + } + const valid = await bcrypt.compare(password, user.password_hash || ''); + if (!valid) { + 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)); +}); + +// --- 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'); @@ -288,7 +604,9 @@ app.put('/api/settings', requireAdmin, async (req, res) => { app.get('/api/audit', requireAdmin, async (req, res) => { const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000); const { rows } = await pool.query( - 'SELECT id, action, target, ip, created_at FROM audit_log ORDER BY id DESC LIMIT $1', + `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`, [limit] ); res.json(rows); @@ -382,6 +700,7 @@ function normalizeRestoreData(data) { 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), @@ -406,26 +725,49 @@ function normalizeRestoreData(data) { 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 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 }; + return { groups, students, entries, project_files, settings, branches, users, user_branches }; } app.get('/api/backup', requireAdmin, async (req, res) => { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { - const [g, s, e, st, pf] = await Promise.all([ + const [g, s, e, st, pf, br, us, ub] = 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'), ]); 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 }; + 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 }; fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true }); const dir = path.join(__dirname, 'uploads'); @@ -517,13 +859,24 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req 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) VALUES ($1,$2,$3,$4,$5,$6)', - [x.id, x.name, x.created_at, x.day_of_week, x.time_start, x.time_end] + '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) { @@ -544,13 +897,25 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [x.id, x.entry_id, x.token, x.path, x.name, 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']) { + for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users']) { 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]); } @@ -583,6 +948,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req 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', {}); res.json({ ok: true }); }); @@ -593,18 +959,33 @@ function normDates(o) { if (o && o.date_to) o.date_to = o.date_to instanceof Date ? o.date_to.toISOString().slice(0, 10) : String(o.date_to).slice(0, 10); return o; } -app.get('/api/links', requireAdmin, async (_, res) => { +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 ORDER BY l.created_at DESC` + 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', requireAdmin, async (req, res) => { +app.post('/api/links', requireAuth, async (req, res) => { const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); + 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()) { @@ -631,9 +1012,20 @@ app.post('/api/links', requireAdmin, async (req, res) => { res.status(201).json(normDates(rows[0])); }); -app.put('/api/links/:id', requireAdmin, async (req, res) => { +app.put('/api/links/:id', requireAuth, async (req, res) => { const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); + 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()) { @@ -675,7 +1067,14 @@ app.put('/api/links/:id', requireAdmin, async (req, res) => { res.json(normDates(rows[0])); }); -app.delete('/api/links/:id', requireAdmin, async (req, res) => { +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 }); res.json({ ok: true }); @@ -808,7 +1207,8 @@ app.get('/s/:token', (req, res) => { }); // --- Groups CRUD --- -app.get('/api/groups', apiLimiter, async (_, res) => { +app.get('/api/groups', apiLimiter, optionalAuth, async (req, res) => { + const bw = req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }; const { rows } = await pool.query( `SELECT g.*, gp.photo_path AS cover_path, b.name AS branch_name FROM groups g @@ -818,7 +1218,9 @@ app.get('/api/groups', apiLimiter, async (_, res) => { ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1 ) gp ON true LEFT JOIN branches b ON b.id = g.branch_id - ORDER BY g.id` + WHERE 1=1${bw.where} + ORDER BY g.id`, + bw.params ); res.json(rows); }); @@ -836,8 +1238,15 @@ app.get('/api/groups/active', apiLimiter, async (_, res) => { res.json(rows); }); -app.put('/api/groups/:id', requireAdmin, async (req, res) => { +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 @@ -861,13 +1270,18 @@ app.put('/api/groups/:id', requireAdmin, async (req, res) => { } }); -app.post('/api/groups', requireAdmin, async (req, res) => { +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(), branch_id === undefined || branch_id === null || branch_id === '' ? null : branch_id] + [name.trim(), isAdmin ? effectiveBranch : null] ); await logAudit(req, 'group.create', { id: rows[0].id, name: name.trim(), branch_id }); res.status(201).json(rows[0]); @@ -877,7 +1291,10 @@ app.post('/api/groups', requireAdmin, async (req, res) => { } }); -app.delete('/api/groups/:id', requireAdmin, async (req, res) => { +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] @@ -889,13 +1306,25 @@ app.delete('/api/groups/:id', requireAdmin, async (req, res) => { }); // --- Branches CRUD --- -app.get('/api/branches', requireAdmin, async (_, res) => { +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` + ORDER BY b.id`, + params ); res.json(rows); }); @@ -945,7 +1374,10 @@ app.delete('/api/branches/:id', requireAdmin, async (req, res) => { res.json({ ok: true }); }); -app.get('/api/groups/:id/photos', requireAdmin, async (req, res) => { +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', @@ -964,13 +1396,24 @@ app.get('/api/groups/:id/photos', requireAdmin, async (req, res) => { }); const groupPhotoUpload = upload.single('photo'); -app.post('/api/groups/:id/photos', requireAdmin, (req, res, next) => { - groupPhotoUpload(req, res, (err) => { - if (!err) return next(); - if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Файл слишком большой (макс. 10 МБ)' }); - if (err.message === 'Only images') return res.status(400).json({ error: 'Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)' }); - if (err.message === 'Not allowed extension') return res.status(400).json({ error: 'Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)' }); - return res.status(400).json({ error: 'Недопустимый файл' }); +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)' }); + 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; @@ -991,7 +1434,10 @@ app.post('/api/groups/:id/photos', requireAdmin, (req, res, next) => { } }); -app.put('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => { +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 @@ -1005,7 +1451,10 @@ app.put('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => { res.json(rows[0]); }); -app.delete('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => { +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] @@ -1017,7 +1466,10 @@ app.delete('/api/groups/:id/photos/:photoId', requireAdmin, async (req, res) => res.json({ ok: true }); }); -app.put('/api/groups/:id/photos/:photoId/cover', requireAdmin, async (req, res) => { +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] @@ -1030,25 +1482,36 @@ app.put('/api/groups/:id/photos/:photoId/cover', requireAdmin, async (req, res) }); // --- Students CRUD --- -app.get('/api/students', apiLimiter, async (_, res) => { +app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => { + 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 ORDER BY s.name` + LEFT JOIN groups g ON g.id = s.group_id + WHERE 1=1${bw.where} ORDER BY s.name`, + bw.params ); res.json(rows); }); -app.get('/api/students/names', requireAdmin, async (_, res) => { +app.get('/api/students/names', requireAuth, async (req, res) => { + const bw = branchWhere(req.user, 'g'); const { rows } = await pool.query( - 'SELECT DISTINCT student_name AS name FROM entries WHERE student_name IS NOT NULL AND student_name <> \'\' ORDER BY student_name' + `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', requireAdmin, async (req, res) => { +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 *', @@ -1062,18 +1525,31 @@ app.post('/api/students', requireAdmin, async (req, res) => { } }); -app.put('/api/students/:id', requireAdmin, async (req, res) => { +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(), - group_id === undefined || group_id === null || group_id === '' ? null : group_id, - req.params.id] + [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() }); @@ -1084,11 +1560,14 @@ app.put('/api/students/:id', requireAdmin, async (req, res) => { } }); -app.post('/api/students/batch-group', requireAdmin, async (req, res) => { +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]; @@ -1101,14 +1580,24 @@ app.post('/api/students/batch-group', requireAdmin, async (req, res) => { res.json({ ok: true, updated: rows.length }); }); -app.delete('/api/students/:id', requireAdmin, async (req, res) => { +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 }); res.json({ ok: true }); }); // --- Entries --- -app.get('/api/entries', requireAdmin, async (req, res) => { +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 = []; @@ -1119,6 +1608,18 @@ app.get('/api/entries', requireAdmin, async (req, res) => { 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}`, @@ -1149,7 +1650,14 @@ app.get('/api/entries', requireAdmin, async (req, res) => { res.json({ entries: rows, total }); }); -app.get('/api/entries/:id/files', requireAdmin, async (req, res) => { +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] @@ -1158,7 +1666,7 @@ app.get('/api/entries/:id/files', requireAdmin, async (req, res) => { }); const entryFilesUpload = adminUpload.array('files', 10); -app.post('/api/entries/:id/files', requireAdmin, (req, res, next) => { +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 МБ)' }); @@ -1170,6 +1678,18 @@ app.post('/api/entries/:id/files', requireAdmin, (req, res, next) => { 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); @@ -1208,7 +1728,7 @@ function isImageName(name) { return /\.(jpe?g|png|gif|webp|bmp|avif|ico)$/i.test(name || ''); } -app.get('/api/files', requireAdmin, async (req, res) => { +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 = []; @@ -1218,9 +1738,21 @@ app.get('/api/files', requireAdmin, async (req, res) => { 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${where}`, + `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; @@ -1271,7 +1803,18 @@ app.get('/api/files/detached', requireAdmin, async (req, res) => { res.json({ files, total }); }); -app.post('/api/files/:id/detach', requireAdmin, async (req, res) => { +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] @@ -1280,7 +1823,18 @@ app.post('/api/files/:id/detach', requireAdmin, async (req, res) => { res.json({ ok: true }); }); -app.delete('/api/files/:id', requireAdmin, async (req, res) => { +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); @@ -1298,13 +1852,26 @@ app.get('/api/files/:token', fileLimiter, async (req, res) => { else res.download(fp, r.name); }); -app.get('/api/stats', requireAdmin, async (_, res) => { +app.get('/api/stats', requireAuth, async (req, res) => { + 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 WHERE deleted_at IS NULL'), - pool.query('SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NOT NULL'), - pool.query('SELECT count(*)::int AS n FROM groups'), - pool.query('SELECT count(DISTINCT student_name)::int AS n FROM entries WHERE deleted_at IS NULL'), - pool.query("SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NULL AND created_at >= now()::date"), + 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), ]); res.json({ entries: entries.rows[0].n, @@ -1442,50 +2009,65 @@ app.get('/api/system-info', requireAdmin, async (_, res) => { } }); -app.get('/api/dashboard', requireAdmin, async (req, res) => { +app.get('/api/dashboard', requireAuth, async (req, res) => { 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('SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NULL'), - pool.query('SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NOT NULL'), - pool.query('SELECT count(*)::int AS n FROM groups'), - pool.query('SELECT count(DISTINCT student_name)::int AS n FROM entries WHERE deleted_at IS NULL'), - pool.query("SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NULL AND created_at >= now()::date"), + 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(created_at, 'YYYY-MM-DD') AS d, count(*)::int AS n - FROM entries WHERE deleted_at IS NULL - AND created_at >= (now() - interval '13 days')::date - GROUP BY 1 ORDER BY 1` + `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 * 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` + `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 - ORDER BY e.created_at DESC LIMIT 7` + WHERE e.deleted_at IS NULL${whereEntry} + ORDER BY e.created_at DESC LIMIT 7`, + whereEntry ? whereGroupParams : [] ), pool.query( - `SELECT student_name, count(*)::int AS n FROM entries - WHERE deleted_at IS NULL GROUP BY student_name ORDER BY n DESC, student_name LIMIT 5` + `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 - ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8` + FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup} + ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 8`, + whereGroup ? whereGroupParams : [] ), pool.query( - `SELECT photo_path, taken_at FROM group_photos - ORDER BY taken_at DESC NULLS LAST, created_at DESC LIMIT 1` + `SELECT gp.photo_path, gp.taken_at FROM group_photos gp JOIN groups g ON g.id = gp.group_id${whereGroup} + ORDER BY gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`, + whereGroup ? whereGroupParams : [] ), ]); const activeGroups = active.rows.map(g => ({ @@ -1593,7 +2175,12 @@ app.post('/api/entries', entryLimiter, (req, res, next) => { } }); -app.put('/api/entries/:id', requireAdmin, async (req, res) => { +app.put('/api/entries/:id', requireAuth, async (req, res) => { + if (req.user.role !== 'admin') { + const acc = await entryAccessible(req.user, req.params.id); + if (!acc.found) return res.status(404).json({ error: 'Not found' }); + if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' }); + } const { student_name, group_id, description } = req.body; const { rows } = await pool.query( `UPDATE entries SET @@ -1613,35 +2200,69 @@ app.put('/api/entries/:id', requireAdmin, async (req, res) => { res.json(rows[0]); }); -app.delete('/api/entries/:id', requireAdmin, async (req, res) => { +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 }); res.json({ ok: true }); }); -app.put('/api/entries/:id/restore', requireAdmin, async (req, res) => { +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: 'Нет доступа к этой записи' }); + } + } await pool.query('UPDATE entries SET deleted_at = NULL WHERE id = $1', [req.params.id]); await logAudit(req, 'entry.restore', { id: req.params.id }); res.json({ ok: true }); }); -app.delete('/api/entries/:id/permanent', requireAdmin, async (req, res) => { +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 }); res.json({ ok: true }); }); -app.get('/api/trash', requireAdmin, async (req, res) => { +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 1=1'; + 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'; + } + } const { rows: crows } = await pool.query( - "SELECT count(*)::int AS n FROM entries WHERE deleted_at IS NOT NULL" + `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NOT NULL` + + (scoped ? (bw.ids.length ? ` AND g.branch_id IN (${bw.ids.map((_, i) => `$${i + 1}`).join(',')})` : ' AND 1 = 0') : ''), + scoped ? bw.ids : [] ); const total = crows[0].n; let q = `SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id - WHERE e.deleted_at IS NOT NULL ORDER BY e.deleted_at DESC`; - const qparams = []; + ${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); @@ -1659,7 +2280,7 @@ app.get('/api/trash', requireAdmin, async (req, res) => { res.json({ entries: rows, total }); }); -app.delete('/api/trash', requireAdmin, async (req, res) => { +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 @@ -1726,5 +2347,8 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { } sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err)); -ensureAuditTable().catch(err => console.error('Audit table:', err)); -ensureBranchesTable().catch(err => console.error('Branches table:', err)); +(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); } +})();