Рефакторинг: улучшение системы аутентификации и 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:
kilyabin
2025-11-28 00:29:46 +04:00
parent 24bb531dfb
commit 9df04745df
17 changed files with 511 additions and 117 deletions

View File

@@ -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
View File

@@ -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.
[![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) [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

View File

@@ -1 +0,0 @@
u<EFBFBD>Z

28
old/README.md Normal file
View 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`
- Больше не используется в текущей реализации
## Примечания
- Эти файлы не включены в сборку проекта
- Они сохранены только для исторической справки
- При необходимости можно удалить эту папку без влияния на работу приложения

View File

@@ -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
} }
} }

View File

@@ -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

View File

@@ -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'

View File

@@ -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} />
</> </>
) )
} }

View File

@@ -21,3 +21,4 @@ export default function handler(

View File

@@ -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(

View File

@@ -21,3 +21,4 @@ export default function handler(

View File

@@ -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>
)
}

View File

@@ -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
View 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>
)
}

View File

@@ -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>)