refactor: optimize project structure, migrate to SQLite, and add new features

fix:
  - Fix TypeScript type errors in api-wrapper.ts (ApiResponse type)
  - Fix backward compatibility in database.ts getSettings() for missing fields
  - Fix default value for weekNavigationEnabled (changed from true to false)
  - Fix API routes error handling with unified wrapper
  - Fix duplicate toggle switch code in admin.tsx (6 instances)
  - Fix inconsistent authentication check in API routes (unified with withAuth)
  - Fix error message text in loading-context.tsx (improved user experience)

add:
  - Add database.ts: SQLite database layer with better-sqlite3 for persistent storage
    * Groups management (CRUD operations)
    * Settings management with caching
    * Admin password hashing with bcrypt
    * Automatic database initialization and migration
  - Add api-wrapper.ts utility for unified API route handling
    * withAuth wrapper for protected routes
    * withMethods wrapper for public routes
    * Consistent error handling and method validation
  - Add validation.ts utility with centralized validation functions
    * validateCourse - course validation (1-5)
    * validateGroupId - group ID format validation
    * validatePassword - password strength validation
  - Add showAddGroupButton setting to control visibility of 'Add Group' button on homepage
  - Add toggle switch component in admin.tsx for reusable UI (replaces 6 duplicate instances)
  - Add CourseSelect component in admin.tsx for reusable course selection
  - Add DialogFooterButtons component in admin.tsx for reusable dialog footer
  - Add unified loadData function in admin.tsx to reduce code duplication
  - Add change-password.ts API endpoint for admin password management
  - Add logs.ts API endpoint for viewing error logs in admin panel
  - Add logErrorToFile function in logger.ts for persistent error logging
  - Add comprehensive error logging in schedule.ts (parsing, fetch, timeout, network errors)
  - Add comprehensive project structure documentation in README.md
  - Add architecture and code organization section in README.md
  - Add database information section in README.md
  - Add SQLite and bcrypt to tech stack documentation
  - Add better-sqlite3 and bcrypt dependencies to package.json
  - Add .gitignore rules for error.log and database files (data/, *.db, *.db-shm, *.db-wal)

refactor:
  - Refactor admin.tsx: extract reusable components (toggle, select, dialog footer)
  - Refactor API routes to use withAuth wrapper for consistent authentication
  - Refactor API routes to use validation utilities instead of inline validation
  - Refactor groups.ts and groups.json: move to old/data/ directory (deprecated, now using SQLite)
  - Refactor settings-loader.ts: migrate from JSON to SQLite database
  - Refactor groups-loader.ts: migrate from JSON to SQLite database
  - Refactor database.ts: improve backward compatibility for settings migration
  - Refactor admin.tsx: unify data loading functions (loadGroupsList, loadSettingsList)
  - Refactor index.tsx: add showAddGroupButton prop and conditional rendering
  - Refactor API routes: consistent error handling and method validation
  - Refactor README.md: update tech stack, project structure, and admin panel documentation
  - Refactor auth.ts: improve session management and cookie handling
  - Refactor schedule.ts: improve error handling with detailed logging and error types
  - Refactor logger.ts: add file-based error logging functionality
  - Refactor loading-context.tsx: improve error message clarity

remove:
  - Remove hello.ts test API endpoint
  - Remove groups.ts and groups.json (moved to old/data/, replaced by SQLite)

update:
  - Update .gitignore to exclude old data files, database files, and error logs
  - Update package.json: add better-sqlite3, bcrypt and their type definitions
  - Update README.md with new features, architecture, and database information
  - Update all API routes to use new wrapper system
  - Update admin panel with new settings and improved UI
  - Update sitemap.xml with cache usage comment
This commit is contained in:
kilyabin
2025-12-03 21:44:07 +04:00
parent 0907581cc0
commit e46a2419c3
27 changed files with 1937 additions and 627 deletions

View File

@@ -2,7 +2,7 @@ import { Day } from '@/shared/model/day'
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
import contentTypeParser from 'content-type'
import { JSDOM } from 'jsdom'
import { reportParserError } from '@/app/logger'
import { reportParserError, logErrorToFile } from '@/app/logger'
import { PROXY_URL } from '@/shared/constants/urls'
export type ScheduleResult = {
@@ -60,19 +60,78 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe
dom.window.close()
}
console.error(`Error while parsing ${PROXY_URL}`)
const error = e instanceof Error ? e : new Error(String(e))
logErrorToFile(error, {
type: 'parsing_error',
groupName,
url,
groupID
})
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
throw e
}
} else {
// Логируем только метаданные, без содержимого ответа
console.error(`Failed to fetch schedule: status=${page.status}, contentType=${contentType}, contentLength=${content.length}`)
const error = new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`)
logErrorToFile(error, {
type: 'fetch_error',
groupName,
url,
groupID,
status: page.status,
contentType
})
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
throw new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`)
throw error
}
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
throw new ScheduleTimeoutError(`Request timeout while fetching ${PROXY_URL}`)
const timeoutError = new ScheduleTimeoutError(`Request timeout while fetching ${PROXY_URL}`)
logErrorToFile(timeoutError, {
type: 'timeout_error',
groupName,
url,
groupID
})
throw timeoutError
}
// Улучшенная обработка сетевых ошибок для диагностики
const errorObj = error instanceof Error ? error : new Error(String(error))
if (errorObj && 'cause' in errorObj && errorObj.cause instanceof Error) {
const networkError = errorObj.cause as Error & { code?: string }
if (networkError.code === 'ECONNRESET' || networkError.code === 'ECONNREFUSED' || networkError.code === 'ETIMEDOUT') {
console.error(`Network error while fetching ${PROXY_URL}:`, {
code: networkError.code,
message: networkError.message,
url
})
logErrorToFile(errorObj, {
type: 'network_error',
groupName,
url,
groupID,
networkErrorCode: networkError.code,
networkErrorMessage: networkError.message
})
} else {
// Логируем другие ошибки тоже
logErrorToFile(errorObj, {
type: 'unknown_error',
groupName,
url,
groupID
})
}
} else {
// Логируем ошибки без cause
logErrorToFile(errorObj, {
type: 'unknown_error',
groupName,
url,
groupID
})
}
throw error
}

View File

@@ -1,4 +1,6 @@
import TelegramBot from 'node-telegram-bot-api'
import fs from 'fs'
import path from 'path'
const token = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN
const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID
@@ -10,6 +12,46 @@ if (!token || !ownerID) {
bot = new TelegramBot(token, { polling: false })
}
// Путь к файлу логов (в корне проекта)
const getErrorLogPath = () => {
// В production (standalone) используем текущую рабочую директорию
// В development используем корень проекта (process.cwd())
return path.join(process.cwd(), 'error.log')
}
/**
* Логирует ошибку в файл error.log
* @param error - Объект ошибки или строка с описанием ошибки
* @param context - Дополнительный контекст (опционально)
*/
export function logErrorToFile(error: Error | string, context?: Record<string, unknown>): void {
try {
const logPath = getErrorLogPath()
const timestamp = new Date().toISOString()
const errorMessage = error instanceof Error ? error.message : error
const errorStack = error instanceof Error ? error.stack : undefined
const errorName = error instanceof Error ? error.name : 'Error'
let logEntry = `[${timestamp}] ${errorName}: ${errorMessage}\n`
if (errorStack) {
logEntry += `Stack: ${errorStack}\n`
}
if (context && Object.keys(context).length > 0) {
logEntry += `Context: ${JSON.stringify(context, null, 2)}\n`
}
logEntry += '---\n'
// Используем appendFileSync для надежности (не блокирует надолго)
fs.appendFileSync(logPath, logEntry, 'utf8')
} catch (logError) {
// Если не удалось записать в файл, выводим в консоль
console.error('Failed to write to error.log:', logError)
}
}
export async function reportParserError(...text: string[]) {
if (!token || !ownerID) return

View File

@@ -275,10 +275,101 @@ const parseLesson = (row: Element): Lesson | null => {
const isFreeTimeReplacement = lesson.isChange &&
(cellText.includes('Свободное время') && cellText.includes('Замена') && cellText.includes('на:'))
// Проверяем, является ли это заменой предмета на предмет
const isSubjectReplacement = lesson.isChange &&
!isFreeTimeReplacement &&
cellText.includes('Замена') &&
cellText.includes('на:')
if (isFreeTimeReplacement) {
// Для замены "свободное время" на пару нужно парсить данные после "на:"
// Структура: "Замена Свободное время на:</a><br> название <br> преподаватель <font> адрес <br> кабинет </font>
// Используем HTML парсинг для извлечения данных после "на:"
const afterOnIndex = cellHTML.indexOf('на:')
if (afterOnIndex !== -1) {
const afterOn = cellHTML.substring(afterOnIndex + 3) // +3 для "на:"
// Пропускаем первый <br> (он идет сразу после "на:")
const firstBrIndex = afterOn.indexOf('<br')
if (firstBrIndex !== -1) {
// Находим конец первого <br> тега
const firstBrEnd = afterOn.indexOf('>', firstBrIndex) + 1
const afterFirstBr = afterOn.substring(firstBrEnd)
// Извлекаем название предмета (текст до следующего <br>)
const secondBrIndex = afterFirstBr.indexOf('<br')
if (secondBrIndex !== -1) {
const subjectHTML = afterFirstBr.substring(0, secondBrIndex)
lesson.subject = subjectHTML.replace(/<[^>]+>/g, '').trim()
// Извлекаем преподавателя (текст между вторым <br> и <font> или следующим <br>)
const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1
const afterSecondBr = afterFirstBr.substring(secondBrEnd)
const fontIndex = afterSecondBr.indexOf('<font')
if (fontIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, fontIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
// Если нет <font>, преподаватель может быть до следующего <br> или до конца
const thirdBrIndex = afterSecondBr.indexOf('<br')
if (thirdBrIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, thirdBrIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim()
}
}
} else {
// Если нет второго <br>, название предмета может быть до <font> или до конца
const fontIndex = afterFirstBr.indexOf('<font')
if (fontIndex !== -1) {
const subjectHTML = afterFirstBr.substring(0, fontIndex)
lesson.subject = subjectHTML.replace(/<[^>]+>/g, '').trim()
} else {
lesson.subject = afterFirstBr.replace(/<[^>]+>/g, '').trim()
}
}
}
// Ищем адрес и кабинет внутри <font>
const fontMatch = afterOn.match(/<font[^>]*>([\s\S]*?)<\/font>/i)
if (fontMatch) {
const fontContent = fontMatch[1]
// Ищем паттерн: <br> адрес <br> Кабинет: номер
// Сначала убираем все теги и разбиваем по <br>
const cleanContent = fontContent.replace(/<[^>]+>/g, '|').split('|').filter(p => p.trim())
// Ищем адрес (первая непустая часть) и кабинет (часть с "Кабинет:")
for (let i = 0; i < cleanContent.length; i++) {
const part = cleanContent[i].trim()
if (part && !part.includes('Кабинет:')) {
const nextPart = cleanContent[i + 1]?.trim() || ''
const classroomMatch = nextPart.match(/Кабинет:\s*([^\s]+)/i)
if (classroomMatch) {
lesson.place = {
address: part,
classroom: classroomMatch[1]
}
break
}
}
}
} else {
// Если нет <font>, ищем адрес и кабинет напрямую в тексте после "на:"
const addressMatch = afterOn.match(/([^<]+?)(?:<br[^>]*>|\s+)Кабинет:\s*([^<\s]+)/i)
if (addressMatch) {
lesson.place = {
address: addressMatch[1].replace(/<[^>]+>/g, '').trim(),
classroom: addressMatch[2].trim()
}
}
}
}
} else if (isSubjectReplacement) {
// Для замены предмета на предмет нужно парсить данные после "на:"
// Структура: "Замена [старый предмет] на:</a><br> [новый предмет] <br> [преподаватель] <font> [адрес] <br> Кабинет: [номер] </font>
// Используем HTML парсинг для извлечения данных после "на:"
const afterOnIndex = cellHTML.indexOf('на:')
if (afterOnIndex !== -1) {

View File

@@ -155,6 +155,7 @@ function cleanupCache() {
}
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
// Используем кеш (обновляется каждую минуту автоматически)
const groups = loadGroups()
const settings = loadSettings()
const group = context.params?.group

View File

@@ -27,9 +27,74 @@ import {
type AdminPageProps = {
groups: GroupsData
settings: AppSettings
isDefaultPassword: boolean
}
export default function AdminPage({ groups: initialGroups, settings: initialSettings }: AdminPageProps) {
// Компонент Toggle Switch
function ToggleSwitch({ checked, onChange, disabled }: {
checked: boolean
onChange: (checked: boolean) => void
disabled?: boolean
}) {
return (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
</label>
)
}
// Компонент выбора курса
function CourseSelect({ value, onChange, id }: {
value: string
onChange: (value: string) => void
id: string
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id}>
<SelectValue placeholder="Выберите курс" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 курс</SelectItem>
<SelectItem value="2">2 курс</SelectItem>
<SelectItem value="3">3 курс</SelectItem>
<SelectItem value="4">4 курс</SelectItem>
<SelectItem value="5">5 курс</SelectItem>
</SelectContent>
</Select>
)
}
// Компонент для DialogFooter с кнопками
function DialogFooterButtons({ onCancel, onSubmit, submitLabel, loading, submitVariant = 'default' }: {
onCancel: () => void
onSubmit?: () => void
submitLabel: string
loading?: boolean
submitVariant?: 'default' | 'destructive'
}) {
return (
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
Отмена
</Button>
{onSubmit && (
<Button type="button" variant={submitVariant} onClick={onSubmit} disabled={loading}>
{loading ? 'Обработка...' : submitLabel}
</Button>
)}
</DialogFooter>
)
}
export default function AdminPage({ groups: initialGroups, settings: initialSettings, isDefaultPassword: initialIsDefaultPassword }: AdminPageProps) {
const [authenticated, setAuthenticated] = React.useState<boolean | null>(null)
const [password, setPassword] = React.useState('')
const [loading, setLoading] = React.useState(false)
@@ -40,8 +105,18 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
const [showAddDialog, setShowAddDialog] = React.useState(false)
const [showEditDialog, setShowEditDialog] = React.useState(false)
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
const [showLogsDialog, setShowLogsDialog] = React.useState(false)
const [logs, setLogs] = React.useState<string>('')
const [logsLoading, setLogsLoading] = React.useState(false)
const [groupToDelete, setGroupToDelete] = React.useState<string | null>(null)
const [toasts, setToasts] = React.useState<Toast[]>([])
const [showChangePasswordDialog, setShowChangePasswordDialog] = React.useState(false)
const [isDefaultPassword, setIsDefaultPassword] = React.useState<boolean>(initialIsDefaultPassword)
const [passwordFormData, setPasswordFormData] = React.useState({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
const id = Date.now().toString()
@@ -105,29 +180,22 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
}
}
const loadGroupsList = async () => {
const loadData = async <T,>(endpoint: string, setter: (data: T) => void) => {
try {
const res = await fetch('/api/admin/groups')
const res = await fetch(endpoint)
const data = await res.json()
if (data.groups) {
setGroups(data.groups)
setter(data.groups as T)
} else if (data.settings) {
setter(data.settings as T)
}
} catch (err) {
console.error('Error loading groups:', err)
console.error(`Error loading data from ${endpoint}:`, err)
}
}
const loadSettingsList = async () => {
try {
const res = await fetch('/api/admin/settings')
const data = await res.json()
if (data.settings) {
setSettings(data.settings)
}
} catch (err) {
console.error('Error loading settings:', err)
}
}
const loadGroupsList = () => loadData('/api/admin/groups', setGroups)
const loadSettingsList = () => loadData('/api/admin/settings', setSettings)
const handleUpdateSettings = async (newSettings: AppSettings) => {
// Сохраняем предыдущее состояние для отката при ошибке
@@ -289,6 +357,29 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
setShowDeleteDialog(true)
}
const loadLogs = async () => {
setLogsLoading(true)
try {
const res = await fetch('/api/admin/logs')
const data = await res.json()
if (data.success && data.logs) {
setLogs(data.logs)
} else {
setLogs(data.error || 'Не удалось загрузить логи')
}
} catch (err) {
setLogs('Ошибка при загрузке логов')
console.error('Error loading logs:', err)
} finally {
setLogsLoading(false)
}
}
const handleOpenLogsDialog = () => {
setShowLogsDialog(true)
loadLogs()
}
if (authenticated === null) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -350,19 +441,27 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Админ-панель</h1>
<Button
variant="outline"
onClick={async () => {
try {
await fetch('/api/admin/logout', { method: 'POST' })
} catch (err) {
console.error('Logout error:', err)
}
setAuthenticated(false)
}}
>
Выйти
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleOpenLogsDialog}
>
Логи
</Button>
<Button
variant="outline"
onClick={async () => {
try {
await fetch('/api/admin/logout', { method: 'POST' })
} catch (err) {
console.error('Logout error:', err)
}
setAuthenticated(false)
}}
>
Выйти
</Button>
</div>
</div>
{error && (
@@ -371,6 +470,34 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
</div>
)}
{isDefaultPassword && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20">
<CardHeader>
<CardTitle className="text-yellow-800 dark:text-yellow-200">Внимание: используется стандартный пароль</CardTitle>
<CardDescription className="text-yellow-700 dark:text-yellow-300">
Для безопасности рекомендуется сменить пароль на более надежный
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setShowChangePasswordDialog(true)} variant="default">
Сменить пароль
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Безопасность</CardTitle>
<CardDescription>Управление паролем администратора</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setShowChangePasswordDialog(true)} variant="outline">
Сменить пароль
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Настройки</CardTitle>
@@ -385,16 +512,24 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
Включить или выключить навигацию по неделям в расписании
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.weekNavigationEnabled}
onChange={(e) => handleUpdateSettings({ ...settings, weekNavigationEnabled: e.target.checked })}
disabled={loading}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
</label>
<ToggleSwitch
checked={settings.weekNavigationEnabled}
onChange={(checked) => handleUpdateSettings({ ...settings, weekNavigationEnabled: checked })}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<div className="font-semibold">Кнопка "Добавить группу"</div>
<div className="text-sm text-muted-foreground">
Отображать кнопку "Добавить группу" на главной странице
</div>
</div>
<ToggleSwitch
checked={settings.showAddGroupButton ?? true}
onChange={(checked) => handleUpdateSettings({ ...settings, showAddGroupButton: checked })}
disabled={loading}
/>
</div>
</div>
</CardContent>
@@ -468,22 +603,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
Принудительно использовать кэш, даже если он свежий (симулирует ошибку парсинга)
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.debug?.forceCache ?? false}
onChange={(e) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
forceCache: e.target.checked
}
})}
disabled={loading}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
</label>
<ToggleSwitch
checked={settings.debug?.forceCache ?? false}
onChange={(checked) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
forceCache: checked
}
})}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
@@ -492,22 +622,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
Показать пустое расписание независимо от реальных данных
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.debug?.forceEmpty ?? false}
onChange={(e) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
forceEmpty: e.target.checked
}
})}
disabled={loading}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
</label>
<ToggleSwitch
checked={settings.debug?.forceEmpty ?? false}
onChange={(checked) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
forceEmpty: checked
}
})}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
@@ -516,22 +641,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
Показать страницу ошибки независимо от реальных данных
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.debug?.forceError ?? false}
onChange={(e) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
forceError: e.target.checked
}
})}
disabled={loading}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
</label>
<ToggleSwitch
checked={settings.debug?.forceError ?? false}
onChange={(checked) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
forceError: checked
}
})}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
@@ -540,22 +660,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
Симулировать таймаут при загрузке расписания
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.debug?.forceTimeout ?? false}
onChange={(e) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
forceTimeout: e.target.checked
}
})}
disabled={loading}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
</label>
<ToggleSwitch
checked={settings.debug?.forceTimeout ?? false}
onChange={(checked) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
forceTimeout: checked
}
})}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
@@ -564,22 +679,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
Показать дополнительную информацию о кэше в интерфейсе
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.debug?.showCacheInfo ?? false}
onChange={(e) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
showCacheInfo: e.target.checked
}
})}
disabled={loading}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500"></div>
</label>
<ToggleSwitch
checked={settings.debug?.showCacheInfo ?? false}
onChange={(checked) => handleUpdateSettings({
...settings,
debug: {
...settings.debug,
showCacheInfo: checked
}
})}
disabled={loading}
/>
</div>
</div>
</CardContent>
@@ -639,21 +749,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
</div>
<div className="space-y-2">
<Label htmlFor="add-course">Курс</Label>
<Select
<CourseSelect
value={formData.course}
onValueChange={(value) => setFormData({ ...formData, course: value })}
>
<SelectTrigger id="add-course">
<SelectValue placeholder="Выберите курс" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 курс</SelectItem>
<SelectItem value="2">2 курс</SelectItem>
<SelectItem value="3">3 курс</SelectItem>
<SelectItem value="4">4 курс</SelectItem>
<SelectItem value="5">5 курс</SelectItem>
</SelectContent>
</Select>
onChange={(value) => setFormData({ ...formData, course: value })}
id="add-course"
/>
</div>
</div>
<DialogFooter>
@@ -712,21 +812,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
</div>
<div className="space-y-2">
<Label htmlFor="edit-course">Курс</Label>
<Select
<CourseSelect
value={formData.course}
onValueChange={(value) => setFormData({ ...formData, course: value })}
>
<SelectTrigger id="edit-course">
<SelectValue placeholder="Выберите курс" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 курс</SelectItem>
<SelectItem value="2">2 курс</SelectItem>
<SelectItem value="3">3 курс</SelectItem>
<SelectItem value="4">4 курс</SelectItem>
<SelectItem value="5">5 курс</SelectItem>
</SelectContent>
</Select>
onChange={(value) => setFormData({ ...formData, course: value })}
id="edit-course"
/>
</div>
</div>
<DialogFooter>
@@ -751,17 +841,161 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
Это действие нельзя отменить.
</DialogDescription>
</DialogHeader>
<DialogFooterButtons
onCancel={() => setShowDeleteDialog(false)}
onSubmit={handleDeleteGroup}
submitLabel="Удалить"
loading={loading}
submitVariant="destructive"
/>
</DialogContent>
</Dialog>
{/* Диалог просмотра логов */}
<Dialog open={showLogsDialog} onOpenChange={setShowLogsDialog}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Логи ошибок</DialogTitle>
<DialogDescription>
Содержимое файла error.log
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{logsLoading ? (
<div className="p-4 text-center text-muted-foreground">Загрузка логов...</div>
) : (
<div className="relative">
<pre className="p-4 bg-muted rounded-md overflow-auto max-h-[60vh] text-sm font-mono whitespace-pre-wrap break-words">
{logs || 'Логи пусты'}
</pre>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={loadLogs}
>
Обновить
</Button>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setShowDeleteDialog(false)}>
Отмена
</Button>
<Button type="button" variant="destructive" onClick={handleDeleteGroup} disabled={loading}>
{loading ? 'Удаление...' : 'Удалить'}
<Button type="button" variant="outline" onClick={() => setShowLogsDialog(false)}>
Закрыть
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Диалог смены пароля */}
<Dialog open={showChangePasswordDialog} onOpenChange={setShowChangePasswordDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Сменить пароль</DialogTitle>
<DialogDescription>
Введите старый пароль и новый пароль (минимум 8 символов)
</DialogDescription>
</DialogHeader>
<form
onSubmit={async (e) => {
e.preventDefault()
setLoading(true)
setError(null)
// Валидация на клиенте
if (passwordFormData.newPassword.length < 8) {
setError('Новый пароль должен содержать минимум 8 символов')
setLoading(false)
return
}
if (passwordFormData.newPassword !== passwordFormData.confirmPassword) {
setError('Новые пароли не совпадают')
setLoading(false)
return
}
try {
const res = await fetch('/api/admin/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldPassword: passwordFormData.oldPassword,
newPassword: passwordFormData.newPassword
})
})
const data = await res.json()
if (res.ok && data.success) {
setShowChangePasswordDialog(false)
setPasswordFormData({ oldPassword: '', newPassword: '', confirmPassword: '' })
setIsDefaultPassword(false) // После смены пароля он больше не дефолтный
showToast('Пароль успешно изменен', 'success')
} else {
const errorMessage = data.error || 'Ошибка при смене пароля'
setError(errorMessage)
showToast(errorMessage, 'error')
}
} catch (err) {
const errorMessage = 'Ошибка соединения с сервером'
setError(errorMessage)
showToast(errorMessage, 'error')
} finally {
setLoading(false)
}
}}
>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="old-password">Старый пароль</Label>
<Input
id="old-password"
type="password"
value={passwordFormData.oldPassword}
onChange={(e) => setPasswordFormData({ ...passwordFormData, oldPassword: e.target.value })}
required
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Новый пароль</Label>
<Input
id="new-password"
type="password"
value={passwordFormData.newPassword}
onChange={(e) => setPasswordFormData({ ...passwordFormData, newPassword: e.target.value })}
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">
Минимум 8 символов
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Подтверждение нового пароля</Label>
<Input
id="confirm-password"
type="password"
value={passwordFormData.confirmPassword}
onChange={(e) => setPasswordFormData({ ...passwordFormData, confirmPassword: e.target.value })}
required
minLength={8}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setShowChangePasswordDialog(false)}>
Отмена
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Сохранение...' : 'Сменить пароль'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Toast уведомления */}
<ToastContainer toasts={toasts} onClose={removeToast} />
</>
@@ -771,11 +1005,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
const groups = loadGroups()
const settings = loadSettings()
// Проверяем, используется ли дефолтный пароль
const { isDefaultPassword } = await import('@/shared/data/database')
const isDefault = await isDefaultPassword()
return {
props: {
groups,
settings
settings,
isDefaultPassword: isDefault
}
}
}

View File

@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { changePassword } from '@/shared/data/database'
import { validatePassword } from '@/shared/utils/validation'
type ResponseData = ApiResponse
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
const { oldPassword, newPassword } = req.body
if (!oldPassword || typeof oldPassword !== 'string') {
res.status(400).json({ error: 'Old password is required' })
return
}
if (!newPassword || typeof newPassword !== 'string') {
res.status(400).json({ error: 'New password is required' })
return
}
// Валидация нового пароля (минимум 8 символов)
if (!validatePassword(newPassword)) {
res.status(400).json({ error: 'New password must be at least 8 characters long' })
return
}
const success = await changePassword(oldPassword, newPassword)
if (success) {
res.status(200).json({ success: true })
} else {
res.status(401).json({ error: 'Invalid old password' })
}
}
export default withAuth(handler, ['POST'])

View File

@@ -1,20 +1,20 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from '@/shared/utils/auth'
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
import { validateGroupId, validateCourse } from '@/shared/utils/validation'
type ResponseData = {
type ResponseData = ApiResponse<{
groups?: GroupsData
success?: boolean
error?: string
}
}>
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method === 'GET') {
// Получение списка групп
const groups = loadGroups()
// Получение списка групп (всегда свежие данные для админ-панели)
clearGroupsCache()
const groups = loadGroups(true)
res.status(200).json({ groups })
return
}
@@ -28,6 +28,11 @@ async function handler(
return
}
if (!validateGroupId(id)) {
res.status(400).json({ error: 'Group ID must contain only lowercase letters, numbers, dashes and underscores' })
return
}
if (!parseId || typeof parseId !== 'number') {
res.status(400).json({ error: 'Parse ID must be a number' })
return
@@ -40,17 +45,11 @@ async function handler(
// Валидация курса (1-5)
const groupCourse = course !== undefined ? Number(course) : 1
if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) {
if (!validateCourse(groupCourse)) {
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
return
}
// Валидация ID (только латинские буквы, цифры, дефисы и подчеркивания)
if (!/^[a-z0-9_-]+$/.test(id)) {
res.status(400).json({ error: 'Group ID must contain only lowercase letters, numbers, dashes and underscores' })
return
}
const groups = loadGroups()
// Проверка на дубликат
@@ -66,23 +65,14 @@ async function handler(
course: groupCourse
}
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
saveGroups(groups)
// Сбрасываем кеш и загружаем свежие данные из БД
clearGroupsCache()
const updatedGroups = loadGroups(true)
res.status(200).json({ success: true, groups: updatedGroups })
return
}
res.status(405).json({ error: 'Method not allowed' })
}
export default function protectedHandler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
return requireAuth(req, res, handler)
}
export default withAuth(handler, ['GET', 'POST'])

