From 026d346d31121c5c7277b02879516ff0c2431cec Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Tue, 7 Jul 2026 10:37:51 +0500 Subject: [PATCH] fdnjhbpfwbz --- .gitignore | 3 +- auth.js | 61 +++++++++++++++++++ package.json | 4 ++ public/app.js | 29 ++++++++- public/auth.js | 136 +++++++++++++++++++++++++++++++++++++++++ public/index.html | 40 +++++++++++- public/style.css | 112 ++++++++++++++++++++++++++++++++++ server.js | 152 +++++++++++++++++++++++++++++++++++++++++----- src/db.js | 12 ++++ 9 files changed, 531 insertions(+), 18 deletions(-) create mode 100644 auth.js create mode 100644 public/auth.js diff --git a/.gitignore b/.gitignore index a5ac6d2..60efdb1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ server.log *.pdf *.txt package-lock.json -*.zip \ No newline at end of file +*.zip +.env \ No newline at end of file diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..a565a23 --- /dev/null +++ b/auth.js @@ -0,0 +1,61 @@ +const bcrypt = require('bcryptjs'); + +class AuthService { + constructor() { + this.db = null; + } + + setDatabase(database) { + this.db = database; + this.initUsers(); + } + + initUsers() { + if (!this.db) return; + const users = [ + { + login: process.env.USER_1_LOGIN, + password: process.env.USER_1_PASSWORD, + name: process.env.USER_1_NAME, + email: process.env.USER_1_EMAIL, + role: process.env.USER_1_ROLE || 'admin' + } + ]; + for (const u of users) { + if (u.login && u.password) { + this.createUserIfNotExists(u); + } + } + } + + async createUserIfNotExists(userData) { + const row = this.db.prepare('SELECT id FROM users WHERE login = ?').get(userData.login); + if (!row) { + const hashedPassword = await bcrypt.hash(userData.password, 10); + this.db.prepare( + 'INSERT INTO users (login, password, name, email, role, auth_type, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime(\'now\'))' + ).run(userData.login, hashedPassword, userData.name, userData.email || null, userData.role, 'local'); + console.log('Создан пользователь: ' + userData.name); + } + } + + authenticate(login, password) { + const user = this.db.prepare("SELECT * FROM users WHERE login = ? AND auth_type = 'local'").get(login); + if (!user) return null; + const isValid = bcrypt.compareSync(password, user.password); + if (isValid) { + this.db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id); + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + return null; + } + + getUserById(id) { + const user = this.db.prepare('SELECT id, login, name, email, role, auth_type, created_at, last_login FROM users WHERE id = ?').get(id); + return user || null; + } +} + +const authService = new AuthService(); +module.exports = authService; diff --git a/package.json b/package.json index 7fde416..0febb7f 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,12 @@ "type": "commonjs", "dependencies": { "adm-zip": "^0.5.17", + "bcryptjs": "^2.4.3", "better-sqlite3": "^12.11.1", + "connect-sqlite3": "^0.9.16", + "dotenv": "^16.3.1", "express": "^4.22.2", + "express-session": "^1.18.2", "multer": "^2.2.0", "pdf-parse": "^2.4.5" } diff --git a/public/app.js b/public/app.js index 4ee45d0..77b0a40 100644 --- a/public/app.js +++ b/public/app.js @@ -1,3 +1,26 @@ +if (typeof window.fetch === 'function' && !window.__fetchPatched) { + window.__fetchPatched = true; + const _origFetch = window.fetch.bind(window); + window.fetch = function(input, init) { + init = init || {}; + init.credentials = 'include'; + init.cache = 'no-store'; + const method = (init.method || 'GET').toUpperCase(); + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + const token = window.__CSRF_TOKEN; + if (token) { + if (!init.headers) init.headers = {}; + if (typeof init.headers === 'object' && !(init.headers instanceof Headers)) { + init.headers['X-CSRF-Token'] = token; + } else if (init.headers instanceof Headers) { + init.headers.set('X-CSRF-Token', token); + } + } + } + return _origFetch(input, init); + }; +} + const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); @@ -804,5 +827,7 @@ function renderDvoikiCards(rows) { }).join(''); } -loadFilters(); -loadAll(); +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('login-form').addEventListener('submit', login); + checkAuth(); +}); diff --git a/public/auth.js b/public/auth.js new file mode 100644 index 0000000..fe85105 --- /dev/null +++ b/public/auth.js @@ -0,0 +1,136 @@ +let currentUser = null; + +function getCookie(name) { + const match = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'); + return match ? match[2] : null; +} + +function setCookie(name, value, maxAgeSec) { + document.cookie = name + '=' + value + ';path=/;max-age=' + maxAgeSec; +} + +function getLockedUntil() { return parseInt(getCookie('lockout_until') || '0', 10); } +function setLockedUntil(ts) { setCookie('lockout_until', ts, 300); } +function getLoginAttempts() { return parseInt(getCookie('login_attempts') || '0', 10); } +function setLoginAttempts(count) { setCookie('login_attempts', count, 300); } +function clearLoginCookies() { + setCookie('login_attempts', 0, 0); + setCookie('lockout_until', 0, 0); +} + +function isLocked() { + return Date.now() < getLockedUntil(); +} + +function getRemainingLockoutSeconds() { + const rem = Math.floor((getLockedUntil() - Date.now()) / 1000); + return Math.max(0, rem); +} + +async function checkAuth() { + try { + const response = await fetch('/api/user', { credentials: 'include' }); + if (response.ok) { + const data = await response.json(); + currentUser = data.user; + if (data.csrfToken) { + window.__CSRF_TOKEN = data.csrfToken; + } + showMainInterface(); + } else { + showLoginInterface(); + } + } catch (error) { + showLoginInterface(); + } +} + +function showLoginInterface() { + document.getElementById('app').style.display = 'none'; + document.getElementById('login-screen').style.display = 'flex'; +} + +function showMainInterface() { + document.getElementById('login-screen').style.display = 'none'; + document.getElementById('app').style.display = 'block'; + const userEl = document.getElementById('currentUser'); + if (userEl && currentUser) { + userEl.textContent = (currentUser.role === 'admin' ? '👑 ' : '') + currentUser.name; + } + if (typeof loadFilters === 'function') loadFilters(); + if (typeof loadAll === 'function') loadAll(); +} + +async function login(event) { + event.preventDefault(); + if (isLocked()) { + const rem = getRemainingLockoutSeconds(); + document.getElementById('login-error').textContent = 'Слишком много попыток. Подождите ' + rem + ' секунд.'; + document.getElementById('login-error').style.display = 'block'; + document.getElementById('login-attempts').textContent = ''; + return; + } + + const login = document.getElementById('login').value; + const password = document.getElementById('password').value; + const errorEl = document.getElementById('login-error'); + const attemptsEl = document.getElementById('login-attempts'); + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ login, password }) + }); + + if (response.ok) { + clearLoginCookies(); + const data = await response.json(); + currentUser = data.user; + if (data.csrfToken) { + window.__CSRF_TOKEN = data.csrfToken; + } + showMainInterface(); + } else if (response.status === 429) { + const data = await response.json(); + errorEl.textContent = data.error; + errorEl.style.display = 'block'; + } else { + let attempts = getLoginAttempts() + 1; + setLoginAttempts(attempts); + if (attempts >= 10) { + setLockedUntil(Date.now() + 300000); + errorEl.textContent = 'Слишком много попыток. Подождите 5 минут.'; + attemptsEl.textContent = ''; + } else { + errorEl.textContent = 'Неверный логин или пароль'; + attemptsEl.textContent = 'Попыток: ' + attempts + ' из 10'; + } + errorEl.style.display = 'block'; + } + } catch (error) { + document.getElementById('login-error').textContent = 'Ошибка соединения'; + document.getElementById('login-error').style.display = 'block'; + } +} + +async function logout() { + try { + await fetch('/api/logout', { method: 'POST' }); + } catch (e) {} + currentUser = null; + window.__CSRF_TOKEN = null; + showLoginInterface(); +} + +function togglePasswordVisibility() { + const pwd = document.getElementById('password'); + const icon = document.querySelector('.password-toggle i'); + if (pwd.type === 'password') { + pwd.type = 'text'; + if (icon) icon.className = 'fas fa-eye-slash'; + } else { + pwd.type = 'password'; + if (icon) icon.className = 'fas fa-eye'; + } +} diff --git a/public/index.html b/public/index.html index 62f407a..ea44027 100644 --- a/public/index.html +++ b/public/index.html @@ -5,11 +5,47 @@ Школьный журнал — статистика + +
+ +
+ + + + diff --git a/public/style.css b/public/style.css index 47a8488..34216e3 100644 --- a/public/style.css +++ b/public/style.css @@ -681,6 +681,118 @@ h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; } font-size: 13px; } +#login-screen { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); +} +.login-card { + background: #fff; + border-radius: 16px; + padding: 40px; + width: 400px; + max-width: 95vw; + box-shadow: 0 8px 40px rgba(0,0,0,0.3); + text-align: center; +} +.login-title { + font-size: 24px; + color: #1a1a2e; + margin-bottom: 6px; +} +.login-subtitle { + font-size: 14px; + color: #888; + margin-bottom: 28px; +} +.input-group { + margin-bottom: 16px; +} +.input-underline { + display: flex; + align-items: center; + border: 1px solid #d0d0d0; + border-radius: 8px; + padding: 0 12px; + transition: border-color 0.15s; +} +.input-underline:focus-within { + border-color: #4f46e5; +} +.input-icon { + color: #999; + font-size: 14px; + margin-right: 8px; +} +.input-underline input { + flex: 1; + border: none; + padding: 12px 0; + font-size: 15px; + outline: none; + background: transparent; +} +.password-toggle { + background: none; + border: none; + color: #999; + cursor: pointer; + padding: 0; + font-size: 14px; + width: auto; +} +.password-toggle:hover { + color: #555; +} +.login-btn { + width: 100%; + padding: 12px; + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.login-error { + margin-top: 14px; + padding: 10px; + background: #fee2e2; + color: #991b1b; + border-radius: 8px; + font-size: 13px; +} +.login-attempts { + margin-top: 8px; + font-size: 12px; + color: #d97706; +} +.header-right { + display: flex; + align-items: center; + gap: 12px; +} +.user-info { + font-size: 14px; + color: #c0c0d0; +} +.btn-logout { + background: transparent; + color: #c0c0d0; + border: 1px solid #555; + padding: 6px 14px; + font-size: 13px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; +} +.btn-logout:hover { + background: rgba(255,255,255,0.1); + color: #fff; + border-color: #888; +} + @media (max-width: 640px) { .upload-section form { flex-direction: column; } button { width: 100%; } diff --git a/server.js b/server.js index 06c1fcd..65fdd91 100644 --- a/server.js +++ b/server.js @@ -1,12 +1,19 @@ +require('dotenv').config(); const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); +const session = require('express-session'); +const SQLiteStore = require('connect-sqlite3')(session); const { parsePdfBuffer } = require('./src/parser'); const { upsertClass, upsertStudent, upsertSubject, upsertTeacher, upsertGradeValue, insertDailyGradeValue, insertLessonPlanValue, db } = require('./src/db'); const { getFullStats, getSubjectDetails, getTeacherDetails, getClassDetails } = require('./src/stats'); const AdmZip = require('adm-zip'); +const authService = require('./auth'); + +authService.setDatabase(db); const app = express(); const PORT = process.env.PORT || 3000; @@ -54,6 +61,123 @@ const tasks = new Map(); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); +app.use(session({ + store: new SQLiteStore({ + db: 'sessions.db', + dir: path.join(__dirname, 'data'), + table: 'sessions' + }), + secret: process.env.SESSION_SECRET || 'fallback_secret', + resave: false, + saveUninitialized: false, + cookie: { + secure: false, + maxAge: 7 * 24 * 60 * 60 * 1000, + httpOnly: true, + sameSite: 'lax' + } +})); + +app.use((req, res, next) => { + const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method); + const isLoginPath = req.path === '/api/login'; + const isStatic = req.path.startsWith('/'); + if (!isLoginPath && req.session && req.session.user && !req.session.csrfToken) { + req.session.csrfToken = crypto.randomBytes(32).toString('hex'); + } + if (mutating) { + if (isLoginPath) return next(); + if (isStatic && !req.path.startsWith('/api/')) return next(); + if (!req.session || !req.session.user) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + const clientToken = req.headers['x-csrf-token']; + if (!req.session.csrfToken) { + req.session.csrfToken = crypto.randomBytes(32).toString('hex'); + } + if (!clientToken || clientToken !== req.session.csrfToken) { + return res.status(403).json({ error: 'CSRF token missing or invalid' }); + } + } + next(); +}); + +const requireAuth = (req, res, next) => { + if (!req.session.user) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + next(); +}; + +const loginAttempts = new Map(); + +app.post('/api/login', (req, res) => { + const ip = req.ip || req.connection.remoteAddress; + const now = Date.now(); + if (loginAttempts.has(ip)) { + const data = loginAttempts.get(ip); + if (now < data.resetTime && data.count > 10) { + return res.status(429).json({ error: 'Слишком много попыток входа. Попробуйте через минуту.' }); + } + if (now > data.resetTime) { + loginAttempts.set(ip, { count: 1, resetTime: now + 60000 }); + } else { + data.count++; + } + } else { + loginAttempts.set(ip, { count: 1, resetTime: now + 60000 }); + } + + const { login, password } = req.body; + if (!login || !password) { + return res.status(400).json({ error: 'Логин и пароль обязательны' }); + } + + try { + const user = authService.authenticate(login, password); + if (user) { + const sessionUser = { + id: user.id, + login: user.login, + name: user.name, + email: user.email, + role: user.role, + auth_type: user.auth_type + }; + if (sessionUser.login === process.env.USER_1_LOGIN) { + sessionUser.role = 'admin'; + } + req.session.user = sessionUser; + req.session.csrfToken = crypto.randomBytes(32).toString('hex'); + req.session.save((err) => { + res.json({ success: true, user: sessionUser, csrfToken: req.session.csrfToken }); + }); + } else { + res.status(401).json({ error: 'Неверный логин или пароль' }); + } + } catch (error) { + res.status(500).json({ error: 'Ошибка сервера при авторизации' }); + } +}); + +app.post('/api/logout', (req, res) => { + req.session.destroy((err) => { + if (err) return res.status(500).json({ error: 'Ошибка при выходе' }); + res.json({ success: true }); + }); +}); + +app.get('/api/user', (req, res) => { + if (req.session && req.session.user) { + if (!req.session.csrfToken) { + req.session.csrfToken = crypto.randomBytes(32).toString('hex'); + } + res.json({ user: req.session.user, csrfToken: req.session.csrfToken }); + } else { + res.status(401).json({ error: 'Не аутентифицирован' }); + } +}); + function processParsedPdf(parsed) { if (!parsed.className) throw new Error('Не удалось определить класс'); if (!parsed.subjects || parsed.subjects.length === 0) throw new Error('Не найдены предметы'); @@ -149,7 +273,7 @@ function processParsedPdf(parsed) { }; } -app.post('/api/upload', upload.single('pdf'), async (req, res) => { +app.post('/api/upload', requireAuth, upload.single('pdf'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); @@ -243,7 +367,7 @@ app.post('/api/upload', upload.single('pdf'), async (req, res) => { } }); -app.get('/api/task/:taskId', (req, res) => { +app.get('/api/task/:taskId', requireAuth, (req, res) => { const task = tasks.get(req.params.taskId); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); @@ -267,7 +391,7 @@ app.get('/api/task/:taskId', (req, res) => { } }); -app.get('/api/stats', (req, res) => { +app.get('/api/stats', requireAuth, (req, res) => { try { res.json(getFullStats(req.query)); } catch (e) { @@ -275,7 +399,7 @@ app.get('/api/stats', (req, res) => { } }); -app.get('/api/subjects/:name', (req, res) => { +app.get('/api/subjects/:name', requireAuth, (req, res) => { try { res.json(getSubjectDetails(req.params.name)); } catch (e) { @@ -283,7 +407,7 @@ app.get('/api/subjects/:name', (req, res) => { } }); -app.get('/api/teachers/:name', (req, res) => { +app.get('/api/teachers/:name', requireAuth, (req, res) => { try { res.json(getTeacherDetails(req.params.name)); } catch (e) { @@ -291,7 +415,7 @@ app.get('/api/teachers/:name', (req, res) => { } }); -app.get('/api/classes/:name', (req, res) => { +app.get('/api/classes/:name', requireAuth, (req, res) => { try { res.json(getClassDetails(req.params.name)); } catch (e) { @@ -299,7 +423,7 @@ app.get('/api/classes/:name', (req, res) => { } }); -app.get('/api/filters', (req, res) => { +app.get('/api/filters', requireAuth, (req, res) => { try { const students = db.prepare(`SELECT DISTINCT s.full_name FROM daily_grades d JOIN students s ON d.student_id = s.id ORDER BY s.full_name`).all().map(r => r.full_name); const subjects = db.prepare(`SELECT DISTINCT sj.name FROM daily_grades d JOIN subjects sj ON d.subject_id = sj.id ORDER BY sj.name`).all().map(r => r.name); @@ -312,7 +436,7 @@ app.get('/api/filters', (req, res) => { } }); -app.get('/api/daily', (req, res) => { +app.get('/api/daily', requireAuth, (req, res) => { try { const { student, subject, teacher, date_from, date_to, limit, offset } = req.query; const cls = buildClassWhere(req.query); @@ -391,7 +515,7 @@ app.get('/api/daily', (req, res) => { } }); -app.get('/api/pivot', (req, res) => { +app.get('/api/pivot', requireAuth, (req, res) => { try { const { period, student, subject, teacher, date_from, date_to } = req.query; const cls = buildClassWhere(req.query); @@ -548,7 +672,7 @@ app.get('/api/pivot', (req, res) => { } }); -app.get('/api/lessons', (req, res) => { +app.get('/api/lessons', requireAuth, (req, res) => { try { const { subject, teacher, date_from, date_to, no_topic, no_homework, low_grades, min_grades } = req.query; const cls = buildClassWhere(req.query); @@ -634,7 +758,7 @@ app.get('/api/lessons', (req, res) => { } }); -app.get('/api/dvoiki', (req, res) => { +app.get('/api/dvoiki', requireAuth, (req, res) => { try { const { student, subject, teacher, date_from, date_to } = req.query; const cls = buildClassWhere(req.query); @@ -737,7 +861,7 @@ app.get('/api/dvoiki', (req, res) => { } }); -app.delete('/api/classes/:name', (req, res) => { +app.delete('/api/classes/:name', requireAuth, (req, res) => { try { const cls = db.prepare(`SELECT id FROM classes WHERE name = ?`).get(req.params.name); if (!cls) return res.status(404).json({ error: 'Класс не найден' }); @@ -754,7 +878,7 @@ app.delete('/api/classes/:name', (req, res) => { } }); -app.delete('/api/parallel/:grade', (req, res) => { +app.delete('/api/parallel/:grade', requireAuth, (req, res) => { try { const classes = db.prepare(`SELECT id, name FROM classes WHERE name LIKE ?`).all(req.params.grade + '%'); if (!classes.length) return res.status(404).json({ error: 'Классы не найдены' }); @@ -774,7 +898,7 @@ app.delete('/api/parallel/:grade', (req, res) => { } }); -app.delete('/api/data', (req, res) => { +app.delete('/api/data', requireAuth, (req, res) => { try { db.exec('DELETE FROM lesson_plans; DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;'); res.json({ ok: true, message: 'Все данные удалены' }); diff --git a/src/db.js b/src/db.js index 3041a83..ae90461 100644 --- a/src/db.js +++ b/src/db.js @@ -12,6 +12,18 @@ db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT, + role TEXT DEFAULT 'admin', + auth_type TEXT DEFAULT 'local', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME + ); + CREATE TABLE IF NOT EXISTS classes ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL