/
fedotovskij.v
/
Diplom_another
Обзор
Документация
Войти
/
fedotovskij.v
/
Diplom_another
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
server.js
911 строк
28 KB
root
some commit
22 май 2026, 16:25
22 май 2026, 16:25
d16c143
Код
Авторство
О чём код?
require('dotenv').config(); const crypto = require('crypto'); const express = require('express'); const sqlite3 = require('sqlite3').verbose(); const cors = require('cors'); const path = require('path'); const client = require('prom-client'); const helmet = require('helmet'); const morgan = require('morgan'); const logger = require('./utils/logger'); const app = express(); const PORT = process.env.PORT || 3000; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; const SESSION_SECRET = process.env.SESSION_SECRET || 'dev_secret_change_me'; function toBase64Url(value) { return Buffer.from(value) .toString('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } function fromBase64Url(value) { value = value.replace(/-/g, '+').replace(/_/g, '/'); while (value.length % 4) { value += '='; } return Buffer.from(value, 'base64').toString('utf8'); } function signTokenPayload(payload) { return crypto .createHmac('sha256', SESSION_SECRET) .update(payload) .digest('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } function createAdminToken() { const payload = { role: 'admin', exp: Date.now() + 12 * 60 * 60 * 1000 }; const encodedPayload = toBase64Url(JSON.stringify(payload)); const signature = signTokenPayload(encodedPayload); return `${encodedPayload}.${signature}`; } function verifyAdminToken(token) { if (!token || typeof token !== 'string') { return { valid: false, reason: 'token_missing' }; } const parts = token.split('.'); if (parts.length !== 2) { return { valid: false, reason: 'token_invalid_format' }; } const encodedPayload = parts[0]; const signature = parts[1]; const expectedSignature = signTokenPayload(encodedPayload); if (signature !== expectedSignature) { return { valid: false, reason: 'token_invalid_signature' }; } let payload; try { payload = JSON.parse(fromBase64Url(encodedPayload)); } catch (error) { return { valid: false, reason: 'token_invalid_payload' }; } if (payload.role !== 'admin') { return { valid: false, reason: 'token_invalid_role' }; } if (!payload.exp || Date.now() > payload.exp) { return { valid: false, reason: 'token_expired' }; } return { valid: true, payload }; } function getClientIp(req) { return ( req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || 'unknown' ); } function getBearerToken(req) { const authHeader = req.get('Authorization') || ''; if (!authHeader.startsWith('Bearer ')) { return ''; } return authHeader.replace('Bearer ', '').trim(); } function requireAdmin(req, res, next) { const token = getBearerToken(req); const result = verifyAdminToken(token); if (!result.valid) { logger.warn( `Несанкционированная попытка доступа к API: method=${req.method}, url=${req.originalUrl}, ip=${getClientIp(req)}, reason=${result.reason}` ); return res.status(401).json({ error: 'Требуется авторизация преподавателя' }); } logger.info( `Доступ преподавателя к API: method=${req.method}, url=${req.originalUrl}, ip=${getClientIp(req)}` ); next(); } app.use(morgan('combined', { stream: { write: function(message) { logger.info(message.trim()); } } })); const DB_PATH = process.env.DB_PATH || './school_schedule.db'; app.use( helmet({ contentSecurityPolicy: false }) ); const register = new client.Registry(); client.collectDefaultMetrics({ register }); const scheduleRequestsTotal = new client.Counter({ name: 'schedule_requests_total', help: 'Total number of schedule requests', labelNames: ['class_name'] }); register.registerMetric(scheduleRequestsTotal); app.get('/metrics', async (req, res) => { res.set('Content-Type', register.contentType); res.end(await register.metrics()); }); // Middleware app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname))); // Подключение к базе данных SQLite const db = new sqlite3.Database(DB_PATH, (err) => { if (err) { logger.error(`Ошибка подключения к базе данных: ${err.message}`); } else { logger.info(`Подключено к SQLite: ${DB_PATH}`); initDatabase(); } }); // Инициализация базы данных function formatDateLocal(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function getWeekStartDate(offset = 0) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentDay = today.getDay(); // 0 — воскресенье, 1 — понедельник const mondayOffset = currentDay === 0 ? -6 : 1 - currentDay; today.setDate(today.getDate() + mondayOffset + offset * 7); return formatDateLocal(today); } function normalizeWeekStart(value) { const weekStart = String(value || '').trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(weekStart)) { return weekStart; } return getWeekStartDate(0); } function initDatabase() { db.serialize(() => { // Создание таблицы классов db.run(`CREATE TABLE IF NOT EXISTS classes ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); // Создание таблицы расписания db.run(`CREATE TABLE IF NOT EXISTS schedule ( id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER, week_start TEXT NOT NULL, day_of_week TEXT NOT NULL, lesson_index INTEGER NOT NULL, time TEXT NOT NULL, subject TEXT NOT NULL, teacher TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (class_id) REFERENCES classes (id), UNIQUE(class_id, week_start, day_of_week, lesson_index) )`, (err) => { if (err) { logger.error(`Ошибка создания таблицы schedule: ${err.message}`); } else { addInitialData(); } }); }); } // Добавление начальных данных function addInitialData() { const initialClasses = ['1А', '2Б', '3В', '4Г', '5Д']; const currentWeekStart = getWeekStartDate(0); initialClasses.forEach(className => { db.run( `INSERT OR IGNORE INTO classes (name) VALUES (?)`, [className], (err) => { if (err) { logger.error(`Ошибка добавления класса: ${err.message}`); } } ); }); // Добавление начального расписания для класса 1А const initialSchedule = [ // Понедельник { class: '1А', day: 'monday', index: 0, time: '8:30-9:15', subject: 'Русский язык', teacher: 'Иванова И.И.' }, { class: '1А', day: 'monday', index: 1, time: '9:25-10:10', subject: 'Математика', teacher: 'Петров П.П.' }, { class: '1А', day: 'monday', index: 2, time: '10:30-11:15', subject: 'Физкультура', teacher: 'Сидоров С.С.' }, { class: '1А', day: 'monday', index: 3, time: '11:25-12:10', subject: 'ИЗО', teacher: 'Козлова К.К.' }, // Вторник { class: '1А', day: 'tuesday', index: 0, time: '8:30-9:15', subject: 'Математика', teacher: 'Петров П.П.' }, { class: '1А', day: 'tuesday', index: 1, time: '9:25-10:10', subject: 'Русский язык', teacher: 'Иванова И.И.' }, { class: '1А', day: 'tuesday', index: 2, time: '10:30-11:15', subject: 'Музыка', teacher: 'Музыкальный М.М.' }, { class: '1А', day: 'tuesday', index: 3, time: '11:25-12:10', subject: 'Окружающий мир', teacher: 'Биолог Б.Б.' }, // Среда { class: '1А', day: 'wednesday', index: 0, time: '8:30-9:15', subject: 'Русский язык', teacher: 'Иванова И.И.' }, { class: '1А', day: 'wednesday', index: 1, time: '9:25-10:10', subject: 'Литература', teacher: 'Иванова И.И.' }, { class: '1А', day: 'wednesday', index: 2, time: '10:30-11:15', subject: 'Математика', teacher: 'Петров П.П.' }, { class: '1А', day: 'wednesday', index: 3, time: '11:25-12:10', subject: 'Труд', teacher: 'Мастер М.М.' }, // Четверг { class: '1А', day: 'thursday', index: 0, time: '8:30-9:15', subject: 'Математика', teacher: 'Петров П.П.' }, { class: '1А', day: 'thursday', index: 1, time: '9:25-10:10', subject: 'Русский язык', teacher: 'Иванова И.И.' }, { class: '1А', day: 'thursday', index: 2, time: '10:30-11:15', subject: 'Физкультура', teacher: 'Сидоров С.С.' }, { class: '1А', day: 'thursday', index: 3, time: '11:25-12:10', subject: 'Окружающий мир', teacher: 'Биолог Б.Б.' }, // Пятница { class: '1А', day: 'friday', index: 0, time: '8:30-9:15', subject: 'Литература', teacher: 'Иванова И.И.' }, { class: '1А', day: 'friday', index: 1, time: '9:25-10:10', subject: 'Математика', teacher: 'Петров П.П.' }, { class: '1А', day: 'friday', index: 2, time: '10:30-11:15', subject: 'Русский язык', teacher: 'Иванова И.И.' }, { class: '1А', day: 'friday', index: 3, time: '11:25-12:10', subject: 'ИЗО', teacher: 'Козлова К.К.' } ]; initialSchedule.forEach((lesson) => { db.run( `INSERT OR IGNORE INTO schedule (class_id, week_start, day_of_week, lesson_index, time, subject, teacher) VALUES ((SELECT id FROM classes WHERE name = ?), ?, ?, ?, ?, ?, ?)`, [ lesson.class, currentWeekStart, lesson.day, lesson.index, lesson.time, lesson.subject, lesson.teacher ], (err) => { if (err) { logger.error(`Ошибка добавления начального урока: ${err.message}`); } } ); }); logger.info('Начальные данные добавлены в базу данных'); } // API Routes app.post('/api/auth/login', (req, res) => { const password = req.body && req.body.password ? req.body.password : ''; const ip = getClientIp(req); if (!password) { logger.warn(`Попытка входа без пароля: ip=${ip}`); return res.status(400).json({ error: 'Пароль обязателен' }); } if (password !== ADMIN_PASSWORD) { logger.warn(`Неудачная попытка входа преподавателя: ip=${ip}`); return res.status(401).json({ error: 'Неверный пароль' }); } const token = createAdminToken(); logger.info(`Успешный вход преподавателя: ip=${ip}`); return res.json({ message: 'Авторизация успешна', token }); }); app.post('/api/auth/logout', (req, res) => { logger.info(`Выход преподавателя: ip=${getClientIp(req)}`); return res.json({ message: 'Выход выполнен' }); }); app.get('/api/auth/check', (req, res) => { const token = getBearerToken(req); const result = verifyAdminToken(token); if (!result.valid) { return res.status(401).json({ authenticated: false }); } return res.json({ authenticated: true }); }); // Получить все классы app.get('/api/classes', (req, res) => { logger.info('GET /api/classes'); db.all('SELECT id, name FROM classes ORDER BY name', [], (err, rows) => { if (err) { logger.error(`Ошибка получения классов: ${err.message}`); return res.status(500).json({ error: err.message }); } logger.info(`Классы получены: ${rows.length}`); return res.json(rows || []); }); }); // Получить расписание для класса app.get('/api/schedule/:className', (req, res) => { const className = req.params.className; const weekStart = normalizeWeekStart(req.query.weekStart); logger.info(`Запрошено расписание класса: ${className}, week=${weekStart}`); scheduleRequestsTotal.inc({ class_name: className || 'unknown' }); db.all(`SELECT s.* FROM schedule s JOIN classes c ON s.class_id = c.id WHERE c.name = ? AND s.week_start = ? ORDER BY s.day_of_week, s.lesson_index`, [className, weekStart], (err, rows) => { if (err) { return res.status(500).json({ error: err.message }); } const schedule = { monday: [], tuesday: [], wednesday: [], thursday: [], friday: [], saturday: [] }; rows.forEach(row => { if (schedule[row.day_of_week]) { schedule[row.day_of_week][row.lesson_index] = { time: row.time, subject: row.subject, teacher: row.teacher }; } }); return res.json(schedule); } ); }); // Добавить или обновить урок app.put('/api/schedule/:className', requireAdmin, (req, res) => { const { className } = req.params; const { day, index, time, subject, teacher } = req.body; const weekStart = normalizeWeekStart(req.body.weekStart); if (!day || index === undefined || !time || !subject || !teacher) { return res.status(400).json({ error: 'Все поля обязательны' }); } db.get('SELECT id FROM classes WHERE name = ?', [className], (err, classRow) => { if (err) { return res.status(500).json({ error: err.message }); } if (!classRow) { return res.status(404).json({ error: 'Класс не найден' }); } const classId = classRow.id; db.run(`INSERT OR REPLACE INTO schedule (class_id, week_start, day_of_week, lesson_index, time, subject, teacher, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, [classId, weekStart, day, index, time, subject, teacher], function(err) { if (err) { return res.status(500).json({ error: err.message }); } logger.info( `Сохранён урок: class=${className}, week=${weekStart}, day=${day}, index=${index}, subject=${subject}` ); return res.json({ message: 'Урок успешно сохранен', id: this.lastID, weekStart }); } ); }); }); // Удалить урок app.delete('/api/schedule/:className/:day/:index', requireAdmin, (req, res) => { const className = req.params.className; const day = req.params.day; const index = parseInt(req.params.index, 10); const weekStart = normalizeWeekStart(req.query.weekStart); if (!day || Number.isNaN(index)) { return res.status(400).json({ error: 'Некорректные параметры удаления урока' }); } db.get('SELECT id FROM classes WHERE name = ?', [className], (err, classRow) => { if (err) { logger.error(`Ошибка поиска класса при удалении урока: ${err.message}`); return res.status(500).json({ error: err.message }); } if (!classRow) { return res.status(404).json({ error: 'Класс не найден' }); } db.run( `DELETE FROM schedule WHERE class_id = ? AND week_start = ? AND day_of_week = ? AND lesson_index = ?`, [classRow.id, weekStart, day, index], function(err) { if (err) { logger.error(`Ошибка удаления урока: ${err.message}`); return res.status(500).json({ error: err.message }); } logger.warn( `Удалён урок: class=${className}, week=${weekStart}, day=${day}, index=${index}, changes=${this.changes}` ); return res.json({ message: 'Урок успешно удалён', changes: this.changes }); } ); }); }); function normalizeClassName(value) { return String(value || '') .trim() .replace(/\s+/g, '') .toLocaleUpperCase('ru-RU'); } function isValidRussianClassName(name) { return /^\d{1,2}[А-ЯЁ]{1,2}$/u.test(name); } // Добавить новый класс // Добавить новый класс app.post('/api/classes', requireAdmin, (req, res) => { const rawName = req.body && req.body.name ? req.body.name.trim() : ''; const normalizedName = normalizeClassName(rawName); logger.info(`Создание класса: "${rawName}" => "${normalizedName}"`); if (!rawName) { return res.status(400).json({ error: 'Название класса обязательно' }); } if (!isValidRussianClassName(normalizedName)) { return res.status(400).json({ error: 'Некорректное название класса. Используйте только русские буквы. Пример: 1А, 5Б, 10А' }); } db.all('SELECT id, name FROM classes', [], (err, rows) => { if (err) { logger.error(`Ошибка проверки классов: ${err.message}`); return res.status(500).json({ error: err.message }); } const duplicate = rows.find(row => { return normalizeClassName(row.name) === normalizedName; }); if (duplicate) { logger.warn(`Попытка создать дубликат класса: ${normalizedName}`); return res.status(409).json({ error: `Класс "${normalizeClassName(duplicate.name)}" уже существует` }); } db.run( 'INSERT INTO classes (name) VALUES (?)', [normalizedName], function(err) { if (err) { logger.error(`Ошибка создания класса: ${err.message}`); if (err.code === 'SQLITE_CONSTRAINT') { return res.status(409).json({ error: 'Класс с таким названием уже существует' }); } return res.status(500).json({ error: err.message }); } logger.info(`Создан класс: ${normalizedName}`); return res.status(201).json({ message: 'Класс успешно добавлен', id: this.lastID, name: normalizedName }); } ); }); }); // Удалить класс и его расписание app.delete('/api/classes/:className', requireAdmin, (req, res) => { const { className } = req.params; db.get('SELECT id FROM classes WHERE name = ?', [className], (err, classRow) => { if (err) { return res.status(500).json({ error: err.message }); } if (!classRow) { return res.status(404).json({ error: 'Класс не найден' }); } const classId = classRow.id; db.serialize(() => { // Удаление расписания класса db.run('DELETE FROM schedule WHERE class_id = ?', [classId]); // Удаление класса db.run('DELETE FROM classes WHERE id = ?', [classId], function(err) { if (err) { return res.status(500).json({ error: err.message }); } logger.warn(`Удалён класс: ${className}`); res.json({ message: 'Класс и его расписание успешно удалены' }); }); }); }); }); app.post('/max/webhook', express.json(), async (req, res) => { const expectedSecret = process.env.MAX_WEBHOOK_SECRET; const incomingSecret = req.get('X-Max-Bot-Api-Secret'); if (expectedSecret && incomingSecret !== expectedSecret) { logger.warn('Отклонён MAX webhook: неверный secret'); return res.sendStatus(403); } logger.info('Получен webhook от MAX'); res.sendStatus(200); try { await handleMaxUpdate(req.body); } catch (error) { logger.error(`MAX webhook error: ${error.message}`); } }); function extractTextFromMaxUpdate(update) { const message = update && update.message ? update.message : {}; const messageBody = message && message.body ? message.body : {}; const rootBody = update && update.body ? update.body : {}; return String( messageBody.text || message.text || rootBody.text || '' ).trim(); } function extractChatIdFromMaxUpdate(update) { const message = update && update.message ? update.message : {}; const recipient = message && message.recipient ? message.recipient : {}; const rootRecipient = update && update.recipient ? update.recipient : {}; return ( recipient.chat_id || message.chat_id || update.chat_id || rootRecipient.chat_id || null ); } function parseClassNameFromText(text) { const cleaned = String(text || '') .trim() .replace(/^расписание\s+/i, '') .replace(/\s+/g, '') .toLocaleUpperCase('ru-RU'); if (/^\d{1,2}[А-ЯЁ]{1,2}$/u.test(cleaned)) { return cleaned; } return null; } function getScheduleForClass(className, weekStart) { return new Promise((resolve, reject) => { db.all( `SELECT s.* FROM schedule s JOIN classes c ON s.class_id = c.id WHERE c.name = ? AND s.week_start = ? ORDER BY s.lesson_index, s.day_of_week`, [className, weekStart], (err, rows) => { if (err) { reject(err); return; } resolve(rows || []); } ); }); } function getDayRuName(day) { const names = { monday: 'Понедельник', tuesday: 'Вторник', wednesday: 'Среда', thursday: 'Четверг', friday: 'Пятница', saturday: 'Суббота' }; return names[day] || day; } function formatScheduleForMax(className, rows) { if (!rows.length) { return `Расписание для класса ${className} пока не заполнено.`; } const grouped = {}; rows.forEach((lesson) => { if (!grouped[lesson.day_of_week]) { grouped[lesson.day_of_week] = []; } grouped[lesson.day_of_week].push(lesson); }); const dayOrder = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; let text = `📚 Расписание класса ${className}\n\n`; dayOrder.forEach((day) => { const lessons = grouped[day]; if (!lessons || !lessons.length) { return; } text += `*${getDayRuName(day)}*\n`; lessons .sort((a, b) => a.lesson_index - b.lesson_index) .forEach((lesson) => { text += `${lesson.lesson_index + 1}. ${lesson.time} — ${lesson.subject}`; if (lesson.teacher) { text += `, ${lesson.teacher}`; } text += '\n'; }); text += '\n'; }); return text.trim(); } async function sendMaxMessage(chatId, text) { const token = process.env.MAX_BOT_TOKEN; const apiUrl = process.env.MAX_API_URL || 'https://platform-api.max.ru'; if (!token) { logger.error('MAX_BOT_TOKEN is missing'); return; } if (!chatId) { logger.error('MAX chatId is missing'); return; } const response = await fetch(`${apiUrl}/messages?chat_id=${encodeURIComponent(chatId)}`, { method: 'POST', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ text, format: 'markdown' }) }); if (!response.ok) { const body = await response.text(); logger.error(`MAX send message failed: ${response.status} ${body}`); } } async function handleMaxUpdate(update) { const chatId = extractChatIdFromMaxUpdate(update); const text = extractTextFromMaxUpdate(update); if (!chatId) { logger.warn(`MAX webhook without chatId: ${JSON.stringify(update)}`); return; } if (!text) { await sendMaxMessage(chatId, 'Напишите класс, например: 1А, 5Б или 10А.'); return; } const className = parseClassNameFromText(text); if (!className) { await sendMaxMessage( chatId, 'Не понял класс. Напишите, например: 1А или "расписание 1А". Используйте русские буквы.' ); return; } const weekStart = getWeekStartDate(0); const rows = await getScheduleForClass(className, weekStart); const answer = formatScheduleForMax(className, rows); await sendMaxMessage(chatId, answer); } // Обслуживание статических файлов app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'index.html')); }); // Запуск сервера app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', service: 'school-schedule', timestamp: new Date().toISOString() }); }); let server = null; app.use(function(err, req, res, next) { logger.error(`Unhandled error: ${err.message}`); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); }); if (require.main === module) { server = app.listen(PORT, () => { logger.info(`Сервер запущен на порту ${PORT}`); logger.info(`Откройте http://localhost:${PORT} в браузере`); }); process.on('SIGINT', () => { logger.info('Закрытие базы данных...'); db.close((err) => { if (err) { logger.error(err.message); } logger.info('База данных закрыта'); if (server) { server.close(() => { process.exit(0); }); } else { process.exit(0); } }); }); } module.exports = { app, db, normalizeClassName, isValidRussianClassName };