From 9df04745dfd19bf14d10aed8477f083ef8e5d7d9 Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Fri, 28 Nov 2025 00:29:46 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3:=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=B0=D1=83=D1=82=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20UI=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалены устаревшие файлы (mock.js, old-schedule.txt, loading-overlay.tsx) - Переработана система аутентификации (login, logout, check-auth) - Добавлен компонент toast для уведомлений - Улучшен контекст загрузки (loading-context) - Обновлен парсер расписания (schedule.ts) - Улучшена админ-панель - Обновлена документация (README.md) - Старые файлы перемещены в директорию old/ --- .example.env | 15 --- README.md | 101 ++++++++++++++++---- image.jpg | 1 - old/README.md | 28 ++++++ {src/app/agregator => old}/mock.js | 0 {src/app/agregator => old}/old-schedule.txt | 0 src/app/agregator/schedule.ts | 84 +++++++++------- src/pages/[group].tsx | 5 +- src/pages/_app.tsx | 3 +- src/pages/admin.tsx | 50 ++++++++-- src/pages/api/admin/check-auth.ts | 1 + src/pages/api/admin/login.ts | 91 ++++++++++++++++++ src/pages/api/admin/logout.ts | 1 + src/shared/context/loading-context.tsx | 81 ++++++++++++++++ src/shared/ui/loading-overlay.tsx | 32 ------- src/shared/ui/toast.tsx | 86 +++++++++++++++++ src/shared/utils/auth.ts | 49 +++++++++- 17 files changed, 511 insertions(+), 117 deletions(-) delete mode 100644 .example.env delete mode 100644 image.jpg create mode 100644 old/README.md rename {src/app/agregator => old}/mock.js (100%) rename {src/app/agregator => old}/old-schedule.txt (100%) delete mode 100644 src/shared/ui/loading-overlay.tsx create mode 100644 src/shared/ui/toast.tsx diff --git a/.example.env b/.example.env deleted file mode 100644 index d83e527..0000000 --- a/.example.env +++ /dev/null @@ -1,15 +0,0 @@ -# Development/Example environment variables for KSPGUTI Schedule - -# Site URL - used for canonical links and sitemap (optional, defaults to https://schedule.itlxrd.space) -NEXT_PUBLIC_SITE_URL=https://schedule.itlxrd.space - -# Proxy URL for schedule parsing (optional, defaults to https://lk.ks.psuti.ru) -PROXY_URL=https://lk.ks.psuti.ru - -# Telegram Bot API token for parsing failure notifications (optional) -# Get token from @BotFather on Telegram -PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN= - -# Telegram Chat ID for receiving notifications (optional) -# You can get your chat ID by messaging @userinfobot on Telegram -PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID= \ No newline at end of file diff --git a/README.md b/README.md index 7250757..d784fd5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Schedule for колледж связи пгути +# Schedule for College of Communication Volga State Goverment University of ICT (КС ПГУТИ) Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support. -[![Screenshot](https://github.com/VityaSchel/kspguti-schedule/assets/59040542/07cc1f67-ccb0-4522-a59d-16387fa11987#gh-dark-mode-only)](https://kspsuti.ru#gh-dark-mode-only) +[![Screenshot](https://github.com/VityaSchel/kspguti-schedule/assets/59040542/07cc1f67-ccb0-4522-a59d-16387fa11987#gh-dark-mode-only)](https://schedule.itlxrd.space/) -[![Screenshot](https://github.com/VityaSchel/kspguti-schedule/assets/59040542/7bd26798-5ec1-4033-a9ca-84ffa0c44f52#gh-light-mode-only)](https://kspsuti.ru#gh-light-mode-only) +[![Screenshot](https://github.com/VityaSchel/kspguti-schedule/assets/59040542/7bd26798-5ec1-4033-a9ca-84ffa0c44f52#gh-light-mode-only)](https://schedule.itlxrd.space/) [Visit website](https://kspsuti.ru) @@ -14,11 +14,12 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support. - Tailwind CSS - @shadcn/ui components (built with Radix UI) - JSDOM for parsing scraped pages, rehydration strategy for cache -- TypeScript 5.6.0 with types for each package +- TypeScript 5.9.3 with types for each package - Telegram Bot API (via [node-telegram-bot-api]) for parsing failure notifications - Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c) - Accessibility & tab navigation support - Dark theme with automatic switching based on system settings +- Admin panel for managing groups and settings ## Known issues @@ -26,6 +27,46 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support. Workaround: Locate to next week, then enter previous twice. +## Project structure + +``` +kspguti-schedule/ +├── src/ # Source code +│ ├── app/ # App router (Next.js 13+) +│ │ ├── agregator/ # Schedule fetching logic +│ │ ├── parser/ # HTML parsing for schedule +│ │ └── utils/ # App-level utilities +│ ├── pages/ # Pages router (Next.js) +│ │ ├── api/ # API routes +│ │ │ └── admin/ # Admin panel API endpoints +│ │ ├── [group].tsx # Dynamic group schedule page +│ │ ├── admin.tsx # Admin panel page +│ │ └── index.tsx # Home page +│ ├── entities/ # Entities +│ ├── features/ # Feature modules +│ ├── shared/ # Shared code +│ │ ├── constants/ # App constants +│ │ ├── context/ # React contexts +│ │ ├── data/ # Data loaders and JSON files +│ │ ├── model/ # Data models +│ │ ├── providers/ # React providers +│ │ ├── ui/ # Shared UI components +│ │ └── utils/ # Utility functions +│ ├── shadcn/ # shadcn/ui components +│ └── widgets/ # Complex UI widgets +├── public/ # Static assets +│ └── teachers/ # Teacher photos +├── old/ # Deprecated files (see old/README.md) +├── scripts/ # Deployment scripts +├── systemd/ # Systemd service file +├── components.json # shadcn/ui config +├── docker-compose.yml # Docker Compose config +├── Dockerfile # Docker image definition +├── next.config.js # Next.js configuration +├── tailwind.config.js # Tailwind CSS config +└── tsconfig.json # TypeScript config +``` + ## Development ### Prerequisites @@ -41,6 +82,22 @@ npm install # Run development server npm run dev +``` + +### Admin Panel + +The application includes an admin panel for managing groups and application settings. Access it at `/admin` route. + +**Features:** +- Manage groups (add, edit, delete) +- Configure application settings (e.g., week navigation) +- Password-protected access with session management + +**Environment variables for admin panel:** +- `ADMIN_PASSWORD` - Password for admin panel access (required) +- `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, defaults to 'change-me-in-production') + +⚠️ **Important:** Always set a strong `ADMIN_PASSWORD` and `ADMIN_SESSION_SECRET` in production! ### Docker deployment @@ -68,9 +125,12 @@ docker-compose down ``` **Environment variables:** Edit `docker-compose.yml` to add your environment variables: -- `PROXY_URL` - URL for schedule parsing -- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token -- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID +- `PROXY_URL` - URL for schedule parsing (optional) +- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token (optional) +- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID (optional) +- `ADMIN_PASSWORD` - Password for admin panel access (required for admin features) +- `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, but recommended in production) +- `NEXT_PUBLIC_SITE_URL` - Site URL for canonical links and sitemap (optional) ### Production deployment @@ -165,16 +225,6 @@ sudo ./scripts/manage.sh enable sudo ./scripts/manage.sh disable ``` -Or use systemctl directly: - -```bash -sudo systemctl start kspguti-schedule -sudo systemctl stop kspguti-schedule -sudo systemctl restart kspguti-schedule -sudo systemctl status kspguti-schedule -sudo journalctl -u kspguti-schedule -f -``` - **Service configuration:** - Installation directory: `/opt/kspguti-schedule` @@ -184,10 +234,19 @@ sudo journalctl -u kspguti-schedule -f **Environment variables:** -See `.env.production.example` or `.example.env` for available options. The application uses `.env` file in production: -- `PROXY_URL` - URL for schedule parsing (optional) -- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token (optional) -- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID (optional) +See `.env.production.example` for available options. The application uses `.env` file in production: + +**Schedule parsing:** +- `PROXY_URL` - URL for schedule parsing (optional, defaults to https://lk.ks.psuti.ru) +- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token for parsing failure notifications (optional) +- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID for receiving notifications (optional) + +**Admin panel:** +- `ADMIN_PASSWORD` - Password for admin panel access (required for admin features) +- `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, defaults to 'change-me-in-production', but should be changed in production) + +**Site configuration:** +- `NEXT_PUBLIC_SITE_URL` - Site URL for canonical links and sitemap (optional, defaults to https://schedule.itlxrd.space) #### Other platforms diff --git a/image.jpg b/image.jpg deleted file mode 100644 index c0e5e88..0000000 --- a/image.jpg +++ /dev/null @@ -1 +0,0 @@ -uZ \ No newline at end of file diff --git a/old/README.md b/old/README.md new file mode 100644 index 0000000..a3579bf --- /dev/null +++ b/old/README.md @@ -0,0 +1,28 @@ +# Старые файлы и механики + +Эта папка содержит устаревшие файлы и механики, которые больше не используются в проекте, но сохранены для справки или возможного восстановления. + +## Содержимое + +### `old-schedule.txt` +Старая реализация функций `getSchedule` и `parseSchedule` из модуля расписания. Содержит закомментированный код с механизмом кеширования запросов, который был заменен новой реализацией в `src/app/agregator/schedule.ts`. + +**Особенности старой реализации:** +- Механизм дедупликации запросов через `fetchingGroups` и `callbacks` +- Более простая структура без поддержки навигации по неделям +- Отсутствие таймаутов и улучшенной обработки ошибок + +### `mock.js` +Мок-файл с HTML контентом для тестирования парсера расписания. Содержит пример HTML-страницы с расписанием занятий для группы ПС-7 за период с 25.09.2023 по 01.10.2023. + +**Использование:** +- Ранее использовался для отладки парсера без необходимости делать реальные HTTP-запросы +- Импорт был закомментирован в `old-schedule.txt` +- Больше не используется в текущей реализации + +## Примечания + +- Эти файлы не включены в сборку проекта +- Они сохранены только для исторической справки +- При необходимости можно удалить эту папку без влияния на работу приложения + diff --git a/src/app/agregator/mock.js b/old/mock.js similarity index 100% rename from src/app/agregator/mock.js rename to old/mock.js diff --git a/src/app/agregator/old-schedule.txt b/old/old-schedule.txt similarity index 100% rename from src/app/agregator/old-schedule.txt rename to old/old-schedule.txt diff --git a/src/app/agregator/schedule.ts b/src/app/agregator/schedule.ts index 6be8a00..5224013 100644 --- a/src/app/agregator/schedule.ts +++ b/src/app/agregator/schedule.ts @@ -2,7 +2,6 @@ import { Day } from '@/shared/model/day' import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule' import contentTypeParser from 'content-type' import { JSDOM } from 'jsdom' -// import { content as mockContent } from './mock' import { reportParserError } from '@/app/logger' import { PROXY_URL } from '@/shared/constants/urls' @@ -12,41 +11,62 @@ export type ScheduleResult = { availableWeeks?: WeekInfo[] } -// ПС-7: 146 export async function getSchedule(groupID: number, groupName: string, wk?: number, parseWeekNavigation: boolean = true): Promise { + // Валидация параметров + if (!Number.isInteger(groupID) || groupID <= 0) { + throw new Error('Invalid groupID: must be a positive integer') + } + + if (wk !== undefined && (!Number.isInteger(wk) || wk <= 0 || !isFinite(wk))) { + throw new Error('Invalid wk parameter: must be a positive integer') + } + const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}` - const page = await fetch(url) - // const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } } - const content = await page.text() - const contentType = page.headers.get('content-type') - if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') { - let dom: JSDOM | null = null - try { - dom = new JSDOM(content, { url }) - const root = dom.window.document - const result = parsePage(root, groupName, url, parseWeekNavigation) - const scheduleResult = { - days: result.days, - currentWk: result.currentWk || wk, - availableWeeks: result.availableWeeks - } - // Явно очищаем JSDOM для освобождения памяти - dom.window.close() - dom = null - return scheduleResult - } catch(e) { - // Очищаем JSDOM даже в случае ошибки - if (dom) { + + // Добавляем таймаут 30 секунд для fetch запроса + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 секунд + + try { + const page = await fetch(url, { signal: controller.signal }) + clearTimeout(timeoutId) + const content = await page.text() + const contentType = page.headers.get('content-type') + if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') { + let dom: JSDOM | null = null + try { + dom = new JSDOM(content, { url }) + const root = dom.window.document + const result = parsePage(root, groupName, url, parseWeekNavigation) + const scheduleResult = { + days: result.days, + currentWk: result.currentWk || wk, + availableWeeks: result.availableWeeks + } + // Явно очищаем JSDOM для освобождения памяти dom.window.close() + dom = null + return scheduleResult + } catch(e) { + // Очищаем JSDOM даже в случае ошибки + if (dom) { + dom.window.close() + } + console.error(`Error while parsing ${PROXY_URL}`) + reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName) + throw e } - console.error(`Error while parsing ${PROXY_URL}`) - reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName) - throw e + } else { + // Логируем только метаданные, без содержимого ответа + console.error(`Failed to fetch schedule: status=${page.status}, contentType=${contentType}, contentLength=${content.length}`) + reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName) + throw new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`) } - } else { - console.error(page.status, contentType) - console.error(content.length > 500 ? content.slice(0, 500 - 3) + '...' : content) - reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName) - throw new Error(`Error while fetching ${PROXY_URL}`) + } catch (error) { + clearTimeout(timeoutId) + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Request timeout while fetching ${PROXY_URL}`) + } + throw error } } \ No newline at end of file diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index ab5ddc9..bdb7e9d 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -114,7 +114,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr const settings = loadSettings() const group = context.params?.group const wkParam = context.query.wk - const wk = wkParam ? Number(wkParam) : undefined + // Валидация wk параметра: проверка на валидное число (не NaN, не Infinity) + const wk = wkParam && !isNaN(Number(wkParam)) && isFinite(Number(wkParam)) && Number.isInteger(Number(wkParam)) && Number(wkParam) > 0 + ? Number(wkParam) + : undefined if (group && Object.hasOwn(groups, group) && group in groups) { let scheduleResult: ScheduleResult diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 19dbb64..f222d8d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,8 +1,7 @@ import '@/shared/styles/globals.css' import type { AppProps } from 'next/app' import { ThemeProvider } from '@/shared/providers/theme-provider' -import { LoadingContextProvider, LoadingContext } from '@/shared/context/loading-context' -import { LoadingOverlay } from '@/shared/ui/loading-overlay' +import { LoadingContextProvider, LoadingContext, LoadingOverlay } from '@/shared/context/loading-context' import Head from 'next/head' import React from 'react' diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx index 4d5d3e6..6fe02db 100644 --- a/src/pages/admin.tsx +++ b/src/pages/admin.tsx @@ -15,6 +15,7 @@ import { import { loadGroups, GroupsData } from '@/shared/data/groups-loader' import { loadSettings, AppSettings } from '@/shared/data/settings-loader' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select' +import { ToastContainer, Toast } from '@/shared/ui/toast' import Head from 'next/head' type AdminPageProps = { @@ -34,6 +35,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett const [showEditDialog, setShowEditDialog] = React.useState(false) const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) const [groupToDelete, setGroupToDelete] = React.useState(null) + const [toasts, setToasts] = React.useState([]) + + const showToast = (message: string, type: 'success' | 'error' = 'success') => { + const id = Date.now().toString() + setToasts((prev) => [...prev, { id, message, type }]) + } + + const removeToast = (id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)) + } // Форма добавления/редактирования const [formData, setFormData] = React.useState({ @@ -131,15 +142,20 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett if (res.ok && data.success) { // Обновляем состояние из ответа сервера (для синхронизации) setSettings(data.settings) + showToast('Настройки успешно обновлены', 'success') } else { // Откатываем изменения при ошибке setSettings(previousSettings) - setError(data.error || 'Ошибка при обновлении настроек') + const errorMessage = data.error || 'Ошибка при обновлении настроек' + setError(errorMessage) + showToast(errorMessage, 'error') } } catch (err) { // Откатываем изменения при ошибке setSettings(previousSettings) - setError('Ошибка соединения с сервером') + const errorMessage = 'Ошибка соединения с сервером' + setError(errorMessage) + showToast(errorMessage, 'error') } } @@ -166,11 +182,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett setGroups(data.groups) setShowAddDialog(false) setFormData({ id: '', parseId: '', name: '', course: '1' }) + showToast('Группа успешно добавлена', 'success') } else { - setError(data.error || 'Ошибка при добавлении группы') + const errorMessage = data.error || 'Ошибка при добавлении группы' + setError(errorMessage) + showToast(errorMessage, 'error') } } catch (err) { - setError('Ошибка соединения с сервером') + const errorMessage = 'Ошибка соединения с сервером' + setError(errorMessage) + showToast(errorMessage, 'error') } finally { setLoading(false) } @@ -201,11 +222,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett setShowEditDialog(false) setEditingGroup(null) setFormData({ id: '', parseId: '', name: '', course: '1' }) + showToast('Группа успешно обновлена', 'success') } else { - setError(data.error || 'Ошибка при редактировании группы') + const errorMessage = data.error || 'Ошибка при редактировании группы' + setError(errorMessage) + showToast(errorMessage, 'error') } } catch (err) { - setError('Ошибка соединения с сервером') + const errorMessage = 'Ошибка соединения с сервером' + setError(errorMessage) + showToast(errorMessage, 'error') } finally { setLoading(false) } @@ -228,11 +254,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett setGroups(data.groups) setShowDeleteDialog(false) setGroupToDelete(null) + showToast('Группа успешно удалена', 'success') } else { - setError(data.error || 'Ошибка при удалении группы') + const errorMessage = data.error || 'Ошибка при удалении группы' + setError(errorMessage) + showToast(errorMessage, 'error') } } catch (err) { - setError('Ошибка соединения с сервером') + const errorMessage = 'Ошибка соединения с сервером' + setError(errorMessage) + showToast(errorMessage, 'error') } finally { setLoading(false) } @@ -586,6 +617,9 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett + + {/* Toast уведомления */} + ) } diff --git a/src/pages/api/admin/check-auth.ts b/src/pages/api/admin/check-auth.ts index 56718a3..705208a 100644 --- a/src/pages/api/admin/check-auth.ts +++ b/src/pages/api/admin/check-auth.ts @@ -21,3 +21,4 @@ export default function handler( + diff --git a/src/pages/api/admin/login.ts b/src/pages/api/admin/login.ts index 58cf1a6..b9d42bb 100644 --- a/src/pages/api/admin/login.ts +++ b/src/pages/api/admin/login.ts @@ -6,6 +6,79 @@ type ResponseData = { error?: string } +// Rate limiting: 5 попыток в 15 минут +const MAX_ATTEMPTS = 5 +const WINDOW_MS = 15 * 60 * 1000 // 15 минут + +interface RateLimitEntry { + attempts: number + resetTime: number +} + +const rateLimitMap = new Map() + +function getClientIP(req: NextApiRequest): string { + // Получаем IP адрес клиента + const forwarded = req.headers['x-forwarded-for'] + const realIP = req.headers['x-real-ip'] + const remoteAddress = req.socket.remoteAddress + + if (typeof forwarded === 'string') { + return forwarded.split(',')[0].trim() + } + if (typeof realIP === 'string') { + return realIP + } + if (remoteAddress) { + return remoteAddress + } + return 'unknown' +} + +function checkRateLimit(ip: string): boolean { + const now = Date.now() + const entry = rateLimitMap.get(ip) + + // Очищаем старые записи + if (entry && now > entry.resetTime) { + rateLimitMap.delete(ip) + } + + const currentEntry = rateLimitMap.get(ip) + + if (!currentEntry) { + // Первая попытка + rateLimitMap.set(ip, { + attempts: 1, + resetTime: now + WINDOW_MS + }) + return true + } + + if (currentEntry.attempts >= MAX_ATTEMPTS) { + return false + } + + // Увеличиваем счетчик попыток + currentEntry.attempts++ + return true +} + +function recordFailedAttempt(ip: string): void { + const entry = rateLimitMap.get(ip) + if (entry) { + // Попытка уже засчитана в checkRateLimit + return + } + + // Если записи нет, создаем новую + const now = Date.now() + rateLimitMap.set(ip, { + attempts: 1, + resetTime: now + WINDOW_MS + }) +} + export default function handler( req: NextApiRequest, res: NextApiResponse @@ -15,6 +88,19 @@ export default function handler( return } + const clientIP = getClientIP(req) + + // Проверяем rate limit + if (!checkRateLimit(clientIP)) { + const entry = rateLimitMap.get(clientIP) + const retryAfter = entry ? Math.ceil((entry.resetTime - Date.now()) / 1000) : WINDOW_MS / 1000 + res.status(429).json({ + error: 'Too many login attempts. Please try again later.', + retryAfter + }) + return + } + const { password } = req.body if (!password || typeof password !== 'string') { @@ -23,9 +109,13 @@ export default function handler( } if (verifyPassword(password)) { + // Успешный вход - сбрасываем rate limit + rateLimitMap.delete(clientIP) setSessionCookie(res) res.status(200).json({ success: true }) } else { + // Неудачная попытка - записываем + recordFailedAttempt(clientIP) res.status(401).json({ error: 'Invalid password' }) } } @@ -33,3 +123,4 @@ export default function handler( + diff --git a/src/pages/api/admin/logout.ts b/src/pages/api/admin/logout.ts index 28db898..12c9ab3 100644 --- a/src/pages/api/admin/logout.ts +++ b/src/pages/api/admin/logout.ts @@ -21,3 +21,4 @@ export default function handler( + diff --git a/src/shared/context/loading-context.tsx b/src/shared/context/loading-context.tsx index 217a3be..400415a 100644 --- a/src/shared/context/loading-context.tsx +++ b/src/shared/context/loading-context.tsx @@ -1,5 +1,7 @@ import React from 'react' import { useRouter } from 'next/router' +import { Spinner } from '@/shared/ui/spinner' +import { cn } from '@/shared/utils' interface LoadingContextType { isLoading: boolean @@ -44,3 +46,82 @@ export function LoadingContextProvider({ children }: React.PropsWithChildren) { ) } +const loadingMessages = [ + 'Вайбкодим…', + 'Отменяем пары…', + 'Объезжаем пробки…', + 'Ищем замены…', + 'Ждем выходных…', +] + +interface LoadingOverlayProps { + isLoading: boolean +} + +export function LoadingOverlay({ isLoading }: LoadingOverlayProps) { + const [currentMessage, setCurrentMessage] = React.useState('') + const [messageOpacity, setMessageOpacity] = React.useState(0) + + React.useEffect(() => { + if (!isLoading) { + setCurrentMessage('') + setMessageOpacity(0) + return + } + + // Выбираем случайное сообщение при старте загрузки + const getRandomMessage = () => { + const randomIndex = Math.floor(Math.random() * loadingMessages.length) + return loadingMessages[randomIndex] + } + + // Устанавливаем первое сообщение + setCurrentMessage(getRandomMessage()) + setMessageOpacity(1) + + // Меняем сообщение каждые 2 секунды + const interval = setInterval(() => { + // Fade out + setMessageOpacity(0) + + // После fade out меняем сообщение и fade in + setTimeout(() => { + setCurrentMessage(getRandomMessage()) + setMessageOpacity(1) + }, 300) // Длительность fade анимации + }, 2000) + + return () => { + clearInterval(interval) + } + }, [isLoading]) + + return ( +
+ {isLoading && ( +
+
+ +
+
+ {currentMessage} +
+
+ )} +
+ ) +} + diff --git a/src/shared/ui/loading-overlay.tsx b/src/shared/ui/loading-overlay.tsx deleted file mode 100644 index 97fc510..0000000 --- a/src/shared/ui/loading-overlay.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { Spinner } from './spinner' -import { cn } from '@/shared/utils' - -interface LoadingOverlayProps { - isLoading: boolean -} - -export function LoadingOverlay({ isLoading }: LoadingOverlayProps) { - return ( -
- {isLoading && ( -
-
- -
-
- )} -
- ) -} - diff --git a/src/shared/ui/toast.tsx b/src/shared/ui/toast.tsx new file mode 100644 index 0000000..e10befc --- /dev/null +++ b/src/shared/ui/toast.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { cn } from '@/shared/utils' +import { CheckCircle2, XCircle, X } from 'lucide-react' + +export type ToastType = 'success' | 'error' + +export interface Toast { + id: string + message: string + type: ToastType +} + +interface ToastProps { + toast: Toast + onClose: () => void +} + +export function ToastComponent({ toast, onClose }: ToastProps) { + const [isClosing, setIsClosing] = React.useState(false) + + React.useEffect(() => { + const timer = setTimeout(() => { + handleClose() + }, 3000) // Автоматически закрывается через 3 секунды + + return () => clearTimeout(timer) + }, []) + + const handleClose = () => { + setIsClosing(true) + // Ждем завершения анимации перед удалением из DOM + setTimeout(() => { + onClose() + }, 300) // Длительность анимации исчезновения + } + + return ( +
+ {toast.type === 'success' ? ( + + ) : ( + + )} +

{toast.message}

+ +
+ ) +} + +interface ToastContainerProps { + toasts: Toast[] + onClose: (id: string) => void +} + +export function ToastContainer({ toasts, onClose }: ToastContainerProps) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((toast) => ( + onClose(toast.id)} /> + ))} +
+ ) +} + diff --git a/src/shared/utils/auth.ts b/src/shared/utils/auth.ts index d60f24a..7bc79d7 100644 --- a/src/shared/utils/auth.ts +++ b/src/shared/utils/auth.ts @@ -2,11 +2,28 @@ import { NextApiRequest, NextApiResponse } from 'next' import crypto from 'crypto' const SESSION_COOKIE_NAME = 'admin_session' -const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'change-me-in-production' +const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET const SESSION_DURATION = 1000 * 60 * 60 * 24 // 24 часа +// Получаем секрет сессии с учетом окружения +function getSessionSecret(): string { + if (SESSION_SECRET) { + return SESSION_SECRET + } + + // В production требуем явную установку + if (process.env.NODE_ENV === 'production') { + throw new Error('ADMIN_SESSION_SECRET must be set in production environment') + } + + // В development используем дефолтный секрет с предупреждением + console.warn('ADMIN_SESSION_SECRET is not set. Using default secret for development. This is not secure for production!') + return 'change-me-in-production' +} + /** * Проверяет пароль администратора + * Использует timing-safe сравнение для защиты от timing attacks */ export function verifyPassword(password: string): boolean { const adminPassword = process.env.ADMIN_PASSWORD @@ -14,17 +31,31 @@ export function verifyPassword(password: string): boolean { console.error('ADMIN_PASSWORD is not set') return false } - return password === adminPassword + + // Используем timing-safe сравнение для защиты от timing attacks + if (password.length !== adminPassword.length) { + return false + } + + try { + const passwordBuffer = Buffer.from(password, 'utf8') + const adminPasswordBuffer = Buffer.from(adminPassword, 'utf8') + return crypto.timingSafeEqual(passwordBuffer, adminPasswordBuffer) + } catch { + return false + } } /** * Создает сессионный токен */ function createSessionToken(): string { + const secret = getSessionSecret() + const randomBytes = crypto.randomBytes(32).toString('hex') const timestamp = Date.now().toString() const data = `${randomBytes}:${timestamp}` - const hash = crypto.createHmac('sha256', SESSION_SECRET).update(data).digest('hex') + const hash = crypto.createHmac('sha256', secret).update(data).digest('hex') return `${data}:${hash}` } @@ -33,12 +64,14 @@ function createSessionToken(): string { */ function verifySessionToken(token: string): boolean { try { + const secret = getSessionSecret() + const parts = token.split(':') if (parts.length !== 3) return false const [randomBytes, timestamp, hash] = parts const data = `${randomBytes}:${timestamp}` - const expectedHash = crypto.createHmac('sha256', SESSION_SECRET).update(data).digest('hex') + const expectedHash = crypto.createHmac('sha256', secret).update(data).digest('hex') if (hash !== expectedHash) return false @@ -70,8 +103,14 @@ export function checkAuth(req: NextApiRequest): boolean { const cookieHeader = req.headers.cookie if (!cookieHeader) return false + // Улучшенный парсинг cookies для корректной обработки значений с '=' const cookies = cookieHeader.split(';').reduce((acc, cookie) => { - const [key, value] = cookie.trim().split('=') + const trimmed = cookie.trim() + const equalIndex = trimmed.indexOf('=') + if (equalIndex === -1) return acc + + const key = trimmed.substring(0, equalIndex) + const value = trimmed.substring(equalIndex + 1) acc[key] = value return acc }, {} as Record)