Рефакторинг: улучшение системы аутентификации и 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.
|
||||
|
||||
[](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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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 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<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 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<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({
|
||||
@@ -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
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Toast уведомления */}
|
||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,3 +21,4 @@ export default function handler(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<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(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
@@ -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(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,3 +21,4 @@ export default function handler(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<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'
|
||||
|
||||
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<string, string>)
|
||||
|
||||
Reference in New Issue
Block a user