View File

@@ -1,12 +1,11 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from '@/shared/utils/auth'
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
import { validateCourse } from '@/shared/utils/validation'
type ResponseData = {
success?: boolean
type ResponseData = ApiResponse<{
groups?: GroupsData
error?: string
}
}>
async function handler(
req: NextApiRequest,
@@ -19,7 +18,8 @@ async function handler(
return
}
const groups = loadGroups()
// Загружаем группы с проверкой кеша
let groups = loadGroups()
if (req.method === 'PUT') {
// Редактирование группы
@@ -40,12 +40,9 @@ async function handler(
return
}
if (course !== undefined) {
const groupCourse = Number(course)
if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) {
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
return
}
if (course !== undefined && !validateCourse(course)) {
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
return
}
// Обновляем группу
@@ -56,13 +53,11 @@ async function handler(
course: course !== undefined ? Number(course) : currentGroup.course
}
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
saveGroups(groups)
// Сбрасываем кеш и загружаем свежие данные из БД
clearGroupsCache()
const updatedGroups = loadGroups(true)
res.status(200).json({ success: true, groups: updatedGroups })
return
}
@@ -75,23 +70,14 @@ async function handler(
delete groups[id]
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
saveGroups(groups)
// Сбрасываем кеш и загружаем свежие данные из БД
clearGroupsCache()
const updatedGroups = loadGroups(true)
res.status(200).json({ success: true, groups: updatedGroups })
return
}
res.status(405).json({ error: 'Method not allowed' })
}
export default function protectedHandler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
return requireAuth(req, res, handler)
}
export default withAuth(handler, ['PUT', 'DELETE'])

View File

