Рефакторинг: улучшение системы аутентификации и UI компонентов
- Удалены устаревшие файлы (mock.js, old-schedule.txt, loading-overlay.tsx) - Переработана система аутентификации (login, logout, check-auth) - Добавлен компонент toast для уведомлений - Улучшен контекст загрузки (loading-context) - Обновлен парсер расписания (schedule.ts) - Улучшена админ-панель - Обновлена документация (README.md) - Старые файлы перемещены в директорию old/
This commit is contained in:
15
.example.env
15
.example.env
@@ -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=
|
|
||||||
101
README.md
101
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.
|
Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support.
|
||||||
|
|
||||||
[](https://kspsuti.ru#gh-dark-mode-only)
|
[](https://schedule.itlxrd.space/)
|
||||||
|
|
||||||
[](https://kspsuti.ru#gh-light-mode-only)
|
[](https://schedule.itlxrd.space/)
|
||||||
|
|
||||||
[Visit website](https://kspsuti.ru)
|
[Visit website](https://kspsuti.ru)
|
||||||
|
|
||||||
@@ -14,11 +14,12 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support.
|
|||||||
- Tailwind CSS
|
- Tailwind CSS
|
||||||
- @shadcn/ui components (built with Radix UI)
|
- @shadcn/ui components (built with Radix UI)
|
||||||
- JSDOM for parsing scraped pages, rehydration strategy for cache
|
- 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
|
- Telegram Bot API (via [node-telegram-bot-api]) for parsing failure notifications
|
||||||
- Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c)
|
- Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c)
|
||||||
- Accessibility & tab navigation support
|
- Accessibility & tab navigation support
|
||||||
- Dark theme with automatic switching based on system settings
|
- Dark theme with automatic switching based on system settings
|
||||||
|
- Admin panel for managing groups and settings
|
||||||
|
|
||||||
## Known issues
|
## 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.
|
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
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -41,6 +82,22 @@ npm install
|
|||||||
|
|
||||||
# Run development server
|
# Run development server
|
||||||
npm run dev
|
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
|
### Docker deployment
|
||||||
|
|
||||||
@@ -68,9 +125,12 @@ docker-compose down
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Environment variables:** Edit `docker-compose.yml` to add your environment variables:
|
**Environment variables:** Edit `docker-compose.yml` to add your environment variables:
|
||||||
- `PROXY_URL` - URL for schedule parsing
|
- `PROXY_URL` - URL for schedule parsing (optional)
|
||||||
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token
|
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token (optional)
|
||||||
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID
|
- `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
|
### Production deployment
|
||||||
|
|
||||||
@@ -165,16 +225,6 @@ sudo ./scripts/manage.sh enable
|
|||||||
sudo ./scripts/manage.sh disable
|
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:**
|
**Service configuration:**
|
||||||
|
|
||||||
- Installation directory: `/opt/kspguti-schedule`
|
- Installation directory: `/opt/kspguti-schedule`
|
||||||
@@ -184,10 +234,19 @@ sudo journalctl -u kspguti-schedule -f
|
|||||||
|
|
||||||
**Environment variables:**
|
**Environment variables:**
|
||||||
|
|
||||||
See `.env.production.example` or `.example.env` for available options. The application uses `.env` file in production:
|
See `.env.production.example` 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)
|
**Schedule parsing:**
|
||||||
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID (optional)
|
- `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
|
#### Other platforms
|
||||||
|
|
||||||
|
|||||||
28
old/README.md
Normal file
28
old/README.md
Normal file
@@ -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`
|
||||||
|
- Больше не используется в текущей реализации
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Эти файлы не включены в сборку проекта
|
||||||
|
- Они сохранены только для исторической справки
|
||||||
|
- При необходимости можно удалить эту папку без влияния на работу приложения
|
||||||
|
|
||||||
@@ -2,7 +2,6 @@ import { Day } from '@/shared/model/day'
|
|||||||
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
|
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
|
||||||
import contentTypeParser from 'content-type'
|
import contentTypeParser from 'content-type'
|
||||||
import { JSDOM } from 'jsdom'
|
import { JSDOM } from 'jsdom'
|
||||||
// import { content as mockContent } from './mock'
|
|
||||||
import { reportParserError } from '@/app/logger'
|
import { reportParserError } from '@/app/logger'
|
||||||
import { PROXY_URL } from '@/shared/constants/urls'
|
import { PROXY_URL } from '@/shared/constants/urls'
|
||||||
|
|
||||||
@@ -12,41 +11,62 @@ export type ScheduleResult = {
|
|||||||
availableWeeks?: WeekInfo[]
|
availableWeeks?: WeekInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ПС-7: 146
|
|
||||||
export async function getSchedule(groupID: number, groupName: string, wk?: number, parseWeekNavigation: boolean = true): Promise<ScheduleResult> {
|
export async function getSchedule(groupID: number, groupName: string, wk?: number, parseWeekNavigation: boolean = true): Promise<ScheduleResult> {
|
||||||
|
// Валидация параметров
|
||||||
|
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 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' } }
|
// Добавляем таймаут 30 секунд для fetch запроса
|
||||||
const content = await page.text()
|
const controller = new AbortController()
|
||||||
const contentType = page.headers.get('content-type')
|
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 секунд
|
||||||
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
|
|
||||||
let dom: JSDOM | null = null
|
try {
|
||||||
try {
|
const page = await fetch(url, { signal: controller.signal })
|
||||||
dom = new JSDOM(content, { url })
|
clearTimeout(timeoutId)
|
||||||
const root = dom.window.document
|
const content = await page.text()
|
||||||
const result = parsePage(root, groupName, url, parseWeekNavigation)
|
const contentType = page.headers.get('content-type')
|
||||||
const scheduleResult = {
|
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
|
||||||
days: result.days,
|
let dom: JSDOM | null = null
|
||||||
currentWk: result.currentWk || wk,
|
try {
|
||||||
availableWeeks: result.availableWeeks
|
dom = new JSDOM(content, { url })
|
||||||
}
|
const root = dom.window.document
|
||||||
// Явно очищаем JSDOM для освобождения памяти
|
const result = parsePage(root, groupName, url, parseWeekNavigation)
|
||||||
dom.window.close()
|
const scheduleResult = {
|
||||||
dom = null
|
days: result.days,
|
||||||
return scheduleResult
|
currentWk: result.currentWk || wk,
|
||||||
} catch(e) {
|
availableWeeks: result.availableWeeks
|
||||||
// Очищаем JSDOM даже в случае ошибки
|
}
|
||||||
if (dom) {
|
// Явно очищаем JSDOM для освобождения памяти
|
||||||
dom.window.close()
|
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}`)
|
} else {
|
||||||
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
|
// Логируем только метаданные, без содержимого ответа
|
||||||
throw e
|
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 {
|
} catch (error) {
|
||||||
console.error(page.status, contentType)
|
clearTimeout(timeoutId)
|
||||||
console.error(content.length > 500 ? content.slice(0, 500 - 3) + '...' : content)
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
|
throw new Error(`Request timeout while fetching ${PROXY_URL}`)
|
||||||
throw new Error(`Error while fetching ${PROXY_URL}`)
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
|||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
const group = context.params?.group
|
const group = context.params?.group
|
||||||
const wkParam = context.query.wk
|
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) {
|
if (group && Object.hasOwn(groups, group) && group in groups) {
|
||||||
let scheduleResult: ScheduleResult
|
let scheduleResult: ScheduleResult
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import '@/shared/styles/globals.css'
|
import '@/shared/styles/globals.css'
|
||||||
import type { AppProps } from 'next/app'
|
import type { AppProps } from 'next/app'
|
||||||
import { ThemeProvider } from '@/shared/providers/theme-provider'
|
import { ThemeProvider } from '@/shared/providers/theme-provider'
|
||||||
import { LoadingContextProvider, LoadingContext } from '@/shared/context/loading-context'
|
import { LoadingContextProvider, LoadingContext, LoadingOverlay } from '@/shared/context/loading-context'
|
||||||
import { LoadingOverlay } from '@/shared/ui/loading-overlay'
|
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
||||||
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
|
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select'
|
||||||
|
import { ToastContainer, Toast } from '@/shared/ui/toast'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
|
|
||||||
type AdminPageProps = {
|
type AdminPageProps = {
|
||||||
@@ -34,6 +35,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
const [showEditDialog, setShowEditDialog] = React.useState(false)
|
const [showEditDialog, setShowEditDialog] = React.useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
|
||||||
const [groupToDelete, setGroupToDelete] = React.useState<string | null>(null)
|
const [groupToDelete, setGroupToDelete] = React.useState<string | null>(null)
|
||||||
|
const [toasts, setToasts] = React.useState<Toast[]>([])
|
||||||
|
|
||||||
|
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({
|
const [formData, setFormData] = React.useState({
|
||||||
@@ -131,15 +142,20 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
// Обновляем состояние из ответа сервера (для синхронизации)
|
// Обновляем состояние из ответа сервера (для синхронизации)
|
||||||
setSettings(data.settings)
|
setSettings(data.settings)
|
||||||
|
showToast('Настройки успешно обновлены', 'success')
|
||||||
} else {
|
} else {
|
||||||
// Откатываем изменения при ошибке
|
// Откатываем изменения при ошибке
|
||||||
setSettings(previousSettings)
|
setSettings(previousSettings)
|
||||||
setError(data.error || 'Ошибка при обновлении настроек')
|
const errorMessage = data.error || 'Ошибка при обновлении настроек'
|
||||||
|
setError(errorMessage)
|
||||||
|
showToast(errorMessage, 'error')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Откатываем изменения при ошибке
|
// Откатываем изменения при ошибке
|
||||||
setSettings(previousSettings)
|
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)
|
setGroups(data.groups)
|
||||||
setShowAddDialog(false)
|
setShowAddDialog(false)
|
||||||
setFormData({ id: '', parseId: '', name: '', course: '1' })
|
setFormData({ id: '', parseId: '', name: '', course: '1' })
|
||||||
|
showToast('Группа успешно добавлена', 'success')
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Ошибка при добавлении группы')
|
const errorMessage = data.error || 'Ошибка при добавлении группы'
|
||||||
|
setError(errorMessage)
|
||||||
|
showToast(errorMessage, 'error')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Ошибка соединения с сервером')
|
const errorMessage = 'Ошибка соединения с сервером'
|
||||||
|
setError(errorMessage)
|
||||||
|
showToast(errorMessage, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -201,11 +222,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
setShowEditDialog(false)
|
setShowEditDialog(false)
|
||||||
setEditingGroup(null)
|
setEditingGroup(null)
|
||||||
setFormData({ id: '', parseId: '', name: '', course: '1' })
|
setFormData({ id: '', parseId: '', name: '', course: '1' })
|
||||||
|
showToast('Группа успешно обновлена', 'success')
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Ошибка при редактировании группы')
|
const errorMessage = data.error || 'Ошибка при редактировании группы'
|
||||||
|
setError(errorMessage)
|
||||||
|
showToast(errorMessage, 'error')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Ошибка соединения с сервером')
|
const errorMessage = 'Ошибка соединения с сервером'
|
||||||
|
setError(errorMessage)
|
||||||
|
showToast(errorMessage, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -228,11 +254,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
setGroups(data.groups)
|
setGroups(data.groups)
|
||||||
setShowDeleteDialog(false)
|
setShowDeleteDialog(false)
|
||||||
setGroupToDelete(null)
|
setGroupToDelete(null)
|
||||||
|
showToast('Группа успешно удалена', 'success')
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Ошибка при удалении группы')
|
const errorMessage = data.error || 'Ошибка при удалении группы'
|
||||||
|
setError(errorMessage)
|
||||||
|
showToast(errorMessage, 'error')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Ошибка соединения с сервером')
|
const errorMessage = 'Ошибка соединения с сервером'
|
||||||
|
setError(errorMessage)
|
||||||
|
showToast(errorMessage, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -586,6 +617,9 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Toast уведомления */}
|
||||||
|
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ export default function handler(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,79 @@ type ResponseData = {
|
|||||||
error?: string
|
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<string, RateLimitEntry>()
|
||||||
|
|
||||||
|
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(
|
export default function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<ResponseData>
|
res: NextApiResponse<ResponseData>
|
||||||
@@ -15,6 +88,19 @@ export default function handler(
|
|||||||
return
|
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
|
const { password } = req.body
|
||||||
|
|
||||||
if (!password || typeof password !== 'string') {
|
if (!password || typeof password !== 'string') {
|
||||||
@@ -23,9 +109,13 @@ export default function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (verifyPassword(password)) {
|
if (verifyPassword(password)) {
|
||||||
|
// Успешный вход - сбрасываем rate limit
|
||||||
|
rateLimitMap.delete(clientIP)
|
||||||
setSessionCookie(res)
|
setSessionCookie(res)
|
||||||
res.status(200).json({ success: true })
|
res.status(200).json({ success: true })
|
||||||
} else {
|
} else {
|
||||||
|
// Неудачная попытка - записываем
|
||||||
|
recordFailedAttempt(clientIP)
|
||||||
res.status(401).json({ error: 'Invalid password' })
|
res.status(401).json({ error: 'Invalid password' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,3 +123,4 @@ export default function handler(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ export default function handler(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
import { Spinner } from '@/shared/ui/spinner'
|
||||||
|
import { cn } from '@/shared/utils'
|
||||||
|
|
||||||
interface LoadingContextType {
|
interface LoadingContextType {
|
||||||
isLoading: boolean
|
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<string>('')
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 flex items-center justify-center',
|
||||||
|
'bg-background/80 backdrop-blur-md',
|
||||||
|
'transition-opacity duration-300',
|
||||||
|
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
)}
|
||||||
|
aria-label="Загрузка"
|
||||||
|
role="status"
|
||||||
|
aria-hidden={!isLoading}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-16 h-16">
|
||||||
|
<Spinner size="large" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="min-h-[1.5rem] text-center transition-opacity duration-300"
|
||||||
|
style={{ opacity: messageOpacity }}
|
||||||
|
>
|
||||||
|
{currentMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 z-50 flex items-center justify-center',
|
|
||||||
'bg-background/80 backdrop-blur-md',
|
|
||||||
'transition-opacity duration-300',
|
|
||||||
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
||||||
)}
|
|
||||||
aria-label="Загрузка"
|
|
||||||
role="status"
|
|
||||||
aria-hidden={!isLoading}
|
|
||||||
>
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="w-16 h-16">
|
|
||||||
<Spinner size="large" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
86
src/shared/ui/toast.tsx
Normal file
86
src/shared/ui/toast.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-4 left-1/2 -translate-x-1/2 z-50',
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg',
|
||||||
|
'border min-w-[300px] max-w-[500px]',
|
||||||
|
'transition-all duration-300 ease-in-out',
|
||||||
|
isClosing
|
||||||
|
? 'opacity-0 translate-y-2 scale-95'
|
||||||
|
: 'opacity-100 translate-y-0 scale-100 animate-in slide-in-from-bottom-5 fade-in-0',
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'bg-background border-green-500/50 text-foreground'
|
||||||
|
: 'bg-background border-destructive/50 text-foreground'
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-5 w-5 text-destructive flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<p className="flex-1 text-sm font-medium">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-shrink-0 rounded-sm opacity-70 hover:opacity-100 transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 min-w-[24px] min-h-[24px] flex items-center justify-center"
|
||||||
|
aria-label="Закрыть уведомление"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContainerProps {
|
||||||
|
toasts: Toast[]
|
||||||
|
onClose: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastContainer({ toasts, onClose }: ToastContainerProps) {
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2 items-center">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastComponent key={toast.id} toast={toast} onClose={() => onClose(toast.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,11 +2,28 @@ import { NextApiRequest, NextApiResponse } from 'next'
|
|||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'admin_session'
|
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 часа
|
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 {
|
export function verifyPassword(password: string): boolean {
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD
|
const adminPassword = process.env.ADMIN_PASSWORD
|
||||||
@@ -14,17 +31,31 @@ export function verifyPassword(password: string): boolean {
|
|||||||
console.error('ADMIN_PASSWORD is not set')
|
console.error('ADMIN_PASSWORD is not set')
|
||||||
return false
|
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 {
|
function createSessionToken(): string {
|
||||||
|
const secret = getSessionSecret()
|
||||||
|
|
||||||
const randomBytes = crypto.randomBytes(32).toString('hex')
|
const randomBytes = crypto.randomBytes(32).toString('hex')
|
||||||
const timestamp = Date.now().toString()
|
const timestamp = Date.now().toString()
|
||||||
const data = `${randomBytes}:${timestamp}`
|
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}`
|
return `${data}:${hash}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,12 +64,14 @@ function createSessionToken(): string {
|
|||||||
*/
|
*/
|
||||||
function verifySessionToken(token: string): boolean {
|
function verifySessionToken(token: string): boolean {
|
||||||
try {
|
try {
|
||||||
|
const secret = getSessionSecret()
|
||||||
|
|
||||||
const parts = token.split(':')
|
const parts = token.split(':')
|
||||||
if (parts.length !== 3) return false
|
if (parts.length !== 3) return false
|
||||||
|
|
||||||
const [randomBytes, timestamp, hash] = parts
|
const [randomBytes, timestamp, hash] = parts
|
||||||
const data = `${randomBytes}:${timestamp}`
|
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
|
if (hash !== expectedHash) return false
|
||||||
|
|
||||||
@@ -70,8 +103,14 @@ export function checkAuth(req: NextApiRequest): boolean {
|
|||||||
const cookieHeader = req.headers.cookie
|
const cookieHeader = req.headers.cookie
|
||||||
if (!cookieHeader) return false
|
if (!cookieHeader) return false
|
||||||
|
|
||||||
|
// Улучшенный парсинг cookies для корректной обработки значений с '='
|
||||||
const cookies = cookieHeader.split(';').reduce((acc, cookie) => {
|
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
|
acc[key] = value
|
||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, string>)
|
}, {} as Record<string, string>)
|
||||||
|
|||||||
Reference in New Issue
Block a user