@@ -80,7 +80,7 @@ function recordFailedAttempt(ip: string): void {
})
}
export default function handler(
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
@@ -109,7 +109,8 @@ export default function handler(
return
}
if (verifyPassword(password)) {
const isValid = await verifyPassword(password)
if (isValid) {
// Успешный вход - сбрасываем rate limit
rateLimitMap.delete(clientIP)
setSessionCookie(res)

View File

@@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import fs from 'fs'
import path from 'path'
type ResponseData = ApiResponse<{
logs?: string
}>
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
// Путь к файлу логов (в корне проекта)
const logPath = path.join(process.cwd(), 'error.log')
// Проверяем существование файла
if (!fs.existsSync(logPath)) {
res.status(200).json({ success: true, logs: 'Файл логов пуст или не существует.' })
return
}
// Читаем файл
const logs = fs.readFileSync(logPath, 'utf8')
// Если файл пуст
if (!logs || logs.trim().length === 0) {
res.status(200).json({ success: true, logs: 'Файл логов пуст.' })
return
}
res.status(200).json({ success: true, logs })
}
export default withAuth(handler, ['GET'])

View File

@@ -1,33 +1,37 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from '@/shared/utils/auth'
import { loadSettings, saveSettings, AppSettings } from '@/shared/data/settings-loader'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { loadSettings, saveSettings, clearSettingsCache, AppSettings } from '@/shared/data/settings-loader'
type ResponseData = {
type ResponseData = ApiResponse<{
settings?: AppSettings
success?: boolean
error?: string
}
}>
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method === 'GET') {
// Получение настроек
const settings = loadSettings()
// Получение настроек (всегда свежие данные для админ-панели)
clearSettingsCache()
const settings = loadSettings(true)
res.status(200).json({ settings })
return
}
if (req.method === 'PUT') {
// Обновление настроек
const { weekNavigationEnabled, debug } = req.body
const { weekNavigationEnabled, showAddGroupButton, debug } = req.body
if (typeof weekNavigationEnabled !== 'boolean') {
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
return
}
if (showAddGroupButton !== undefined && typeof showAddGroupButton !== 'boolean') {
res.status(400).json({ error: 'showAddGroupButton must be a boolean' })
return
}
// Валидация debug опций (только в dev режиме)
if (debug !== undefined) {
if (typeof debug !== 'object' || debug === null) {
@@ -51,32 +55,20 @@ async function handler(
const settings: AppSettings = {
weekNavigationEnabled,
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : true,
...(debug !== undefined && { debug })
}
try {
saveSettings(settings)
// Сбрасываем кеш и загружаем свежие настройки для подтверждения
const { clearSettingsCache } = await import('@/shared/data/settings-loader')
clearSettingsCache()
const savedSettings = loadSettings()
res.status(200).json({ success: true, settings: savedSettings })
} catch (error) {
console.error('Error saving settings:', error)
res.status(500).json({ error: 'Failed to save settings' })
}
saveSettings(settings)
// Сбрасываем кеш и загружаем свежие настройки для подтверждения
clearSettingsCache()
const savedSettings = loadSettings(true)
res.status(200).json({ success: true, settings: savedSettings })
return
}
res.status(405).json({ error: 'Method not allowed' })
}
export default function protectedHandler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
return requireAuth(req, res, handler)
}
export default withAuth(handler, ['GET', 'PUT'])

View File

@@ -1,13 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
type Data = {
name: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
res.status(200).json({ name: 'John Doe' })
}

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { GetServerSideProps } from 'next'
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
import { loadSettings } from '@/shared/data/settings-loader'
import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
import { Button } from '@/shadcn/ui/button'
import { ThemeSwitcher } from '@/features/theme-switch'
@@ -24,10 +25,11 @@ import { BsTelegram } from 'react-icons/bs'
type HomePageProps = {
groups: GroupsData
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
showAddGroupButton: boolean
}
export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set([1]))
export default function HomePage({ groups, groupsByCourse, showAddGroupButton }: HomePageProps) {
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
// Подсчитываем смещения для каждого курса для последовательной анимации
@@ -148,28 +150,30 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
)}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
>
<Button
variant="secondary"
onClick={() => setAddGroupDialogOpen(true)}
className="gap-2"
{showAddGroupButton && (
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
>
<MdAdd className="h-4 w-4" />
Добавить группу
</Button>
</div>
<Button
variant="secondary"
onClick={() => setAddGroupDialogOpen(true)}
className="gap-2"
>
<MdAdd className="h-4 w-4" />
Добавить группу
</Button>
</div>
)}
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.08}s` } as React.CSSProperties}
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.08 : 0.05)}s` } as React.CSSProperties}
>
<ThemeSwitcher />
</div>
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.11}s` } as React.CSSProperties}
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.11 : 0.08)}s` } as React.CSSProperties}
>
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
<Button variant="outline" className="gap-2">
@@ -208,7 +212,9 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
}
export const getServerSideProps: GetServerSideProps<HomePageProps> = async () => {
// Используем кеш (обновляется каждую минуту автоматически)
const groups = loadGroups()
const settings = loadSettings()
// Группируем группы по курсам
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}
@@ -229,7 +235,8 @@ export const getServerSideProps: GetServerSideProps<HomePageProps> = async () =>
return {
props: {
groups,
groupsByCourse
groupsByCourse,
showAddGroupButton: settings.showAddGroupButton ?? true
}
}
}

View File

@@ -4,6 +4,7 @@ import { loadGroups } from '@/shared/data/groups-loader'
import { SITEMAP_SITE_URL } from '@/shared/constants/urls'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
// Используем кеш (обновляется каждую минуту автоматически)
const groups = loadGroups()
const fields = Object.keys(groups).map<ISitemapField>(group => (
{

View File

@@ -177,7 +177,7 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
}}
>
<p className="text-sm text-foreground text-center">
Не удается получить актуальное расписание с официального сайта. Возможно, сервер временно недоступен. Будут показаны данные из кэша. Попробуйте обновить страницу позже.
Продолжаем попытку получения расписания с официального сайта. Возможно, сервер временно недоступен и будут показаны данные из кэша. Пожалуйста, подождите...
</p>
</div>
)}

389
src/shared/data/database.ts Normal file
View File

@@ -0,0 +1,389 @@
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import bcrypt from 'bcrypt'
import type { GroupInfo, GroupsData } from './groups-loader'
import type { AppSettings } from './settings-loader'
// Путь к файлу базы данных
const DB_PATH = path.join(process.cwd(), 'data', 'schedule-app.db')
const DEFAULT_PASSWORD = 'ksadmin'
// Создаем директорию data, если её нет
const dbDir = path.dirname(DB_PATH)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
// Инициализация базы данных
let db: Database.Database | null = null
function getDatabase(): Database.Database {
if (db) {
return db
}
db = new Database(DB_PATH)
// Применяем современные настройки SQLite
db.pragma('journal_mode = WAL') // Write-Ahead Logging для лучшей производительности
db.pragma('synchronous = NORMAL') // Баланс между производительностью и надежностью
db.pragma('foreign_keys = ON') // Включение проверки внешних ключей
db.pragma('busy_timeout = 5000') // Таймаут для ожидания блокировок (5 секунд)
db.pragma('temp_store = MEMORY') // Хранение временных данных в памяти
db.pragma('mmap_size = 268435456') // Memory-mapped I/O (256MB)
db.pragma('cache_size = -64000') // Размер кеша в страницах (64MB)
// Создаем таблицы, если их нет
initializeTables()
// Выполняем миграцию данных из JSON, если БД пустая
migrateFromJSON()
return db
}
function initializeTables(): void {
const database = getDatabase()
// Таблица групп
database.exec(`
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
parseId INTEGER NOT NULL,
name TEXT NOT NULL,
course INTEGER NOT NULL CHECK(course >= 1 AND course <= 5)
)
`)
// Таблица настроек
database.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`)
// Таблица админ пароля
database.exec(`
CREATE TABLE IF NOT EXISTS admin_password (
id INTEGER PRIMARY KEY CHECK(id = 1),
password_hash TEXT NOT NULL
)
`)
}
// ==================== Функции для работы с группами ====================
export function getAllGroups(): GroupsData {
const database = getDatabase()
const rows = database.prepare('SELECT id, parseId, name, course FROM groups').all() as Array<{
id: string
parseId: number
name: string
course: number
}>
const groups: GroupsData = {}
for (const row of rows) {
groups[row.id] = {
parseId: row.parseId,
name: row.name,
course: row.course
}
}
return groups
}
export function getGroup(id: string): GroupInfo | null {
const database = getDatabase()
const row = database.prepare('SELECT parseId, name, course FROM groups WHERE id = ?').get(id) as {
parseId: number
name: string
course: number
} | undefined
if (!row) {
return null
}
return {
parseId: row.parseId,
name: row.name,
course: row.course
}
}
export function createGroup(id: string, group: GroupInfo): void {
const database = getDatabase()
database
.prepare('INSERT INTO groups (id, parseId, name, course) VALUES (?, ?, ?, ?)')
.run(id, group.parseId, group.name, group.course)
}
export function updateGroup(id: string, group: Partial<GroupInfo>): void {
const database = getDatabase()
const existing = getGroup(id)
if (!existing) {
throw new Error(`Group with id ${id} not found`)
}
const updated: GroupInfo = {
parseId: group.parseId !== undefined ? group.parseId : existing.parseId,
name: group.name !== undefined ? group.name : existing.name,
course: group.course !== undefined ? group.course : existing.course
}
database
.prepare('UPDATE groups SET parseId = ?, name = ?, course = ? WHERE id = ?')
.run(updated.parseId, updated.name, updated.course, id)
}
export function deleteGroup(id: string): void {
const database = getDatabase()
database.prepare('DELETE FROM groups WHERE id = ?').run(id)
}
// ==================== Функции для работы с настройками ====================
export function getSettings(): AppSettings {
const database = getDatabase()
const row = database.prepare('SELECT value FROM settings WHERE key = ?').get('app') as {
value: string
} | undefined
if (!row) {
// Возвращаем настройки по умолчанию
const defaultSettings: AppSettings = {
weekNavigationEnabled: false,
showAddGroupButton: true,
debug: {
forceCache: false,
forceEmpty: false,
forceError: false,
forceTimeout: false,
showCacheInfo: false
}
}
return defaultSettings
}
try {
const settings = JSON.parse(row.value) as Partial<AppSettings>
// Всегда добавляем дефолтные debug настройки (они не хранятся в БД)
// И добавляем отсутствующие поля для обратной совместимости
return {
weekNavigationEnabled: settings.weekNavigationEnabled ?? false,
showAddGroupButton: settings.showAddGroupButton ?? true,
...settings,
debug: {
forceCache: false,
forceEmpty: false,
forceError: false,
forceTimeout: false,
showCacheInfo: false
}
}
} catch (error) {
console.error('Error parsing settings from database:', error)
const defaultSettings: AppSettings = {
weekNavigationEnabled: false,
showAddGroupButton: true,
debug: {
forceCache: false,
forceEmpty: false,
forceError: false,
forceTimeout: false,
showCacheInfo: false
}
}
return defaultSettings
}
}
export function updateSettings(settings: AppSettings): void {
const database = getDatabase()
const defaultSettings: AppSettings = {
weekNavigationEnabled: false,
showAddGroupButton: true,
debug: {
forceCache: false,
forceEmpty: false,
forceError: false,
forceTimeout: false,
showCacheInfo: false
}
}
// Исключаем debug из настроек перед сохранением в БД
const { debug, ...settingsWithoutDebug } = settings
const mergedSettings: AppSettings = {
...defaultSettings,
...settingsWithoutDebug
// debug намеренно не сохраняется в БД
}
database
.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run('app', JSON.stringify(mergedSettings))
}
// ==================== Функции для работы с паролем ====================
export function getPasswordHash(): string | null {
const database = getDatabase()
const row = database.prepare('SELECT password_hash FROM admin_password WHERE id = 1').get() as {
password_hash: string
} | undefined
return row?.password_hash || null
}
export function setPasswordHash(hash: string): void {
const database = getDatabase()
database.prepare('INSERT OR REPLACE INTO admin_password (id, password_hash) VALUES (1, ?)').run(hash)
}
export async function verifyPassword(password: string): Promise<boolean> {
const hash = getPasswordHash()
if (!hash) {
return false
}
try {
return await bcrypt.compare(password, hash)
} catch (error) {
console.error('Error verifying password:', error)
return false
}
}
export async function changePassword(oldPassword: string, newPassword: string): Promise<boolean> {
// Проверяем старый пароль
const isValid = await verifyPassword(oldPassword)
if (!isValid) {
return false
}
// Хэшируем новый пароль
const saltRounds = 10
const newHash = await bcrypt.hash(newPassword, saltRounds)
// Сохраняем новый хэш
setPasswordHash(newHash)
return true
}
export async function isDefaultPassword(): Promise<boolean> {
const hash = getPasswordHash()
if (!hash) {
return true // Если пароля нет, считаем что используется дефолтный
}
// Проверяем, соответствует ли хэш дефолтному паролю
return await bcrypt.compare(DEFAULT_PASSWORD, hash)
}
// ==================== Миграция данных из JSON ====================
function migrateFromJSON(): void {
const database = getDatabase()
// Проверяем, есть ли уже данные в БД
const groupsCount = database.prepare('SELECT COUNT(*) as count FROM groups').get() as { count: number }
const settingsCount = database.prepare('SELECT COUNT(*) as count FROM settings').get() as { count: number }
const passwordExists = database.prepare('SELECT COUNT(*) as count FROM admin_password WHERE id = 1').get() as {
count: number
}
// Мигрируем группы из JSON, если БД пустая
if (groupsCount.count === 0) {
try {
const possiblePaths = [
path.join(process.cwd(), 'src/shared/data/groups.json'),
path.join(process.cwd(), '.next/standalone/src/shared/data/groups.json'),
path.join(process.cwd(), 'groups.json')
]
for (const filePath of possiblePaths) {
if (fs.existsSync(filePath)) {
const fileContents = fs.readFileSync(filePath, 'utf8')
const rawGroups = JSON.parse(fileContents) as GroupsData | { [key: string]: [number, string] | GroupInfo }
// Мигрируем данные
const insertStmt = database.prepare('INSERT INTO groups (id, parseId, name, course) VALUES (?, ?, ?, ?)')
const transaction = database.transaction((groups: GroupsData) => {
for (const [id, data] of Object.entries(groups)) {
let group: GroupInfo
if (Array.isArray(data) && data.length === 2 && typeof data[0] === 'number' && typeof data[1] === 'string') {
// Старый формат [parseId, name]
group = {
parseId: data[0],
name: data[1],
course: 1
}
} else if (typeof data === 'object' && 'parseId' in data && 'name' in data) {
group = data as GroupInfo
} else {
continue
}
insertStmt.run(id, group.parseId, group.name, group.course)
}
})
transaction(rawGroups as GroupsData)
console.log('Groups migrated from JSON to database')
break
}
}
} catch (error) {
console.error('Error migrating groups from JSON:', error)
}
}
// Мигрируем настройки из JSON, если БД пустая
if (settingsCount.count === 0) {
try {
const possiblePaths = [
path.join(process.cwd(), 'src/shared/data/settings.json'),
path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'),
path.join(process.cwd(), 'settings.json')
]
for (const filePath of possiblePaths) {
if (fs.existsSync(filePath)) {
const fileContents = fs.readFileSync(filePath, 'utf8')
const settings = JSON.parse(fileContents) as AppSettings
updateSettings(settings)
console.log('Settings migrated from JSON to database')
break
}
}
} catch (error) {
console.error('Error migrating settings from JSON:', error)
}
}
// Инициализируем дефолтный пароль, если его нет
if (passwordExists.count === 0) {
const saltRounds = 10
try {
// Используем синхронную версию для инициализации при старте
const hash = bcrypt.hashSync(DEFAULT_PASSWORD, saltRounds)
setPasswordHash(hash)
console.log('Default password "ksadmin" initialized')
} catch (err) {
console.error('Error hashing default password:', err)
}
}
}
// Экспортируем функцию для закрытия соединения (полезно для тестов)
export function closeDatabase(): void {
if (db) {
db.close()
db = null
}
}

View File

@@ -1,5 +1,4 @@
import fs from 'fs'
import path from 'path'
import { getAllGroups as getAllGroupsFromDB, createGroup, updateGroup, deleteGroup, getGroup } from './database'
export type GroupInfo = {
parseId: number
@@ -9,120 +8,74 @@ export type GroupInfo = {
export type GroupsData = { [group: string]: GroupInfo }
// Старый формат для миграции
type OldGroupsData = { [group: string]: [number, string] | GroupInfo }
let cachedGroups: GroupsData | null = null
let cacheTimestamp: number = 0
const CACHE_TTL_MS = 1000 * 60 // 1 минута
/**
* Мигрирует старый формат данных в новый
* Загружает группы из базы данных
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости
*/
function migrateGroups(oldGroups: OldGroupsData): GroupsData {
const migrated: GroupsData = {}
export function loadGroups(forceRefresh: boolean = false): GroupsData {
const now = Date.now()
const isCacheValid = cachedGroups !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
for (const [id, data] of Object.entries(oldGroups)) {
// Проверяем, является ли это старым форматом [parseId, name]
if (Array.isArray(data) && data.length === 2 && typeof data[0] === 'number' && typeof data[1] === 'string') {
// Старый формат - мигрируем
migrated[id] = {
parseId: data[0],
name: data[1],
course: 1 // По умолчанию курс 1
}
} else if (typeof data === 'object' && 'parseId' in data && 'name' in data) {
// Уже новый формат
migrated[id] = data as GroupInfo
}
}
return migrated
}
/**
* Загружает группы из JSON файла
* Использует кеш для оптимизации в production
* Автоматически мигрирует старый формат в новый
*/
export function loadGroups(): GroupsData {
if (cachedGroups) {
if (isCacheValid && cachedGroups !== null) {
return cachedGroups
}
// В production Next.js может использовать другую структуру директорий
// Пробуем несколько путей
const possiblePaths = [
path.join(process.cwd(), 'src/shared/data/groups.json'),
path.join(process.cwd(), '.next/standalone/src/shared/data/groups.json'),
path.join(process.cwd(), 'groups.json'),
]
for (const filePath of possiblePaths) {
try {
if (fs.existsSync(filePath)) {
const fileContents = fs.readFileSync(filePath, 'utf8')
const rawGroups = JSON.parse(fileContents) as OldGroupsData
// Проверяем, нужна ли миграция
const needsMigration = Object.values(rawGroups).some(
data => Array.isArray(data) && data.length === 2
)
let groups: GroupsData
if (needsMigration) {
// Мигрируем старый формат
groups = migrateGroups(rawGroups)
// Сохраняем мигрированные данные
const mainPath = path.join(process.cwd(), 'src/shared/data/groups.json')
if (filePath === mainPath) {
// Сохраняем только если это основной файл
fs.writeFileSync(mainPath, JSON.stringify(groups, null, 2), 'utf8')
console.log('Groups data migrated to new format')
}
} else {
groups = rawGroups as GroupsData
}
cachedGroups = groups
return groups
}
} catch (error) {
// Пробуем следующий путь
continue
}
try {
cachedGroups = getAllGroupsFromDB()
cacheTimestamp = now
return cachedGroups
} catch (error) {
console.error('Error loading groups from database:', error)
// Fallback к пустому объекту
return {}
}
console.error('Error loading groups.json: file not found in any of the expected locations')
// Fallback к пустому объекту
return {}
}
/**
* Сохраняет группы в JSON файл
* Сохраняет группы в базу данных
*/
export function saveGroups(groups: GroupsData): void {
// Всегда сохраняем в основной путь
const filePath = path.join(process.cwd(), 'src/shared/data/groups.json')
try {
// Создаем директорию, если её нет
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
const existingGroups = getAllGroupsFromDB()
fs.writeFileSync(filePath, JSON.stringify(groups, null, 2), 'utf8')
// Сбрасываем кеш
// Определяем, какие группы нужно добавить, обновить или удалить
const existingIds = new Set(Object.keys(existingGroups))
const newIds = new Set(Object.keys(groups))
// Добавляем или обновляем группы
for (const [id, group] of Object.entries(groups)) {
if (existingIds.has(id)) {
updateGroup(id, group)
} else {
createGroup(id, group)
}
}
// Удаляем группы, которых больше нет
for (const id of existingIds) {
if (!newIds.has(id)) {
deleteGroup(id)
}
}
// Сбрасываем кеш и timestamp
cachedGroups = null
cacheTimestamp = 0
} catch (error) {
console.error('Error saving groups.json:', error)
console.error('Error saving groups to database:', error)
throw new Error('Failed to save groups')
}
}
/**
* Сбрасывает кеш групп (полезно после обновления файла)
* Сбрасывает кеш групп (полезно после обновления)
*/
export function clearGroupsCache(): void {
cachedGroups = null
cacheTimestamp = 0
}

View File

@@ -1,27 +0,0 @@
{
"ib4k": {
"parseId": 138,
"name": "ИБ-4к",
"course": 4
},
"ib5": {
"parseId": 144,
"name": "ИБ-5",
"course": 3
},
"ib6": {
"parseId": 145,
"name": "ИБ-6",
"course": 3
},
"ib7k": {
"parseId": 172,
"name": "ИБ-7к",
"course": 3
},
"ib3": {
"parseId": 123,
"name": "ИБ-3",
"course": 4
}
}

View File

@@ -1,18 +0,0 @@
// Загружаем группы из JSON файла только на сервере
// На клиенте будет пустой объект, группы должны передаваться через props
let groups: { [group: string]: [number, string] } = {}
// Используем условный require только на сервере для избежания включения fs в клиентскую сборку
if (typeof window === 'undefined') {
// Серверная сторона
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const groupsLoader = require('./groups-loader')
groups = groupsLoader.loadGroups()
} catch (error) {
console.error('Error loading groups:', error)
groups = {}
}
}
export { groups }

View File

@@ -1,8 +1,8 @@
import fs from 'fs'
import path from 'path'
import { getSettings as getSettingsFromDB, updateSettings as updateSettingsInDB } from './database'
export type AppSettings = {
weekNavigationEnabled: boolean
showAddGroupButton: boolean
debug?: {
forceCache?: boolean
forceEmpty?: boolean
@@ -13,190 +13,64 @@ export type AppSettings = {
}
let cachedSettings: AppSettings | null = null
let cachedSettingsPath: string | null = null
let cachedSettingsMtime: number | null = null
const defaultSettings: AppSettings = {
weekNavigationEnabled: true,
debug: {
forceCache: false,
forceEmpty: false,
forceError: false,
forceTimeout: false,
showCacheInfo: false
}
}
let cacheTimestamp: number = 0
const CACHE_TTL_MS = 1000 * 60 // 1 минута
/**
* Загружает настройки из JSON файла
* Проверяет время модификации файла для инвалидации кеша
* Загружает настройки из базы данных
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости
*/
export function loadSettings(): AppSettings {
// В production Next.js может использовать другую структуру директорий
// Пробуем несколько путей
const possiblePaths = [
path.join(process.cwd(), 'src/shared/data/settings.json'),
path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'),
path.join(process.cwd(), 'settings.json'),
]
export function loadSettings(forceRefresh: boolean = false): AppSettings {
const now = Date.now()
const isCacheValid = cachedSettings !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
// Ищем существующий файл
let foundPath: string | null = null
for (const filePath of possiblePaths) {
if (fs.existsSync(filePath)) {
foundPath = filePath
break
}
if (isCacheValid && cachedSettings !== null) {
return cachedSettings
}
// Если файл найден, проверяем, изменился ли он
if (foundPath) {
try {
const stats = fs.statSync(foundPath)
const mtime = stats.mtimeMs
// Если файл изменился или путь изменился, сбрасываем кеш
if (cachedSettings && (cachedSettingsPath !== foundPath || cachedSettingsMtime !== mtime)) {
cachedSettings = null
cachedSettingsPath = null
cachedSettingsMtime = null
}
// Если кеш валиден, возвращаем его
if (cachedSettings && cachedSettingsPath === foundPath && cachedSettingsMtime === mtime) {
return cachedSettings
}
// Загружаем файл заново
const fileContents = fs.readFileSync(foundPath, 'utf8')
const settings = JSON.parse(fileContents) as AppSettings
// Убеждаемся, что все обязательные поля присутствуют
const mergedSettings: AppSettings = {
...defaultSettings,
...settings,
debug: {
...defaultSettings.debug,
...settings.debug
}
}
cachedSettings = mergedSettings
cachedSettingsPath = foundPath
cachedSettingsMtime = mtime
return mergedSettings
} catch (error) {
console.error('Error reading settings.json:', error)
// Продолжаем дальше, чтобы создать файл с настройками по умолчанию
}
}
// Если файл не найден, создаем его с настройками по умолчанию
const mainPath = path.join(process.cwd(), 'src/shared/data/settings.json')
try {
// Создаем директорию, если её нет
const dir = path.dirname(mainPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(mainPath, JSON.stringify(defaultSettings, null, 2), 'utf8')
const stats = fs.statSync(mainPath)
cachedSettings = defaultSettings
cachedSettingsPath = mainPath
cachedSettingsMtime = stats.mtimeMs
return defaultSettings
cachedSettings = getSettingsFromDB()
cacheTimestamp = now
return cachedSettings
} catch (error) {
console.error('Error creating settings.json:', error)
console.error('Error loading settings from database:', error)
// Возвращаем настройки по умолчанию
const defaultSettings: AppSettings = {
weekNavigationEnabled: false,
showAddGroupButton: true,
debug: {
forceCache: false,
forceEmpty: false,
forceError: false,
forceTimeout: false,
showCacheInfo: false
}
}
return defaultSettings
}
}
/**
* Сохраняет настройки в JSON файл
* Сохраняет настройки в базу данных
*/
export function saveSettings(settings: AppSettings): void {
// Сначала пытаемся найти существующий файл
const possiblePaths = [
path.join(process.cwd(), 'src/shared/data/settings.json'),
path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'),
path.join(process.cwd(), 'settings.json'),
]
// Объединяем с настройками по умолчанию для сохранения всех полей
const mergedSettings: AppSettings = {
...defaultSettings,
...settings,
debug: {
...defaultSettings.debug,
...settings.debug
}
}
// Ищем существующий файл
let targetPath: string | null = null
for (const filePath of possiblePaths) {
if (fs.existsSync(filePath)) {
targetPath = filePath
break
}
}
// Если файл не найден, используем основной путь
if (!targetPath) {
targetPath = path.join(process.cwd(), 'src/shared/data/settings.json')
}
try {
// Создаем директорию, если её нет
const dir = path.dirname(targetPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
// Сохраняем файл
fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
// Обновляем кеш с новыми метаданными
try {
const stats = fs.statSync(targetPath)
cachedSettings = mergedSettings
cachedSettingsPath = targetPath
cachedSettingsMtime = stats.mtimeMs
} catch (error) {
// Если не удалось получить stats, просто обновляем кеш
cachedSettings = mergedSettings
cachedSettingsPath = targetPath
cachedSettingsMtime = null
}
// Также сохраняем в другие возможные пути для совместимости (если они существуют)
for (const filePath of possiblePaths) {
if (filePath !== targetPath && fs.existsSync(path.dirname(filePath))) {
try {
fs.writeFileSync(filePath, JSON.stringify(mergedSettings, null, 2), 'utf8')
} catch (error) {
// Игнорируем ошибки при сохранении в дополнительные пути
}
}
}
updateSettingsInDB(settings)
// Сбрасываем кеш и timestamp
cachedSettings = null
cacheTimestamp = 0
} catch (error) {
console.error('Error saving settings.json:', error)
console.error('Error saving settings to database:', error)
throw new Error('Failed to save settings')
}
}
/**
* Сбрасывает кеш настроек (полезно после обновления файла)
* Сбрасывает кеш настроек (полезно после обновления)
*/
export function clearSettingsCache(): void {
cachedSettings = null
cachedSettingsPath = null
cachedSettingsMtime = null
cacheTimestamp = 0
}

View File

@@ -0,0 +1,65 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from './auth'
export type ApiHandler<T = any> = (
req: NextApiRequest,
res: NextApiResponse<T>
) => void | Promise<void>
export type ApiResponse<T = Record<string, never>> = {
success?: boolean
error?: string
} & (T extends Record<string, never> ? {} : Partial<T>)
/**
* Общий wrapper для защищенных API роутов
* Автоматически проверяет авторизацию и обрабатывает ошибки
*/
export function withAuth<T = any>(
handler: ApiHandler<ApiResponse<T>>,
allowedMethods: string[] = ['GET', 'POST', 'PUT', 'DELETE']
) {
return async (req: NextApiRequest, res: NextApiResponse<ApiResponse<T>>) => {
// Проверка метода
if (!allowedMethods.includes(req.method || '')) {
res.status(405).json({ error: 'Method not allowed' } as ApiResponse<T>)
return
}
// Проверка авторизации
return requireAuth(req, res, async (req, res) => {
try {
await handler(req, res)
} catch (error) {
console.error('API Error:', error)
const errorMessage = error instanceof Error ? error.message : 'Internal server error'
res.status(500).json({ error: errorMessage } as ApiResponse<T>)
}
})
}
}
/**
* Общий wrapper для незащищенных API роутов
*/
export function withMethods<T = any>(
handler: ApiHandler<ApiResponse<T>>,
allowedMethods: string[] = ['GET', 'POST', 'PUT', 'DELETE']
) {
return async (req: NextApiRequest, res: NextApiResponse<ApiResponse<T>>) => {
// Проверка метода
if (!allowedMethods.includes(req.method || '')) {
res.status(405).json({ error: 'Method not allowed' } as ApiResponse<T>)
return
}
try {
await handler(req, res)
} catch (error) {
console.error('API Error:', error)
const errorMessage = error instanceof Error ? error.message : 'Internal server error'
res.status(500).json({ error: errorMessage } as ApiResponse<T>)
}
}
}

View File

@@ -1,5 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next'
import crypto from 'crypto'
import { verifyPassword as verifyPasswordFromDB } from '@/shared/data/database'
const SESSION_COOKIE_NAME = 'admin_session'
const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET
@@ -23,29 +24,13 @@ function getSessionSecret(): string {
/**
* Проверяет пароль администратора
* Использует timing-safe сравнение для защиты от timing attacks
* Использует bcrypt для проверки хэшированного пароля из БД
*/
export function verifyPassword(password: string): boolean {
const adminPassword = process.env.ADMIN_PASSWORD
if (!adminPassword) {
console.error('ADMIN_PASSWORD is not set')
return false
}
// Используем timing-safe сравнение для защиты от timing attacks
if (password.length !== adminPassword.length) {
return false
}
export async function verifyPassword(password: string): Promise<boolean> {
try {
const passwordBuffer = Buffer.from(password, 'utf8')
const adminPasswordBuffer = Buffer.from(adminPassword, 'utf8')
// Buffer в Node.js наследуется от Uint8Array и совместим с ArrayBufferView
return crypto.timingSafeEqual(
passwordBuffer as Uint8Array,
adminPasswordBuffer as Uint8Array
)
} catch {
return await verifyPasswordFromDB(password)
} catch (error) {
console.error('Error verifying password:', error)
return false
}
}
@@ -146,6 +131,10 @@ export function requireAuth(
res.status(401).json({ error: 'Unauthorized' })
return
}
return handler(req, res)
const result = handler(req, res)
// Если handler возвращает Promise, возвращаем его
if (result instanceof Promise) {
return result
}
}

View File

@@ -0,0 +1,25 @@
/**
* Валидация курса (1-5)
*/
export function validateCourse(course: unknown): course is number {
if (course === undefined) return false
const courseNum = Number(course)
return Number.isInteger(courseNum) && courseNum >= 1 && courseNum <= 5
}
/**
* Валидация ID группы (только латинские буквы, цифры, дефисы и подчеркивания)
*/
export function validateGroupId(id: unknown): id is string {
if (!id || typeof id !== 'string') return false
return /^[a-z0-9_-]+$/.test(id)
}
/**
* Валидация пароля (минимум 8 символов)
*/
export function validatePassword(password: unknown): password is string {
if (!password || typeof password !== 'string') return false
return password.length >= 8
}