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:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -40,3 +40,12 @@ next-env.d.ts
|
|||||||
|
|
||||||
# dependency hash (installation-specific)
|
# dependency hash (installation-specific)
|
||||||
.dependencies.hash
|
.dependencies.hash
|
||||||
|
|
||||||
|
# error logs
|
||||||
|
error.log
|
||||||
|
|
||||||
|
# database files
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
107
README.md
107
README.md
@@ -15,11 +15,43 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support.
|
|||||||
- @shadcn/ui components (built with Radix UI)
|
- @shadcn/ui components (built with Radix UI)
|
||||||
- JSDOM for parsing scraped pages, rehydration strategy for cache
|
- JSDOM for parsing scraped pages, rehydration strategy for cache
|
||||||
- TypeScript 5.9.3 with types for each package
|
- TypeScript 5.9.3 with types for each package
|
||||||
|
- SQLite database (better-sqlite3) for storing groups and settings
|
||||||
|
- bcrypt for secure password hashing
|
||||||
- Telegram Bot API (via [node-telegram-bot-api]) for parsing failure notifications
|
- Telegram Bot API (via [node-telegram-bot-api]) for parsing failure notifications
|
||||||
- Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c)
|
- Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c)
|
||||||
- Accessibility & tab navigation support
|
- Accessibility & tab navigation support
|
||||||
- Dark theme with automatic switching based on system settings
|
- Dark theme with automatic switching based on system settings
|
||||||
- Admin panel for managing groups and settings
|
- Admin panel for managing groups and settings
|
||||||
|
- Optimized code structure with reusable utilities and components
|
||||||
|
|
||||||
|
## Architecture & Code Organization
|
||||||
|
|
||||||
|
The project follows a feature-sliced design pattern with clear separation of concerns:
|
||||||
|
|
||||||
|
**Code Structure:**
|
||||||
|
- **Shared utilities** (`src/shared/utils/`):
|
||||||
|
- `auth.ts` - Authentication and session management
|
||||||
|
- `api-wrapper.ts` - Reusable API route wrappers with auth and error handling
|
||||||
|
- `validation.ts` - Centralized validation functions
|
||||||
|
- **Data layer** (`src/shared/data/`):
|
||||||
|
- SQLite database for persistent storage
|
||||||
|
- Data loaders with caching (1-minute TTL)
|
||||||
|
- Automatic cache invalidation on updates
|
||||||
|
- **API routes** (`src/pages/api/admin/`):
|
||||||
|
- Unified authentication via `withAuth` wrapper
|
||||||
|
- Consistent error handling
|
||||||
|
- Method validation
|
||||||
|
- **Components**:
|
||||||
|
- Reusable UI components in `src/shared/ui/`
|
||||||
|
- Feature-specific components in `src/features/`
|
||||||
|
- Complex widgets in `src/widgets/`
|
||||||
|
|
||||||
|
**Optimizations:**
|
||||||
|
- Toggle switch component reused across admin panel
|
||||||
|
- Unified data loading functions
|
||||||
|
- Centralized validation logic
|
||||||
|
- Consistent API error handling
|
||||||
|
- Optimized cache management
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
@@ -32,33 +64,52 @@ Workaround: Locate to next week, then enter previous twice.
|
|||||||
```
|
```
|
||||||
kspguti-schedule/
|
kspguti-schedule/
|
||||||
├── src/ # Source code
|
├── src/ # Source code
|
||||||
│ ├── app/ # App router (Next.js 13+)
|
│ ├── app/ # App-level code
|
||||||
│ │ ├── agregator/ # Schedule fetching logic
|
│ │ ├── agregator/ # Schedule fetching logic
|
||||||
│ │ ├── parser/ # HTML parsing for schedule
|
│ │ ├── parser/ # HTML parsing for schedule
|
||||||
│ │ └── utils/ # App-level utilities
|
│ │ └── utils/ # App-level utilities
|
||||||
│ ├── pages/ # Pages router (Next.js)
|
│ ├── pages/ # Pages router (Next.js)
|
||||||
│ │ ├── api/ # API routes
|
│ │ ├── api/ # API routes
|
||||||
│ │ │ └── admin/ # Admin panel API endpoints
|
│ │ │ └── admin/ # Admin panel API endpoints
|
||||||
|
│ │ │ ├── groups.ts # Groups CRUD operations
|
||||||
|
│ │ │ ├── settings.ts # Settings management
|
||||||
|
│ │ │ ├── login.ts # Authentication
|
||||||
|
│ │ │ ├── check-auth.ts # Auth verification
|
||||||
|
│ │ │ ├── change-password.ts # Password change
|
||||||
|
│ │ │ └── logs.ts # Error logs viewer
|
||||||
│ │ ├── [group].tsx # Dynamic group schedule page
|
│ │ ├── [group].tsx # Dynamic group schedule page
|
||||||
│ │ ├── admin.tsx # Admin panel page
|
│ │ ├── admin.tsx # Admin panel page
|
||||||
│ │ └── index.tsx # Home page
|
│ │ └── index.tsx # Home page
|
||||||
│ ├── entities/ # Entities
|
│ ├── entities/ # Business entities
|
||||||
│ ├── features/ # Feature modules
|
│ ├── features/ # Feature modules
|
||||||
|
│ │ ├── add-group/ # Add group feature
|
||||||
|
│ │ └── theme-switch/ # Theme switcher
|
||||||
│ ├── shared/ # Shared code
|
│ ├── shared/ # Shared code
|
||||||
│ │ ├── constants/ # App constants
|
│ │ ├── constants/ # App constants
|
||||||
│ │ ├── context/ # React contexts
|
│ │ ├── context/ # React contexts
|
||||||
│ │ ├── data/ # Data loaders and JSON files
|
│ │ ├── data/ # Data layer
|
||||||
|
│ │ │ ├── database.ts # SQLite database operations
|
||||||
|
│ │ │ ├── groups-loader.ts # Groups data loader
|
||||||
|
│ │ │ └── settings-loader.ts # Settings data loader
|
||||||
│ │ ├── model/ # Data models
|
│ │ ├── model/ # Data models
|
||||||
│ │ ├── providers/ # React providers
|
│ │ ├── providers/ # React providers
|
||||||
│ │ ├── ui/ # Shared UI components
|
│ │ ├── ui/ # Shared UI components
|
||||||
│ │ └── utils/ # Utility functions
|
│ │ └── utils/ # Utility functions
|
||||||
|
│ │ ├── auth.ts # Authentication utilities
|
||||||
|
│ │ ├── api-wrapper.ts # API route wrappers
|
||||||
|
│ │ └── validation.ts # Validation utilities
|
||||||
│ ├── shadcn/ # shadcn/ui components
|
│ ├── shadcn/ # shadcn/ui components
|
||||||
│ └── widgets/ # Complex UI widgets
|
│ └── widgets/ # Complex UI widgets
|
||||||
|
│ ├── navbar/ # Navigation bar
|
||||||
|
│ └── schedule/ # Schedule display components
|
||||||
├── public/ # Static assets
|
├── public/ # Static assets
|
||||||
│ └── teachers/ # Teacher photos
|
│ └── teachers/ # Teacher photos
|
||||||
├── old/ # Deprecated files (see old/README.md)
|
├── old/ # Deprecated/archived files
|
||||||
|
│ ├── data/ # Old data files (groups.ts, groups.json)
|
||||||
|
│ └── README.md # Documentation for old files
|
||||||
├── scripts/ # Deployment scripts
|
├── scripts/ # Deployment scripts
|
||||||
├── systemd/ # Systemd service file
|
├── systemd/ # Systemd service file
|
||||||
|
├── data/ # SQLite database files
|
||||||
├── components.json # shadcn/ui config
|
├── components.json # shadcn/ui config
|
||||||
├── docker-compose.yml # Docker Compose config
|
├── docker-compose.yml # Docker Compose config
|
||||||
├── Dockerfile # Docker image definition
|
├── Dockerfile # Docker image definition
|
||||||
@@ -91,13 +142,40 @@ The application includes an admin panel for managing groups and application sett
|
|||||||
**Features:**
|
**Features:**
|
||||||
- Manage groups (add, edit, delete)
|
- Manage groups (add, edit, delete)
|
||||||
- Configure application settings (e.g., week navigation)
|
- Configure application settings (e.g., week navigation)
|
||||||
- Password-protected access with session management
|
- Password-protected access with session management (24-hour sessions)
|
||||||
|
- Change admin password from the admin panel
|
||||||
|
- View error logs
|
||||||
|
- Debug options (development mode only)
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Passwords are hashed using bcrypt
|
||||||
|
- Session-based authentication with secure cookies
|
||||||
|
- Rate limiting on login attempts (5 attempts per 15 minutes)
|
||||||
|
- Default password warning if not changed
|
||||||
|
|
||||||
|
**Default password:**
|
||||||
|
- On first launch, the default password is `ksadmin`
|
||||||
|
- ⚠️ **Important:** Change the default password immediately after first login for security!
|
||||||
|
- The admin panel will show a warning if the default password is still in use
|
||||||
|
|
||||||
**Environment variables for admin panel:**
|
**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')
|
- `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, defaults to 'change-me-in-production')
|
||||||
|
- `ADMIN_PASSWORD` - Initial admin password (optional, defaults to 'ksadmin')
|
||||||
|
|
||||||
⚠️ **Important:** Always set a strong `ADMIN_PASSWORD` and `ADMIN_SESSION_SECRET` in production!
|
⚠️ **Important:** Always set a strong `ADMIN_SESSION_SECRET` in production!
|
||||||
|
|
||||||
|
**API Endpoints:**
|
||||||
|
- `GET /api/admin/groups` - Get all groups
|
||||||
|
- `POST /api/admin/groups` - Create new group
|
||||||
|
- `PUT /api/admin/groups/[id]` - Update group
|
||||||
|
- `DELETE /api/admin/groups/[id]` - Delete group
|
||||||
|
- `GET /api/admin/settings` - Get settings
|
||||||
|
- `PUT /api/admin/settings` - Update settings
|
||||||
|
- `POST /api/admin/login` - Authenticate
|
||||||
|
- `GET /api/admin/check-auth` - Check authentication status
|
||||||
|
- `POST /api/admin/logout` - Logout
|
||||||
|
- `POST /api/admin/change-password` - Change password
|
||||||
|
- `GET /api/admin/logs` - Get error logs
|
||||||
|
|
||||||
### Docker deployment
|
### Docker deployment
|
||||||
|
|
||||||
@@ -128,10 +206,19 @@ docker-compose down
|
|||||||
- `PROXY_URL` - URL for schedule parsing (optional)
|
- `PROXY_URL` - URL for schedule parsing (optional)
|
||||||
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token (optional)
|
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token (optional)
|
||||||
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID (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)
|
- `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)
|
- `NEXT_PUBLIC_SITE_URL` - Site URL for canonical links and sitemap (optional)
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
- The application uses SQLite database (`data/schedule-app.db`) for storing:
|
||||||
|
- Groups configuration
|
||||||
|
- Application settings
|
||||||
|
- Admin password (hashed with bcrypt)
|
||||||
|
- Database is automatically created on first run
|
||||||
|
- No additional database setup required
|
||||||
|
|
||||||
|
**Note:** Admin password is stored in SQLite database. Default password is `ksadmin` - change it after first login!
|
||||||
|
|
||||||
### Production deployment
|
### Production deployment
|
||||||
|
|
||||||
#### Netlify
|
#### Netlify
|
||||||
@@ -242,9 +329,11 @@ See `.env.production.example` for available options. The application uses `.env`
|
|||||||
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID for receiving notifications (optional)
|
- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID for receiving notifications (optional)
|
||||||
|
|
||||||
**Admin panel:**
|
**Admin panel:**
|
||||||
- `ADMIN_PASSWORD` - Password for admin panel access (required for admin features)
|
- `ADMIN_PASSWORD` - Initial password for admin panel access (optional, defaults to 'ksadmin')
|
||||||
- `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, defaults to 'change-me-in-production', but should be changed in production)
|
- `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, defaults to 'change-me-in-production', but should be changed in production)
|
||||||
|
|
||||||
|
**Note:** The admin password is stored in SQLite database and can be changed from the admin panel. The `ADMIN_PASSWORD` environment variable is only used for initial setup.
|
||||||
|
|
||||||
**Site configuration:**
|
**Site configuration:**
|
||||||
- `NEXT_PUBLIC_SITE_URL` - Site URL for canonical links and sitemap (optional, defaults to https://schedule.itlxrd.space)
|
- `NEXT_PUBLIC_SITE_URL` - Site URL for canonical links and sitemap (optional, defaults to https://schedule.itlxrd.space)
|
||||||
|
|
||||||
|
|||||||
509
package-lock.json
generated
509
package-lock.json
generated
@@ -16,6 +16,8 @@
|
|||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@types/content-type": "^1.1.6",
|
"@types/content-type": "^1.1.6",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"better-sqlite3": "^11.6.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
@@ -38,6 +40,8 @@
|
|||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/jsdom": "^21.1.3",
|
"@types/jsdom": "^21.1.3",
|
||||||
"@types/node": "22.0.0",
|
"@types/node": "22.0.0",
|
||||||
"@types/node-telegram-bot-api": "^0.61.8",
|
"@types/node-telegram-bot-api": "^0.61.8",
|
||||||
@@ -1099,6 +1103,35 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/node-pre-gyp": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
"make-dir": "^3.1.0",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
|
"nopt": "^5.0.0",
|
||||||
|
"npmlog": "^5.0.1",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"semver": "^7.3.5",
|
||||||
|
"tar": "^6.1.11"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-pre-gyp": "bin/node-pre-gyp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/node-pre-gyp/node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@@ -3138,6 +3171,26 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcrypt": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/better-sqlite3": {
|
||||||
|
"version": "7.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/caseless": {
|
"node_modules/@types/caseless": {
|
||||||
"version": "0.12.5",
|
"version": "0.12.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
|
||||||
@@ -3927,6 +3980,12 @@
|
|||||||
"deprecated": "Use your platform's native atob() and btoa() methods instead",
|
"deprecated": "Use your platform's native atob() and btoa() methods instead",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/abbrev": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -4028,6 +4087,40 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/aproba": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/are-we-there-yet": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
|
||||||
|
"deprecated": "This package is no longer supported.",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"delegates": "^1.0.0",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/are-we-there-yet/node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -4395,7 +4488,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bare-events": {
|
"node_modules/bare-events": {
|
||||||
@@ -4519,6 +4611,20 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/node-pre-gyp": "^1.0.11",
|
||||||
|
"node-addon-api": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bcrypt-pbkdf": {
|
"node_modules/bcrypt-pbkdf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
@@ -4528,6 +4634,23 @@
|
|||||||
"tweetnacl": "^0.14.3"
|
"tweetnacl": "^0.14.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt/node_modules/node-addon-api": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/better-sqlite3": {
|
||||||
|
"version": "11.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||||
|
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"prebuild-install": "^7.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -4541,6 +4664,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bindings": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"file-uri-to-path": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz",
|
||||||
@@ -4848,6 +4980,15 @@
|
|||||||
"simple-swizzle": "^0.2.2"
|
"simple-swizzle": "^0.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-support": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"color-support": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -4874,9 +5015,14 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/console-control-strings": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/content-type": {
|
"node_modules/content-type": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
@@ -5171,6 +5317,12 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delegates": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||||
@@ -6272,6 +6424,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-uri-to-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -6402,11 +6560,40 @@
|
|||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-minipass": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"minipass": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-minipass/node_modules/minipass": {
|
||||||
|
"version": "3.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||||
|
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-minipass/node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/fs.realpath": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
@@ -6462,6 +6649,74 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gauge": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
|
||||||
|
"deprecated": "This package is no longer supported.",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"aproba": "^1.0.3 || ^2.0.0",
|
||||||
|
"color-support": "^1.1.2",
|
||||||
|
"console-control-strings": "^1.0.0",
|
||||||
|
"has-unicode": "^2.0.1",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"signal-exit": "^3.0.0",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wide-align": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gauge/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gauge/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/gauge/node_modules/signal-exit": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/gauge/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gauge/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/generator-function": {
|
"node_modules/generator-function": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
@@ -6793,6 +7048,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-unicode": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -6965,7 +7226,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"once": "^1.3.0",
|
"once": "^1.3.0",
|
||||||
@@ -7183,7 +7443,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -7788,6 +8047,30 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir/node_modules/semver": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -7899,6 +8182,49 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minizlib": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minipass": "^3.0.0",
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minizlib/node_modules/minipass": {
|
||||||
|
"version": "3.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||||
|
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minizlib/node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/mkdirp": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mkdirp-classic": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
@@ -8168,6 +8494,48 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch/node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-html-parser": {
|
"node_modules/node-html-parser": {
|
||||||
"version": "6.1.13",
|
"version": "6.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
|
||||||
@@ -8220,6 +8588,21 @@
|
|||||||
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
|
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nopt": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"abbrev": "1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"nopt": "bin/nopt.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -8240,6 +8623,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/npmlog": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
|
||||||
|
"deprecated": "This package is no longer supported.",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"are-we-there-yet": "^2.0.0",
|
||||||
|
"console-control-strings": "^1.1.0",
|
||||||
|
"gauge": "^3.0.0",
|
||||||
|
"set-blocking": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nth-check": {
|
"node_modules/nth-check": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
@@ -8271,7 +8667,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -8531,7 +8926,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -9380,7 +9774,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.3"
|
"glob": "^7.1.3"
|
||||||
@@ -9396,7 +9789,6 @@
|
|||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
@@ -9408,7 +9800,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fs.realpath": "^1.0.0",
|
"fs.realpath": "^1.0.0",
|
||||||
@@ -9429,7 +9820,6 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
@@ -9607,6 +9997,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -10414,6 +10810,23 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"chownr": "^2.0.0",
|
||||||
|
"fs-minipass": "^2.0.0",
|
||||||
|
"minipass": "^5.0.0",
|
||||||
|
"minizlib": "^2.1.1",
|
||||||
|
"mkdirp": "^1.0.3",
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar-fs": {
|
"node_modules/tar-fs": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
|
||||||
@@ -10449,6 +10862,30 @@
|
|||||||
"streamx": "^2.15.0"
|
"streamx": "^2.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar/node_modules/chownr": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar/node_modules/minipass": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar/node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/text-decoder": {
|
"node_modules/text-decoder": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
|
||||||
@@ -11374,6 +11811,56 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wide-align": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wide-align/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wide-align/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wide-align/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wide-align/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -40,9 +40,13 @@
|
|||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"better-sqlite3": "^11.6.0",
|
||||||
|
"bcrypt": "^5.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/jsdom": "^21.1.3",
|
"@types/jsdom": "^21.1.3",
|
||||||
"@types/node": "22.0.0",
|
"@types/node": "22.0.0",
|
||||||
"@types/node-telegram-bot-api": "^0.61.8",
|
"@types/node-telegram-bot-api": "^0.61.8",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Day } from '@/shared/model/day'
|
|||||||
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
|
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
|
||||||
import contentTypeParser from 'content-type'
|
import contentTypeParser from 'content-type'
|
||||||
import { JSDOM } from 'jsdom'
|
import { JSDOM } from 'jsdom'
|
||||||
import { reportParserError } from '@/app/logger'
|
import { reportParserError, logErrorToFile } from '@/app/logger'
|
||||||
import { PROXY_URL } from '@/shared/constants/urls'
|
import { PROXY_URL } from '@/shared/constants/urls'
|
||||||
|
|
||||||
export type ScheduleResult = {
|
export type ScheduleResult = {
|
||||||
@@ -60,19 +60,78 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe
|
|||||||
dom.window.close()
|
dom.window.close()
|
||||||
}
|
}
|
||||||
console.error(`Error while parsing ${PROXY_URL}`)
|
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)
|
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Логируем только метаданные, без содержимого ответа
|
// Логируем только метаданные, без содержимого ответа
|
||||||
console.error(`Failed to fetch schedule: status=${page.status}, contentType=${contentType}, contentLength=${content.length}`)
|
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)
|
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
|
||||||
throw new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`)
|
throw error
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
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
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import TelegramBot from 'node-telegram-bot-api'
|
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 token = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN
|
||||||
const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID
|
const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID
|
||||||
@@ -10,6 +12,46 @@ if (!token || !ownerID) {
|
|||||||
bot = new TelegramBot(token, { polling: false })
|
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[]) {
|
export async function reportParserError(...text: string[]) {
|
||||||
if (!token || !ownerID) return
|
if (!token || !ownerID) return
|
||||||
|
|
||||||
|
|||||||
@@ -275,10 +275,101 @@ const parseLesson = (row: Element): Lesson | null => {
|
|||||||
const isFreeTimeReplacement = lesson.isChange &&
|
const isFreeTimeReplacement = lesson.isChange &&
|
||||||
(cellText.includes('Свободное время') && cellText.includes('Замена') && cellText.includes('на:'))
|
(cellText.includes('Свободное время') && cellText.includes('Замена') && cellText.includes('на:'))
|
||||||
|
|
||||||
|
// Проверяем, является ли это заменой предмета на предмет
|
||||||
|
const isSubjectReplacement = lesson.isChange &&
|
||||||
|
!isFreeTimeReplacement &&
|
||||||
|
cellText.includes('Замена') &&
|
||||||
|
cellText.includes('на:')
|
||||||
|
|
||||||
if (isFreeTimeReplacement) {
|
if (isFreeTimeReplacement) {
|
||||||
// Для замены "свободное время" на пару нужно парсить данные после "на:"
|
// Для замены "свободное время" на пару нужно парсить данные после "на:"
|
||||||
// Структура: "Замена Свободное время на:</a><br> название <br> преподаватель <font> адрес <br> кабинет </font>
|
// Структура: "Замена Свободное время на:</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 парсинг для извлечения данных после "на:"
|
// Используем HTML парсинг для извлечения данных после "на:"
|
||||||
const afterOnIndex = cellHTML.indexOf('на:')
|
const afterOnIndex = cellHTML.indexOf('на:')
|
||||||
if (afterOnIndex !== -1) {
|
if (afterOnIndex !== -1) {
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ function cleanupCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
||||||
|
// Используем кеш (обновляется каждую минуту автоматически)
|
||||||
const groups = loadGroups()
|
const groups = loadGroups()
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
const group = context.params?.group
|
const group = context.params?.group
|
||||||
|
|||||||
@@ -27,9 +27,74 @@ import {
|
|||||||
type AdminPageProps = {
|
type AdminPageProps = {
|
||||||
groups: GroupsData
|
groups: GroupsData
|
||||||
settings: AppSettings
|
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 [authenticated, setAuthenticated] = React.useState<boolean | null>(null)
|
||||||
const [password, setPassword] = React.useState('')
|
const [password, setPassword] = React.useState('')
|
||||||
const [loading, setLoading] = React.useState(false)
|
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 [showAddDialog, setShowAddDialog] = React.useState(false)
|
||||||
const [showEditDialog, setShowEditDialog] = React.useState(false)
|
const [showEditDialog, setShowEditDialog] = React.useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
|
||||||
|
const [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 [groupToDelete, setGroupToDelete] = React.useState<string | null>(null)
|
||||||
const [toasts, setToasts] = React.useState<Toast[]>([])
|
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 showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||||
const id = Date.now().toString()
|
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 {
|
try {
|
||||||
const res = await fetch('/api/admin/groups')
|
const res = await fetch(endpoint)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.groups) {
|
if (data.groups) {
|
||||||
setGroups(data.groups)
|
setter(data.groups as T)
|
||||||
|
} else if (data.settings) {
|
||||||
|
setter(data.settings as T)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading groups:', err)
|
console.error(`Error loading data from ${endpoint}:`, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadSettingsList = async () => {
|
const loadGroupsList = () => loadData('/api/admin/groups', setGroups)
|
||||||
try {
|
const loadSettingsList = () => loadData('/api/admin/settings', setSettings)
|
||||||
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 handleUpdateSettings = async (newSettings: AppSettings) => {
|
const handleUpdateSettings = async (newSettings: AppSettings) => {
|
||||||
// Сохраняем предыдущее состояние для отката при ошибке
|
// Сохраняем предыдущее состояние для отката при ошибке
|
||||||
@@ -289,6 +357,29 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
setShowDeleteDialog(true)
|
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) {
|
if (authenticated === null) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@@ -350,6 +441,13 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
<div className="max-w-6xl mx-auto space-y-6">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-3xl font-bold">Админ-панель</h1>
|
<h1 className="text-3xl font-bold">Админ-панель</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleOpenLogsDialog}
|
||||||
|
>
|
||||||
|
Логи
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -364,6 +462,7 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Выйти
|
Выйти
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-destructive/10 text-destructive rounded-md">
|
<div className="p-4 bg-destructive/10 text-destructive rounded-md">
|
||||||
@@ -371,6 +470,34 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Настройки</CardTitle>
|
<CardTitle>Настройки</CardTitle>
|
||||||
@@ -385,16 +512,24 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Включить или выключить навигацию по неделям в расписании
|
Включить или выключить навигацию по неделям в расписании
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<ToggleSwitch
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.weekNavigationEnabled}
|
checked={settings.weekNavigationEnabled}
|
||||||
onChange={(e) => handleUpdateSettings({ ...settings, weekNavigationEnabled: e.target.checked })}
|
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}
|
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -468,22 +603,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Принудительно использовать кэш, даже если он свежий (симулирует ошибку парсинга)
|
Принудительно использовать кэш, даже если он свежий (симулирует ошибку парсинга)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<ToggleSwitch
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.debug?.forceCache ?? false}
|
checked={settings.debug?.forceCache ?? false}
|
||||||
onChange={(e) => handleUpdateSettings({
|
onChange={(checked) => handleUpdateSettings({
|
||||||
...settings,
|
...settings,
|
||||||
debug: {
|
debug: {
|
||||||
...settings.debug,
|
...settings.debug,
|
||||||
forceCache: e.target.checked
|
forceCache: checked
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
disabled={loading}
|
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
@@ -492,22 +622,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Показать пустое расписание независимо от реальных данных
|
Показать пустое расписание независимо от реальных данных
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<ToggleSwitch
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.debug?.forceEmpty ?? false}
|
checked={settings.debug?.forceEmpty ?? false}
|
||||||
onChange={(e) => handleUpdateSettings({
|
onChange={(checked) => handleUpdateSettings({
|
||||||
...settings,
|
...settings,
|
||||||
debug: {
|
debug: {
|
||||||
...settings.debug,
|
...settings.debug,
|
||||||
forceEmpty: e.target.checked
|
forceEmpty: checked
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
disabled={loading}
|
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
@@ -516,22 +641,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Показать страницу ошибки независимо от реальных данных
|
Показать страницу ошибки независимо от реальных данных
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<ToggleSwitch
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.debug?.forceError ?? false}
|
checked={settings.debug?.forceError ?? false}
|
||||||
onChange={(e) => handleUpdateSettings({
|
onChange={(checked) => handleUpdateSettings({
|
||||||
...settings,
|
...settings,
|
||||||
debug: {
|
debug: {
|
||||||
...settings.debug,
|
...settings.debug,
|
||||||
forceError: e.target.checked
|
forceError: checked
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
disabled={loading}
|
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
@@ -540,22 +660,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Симулировать таймаут при загрузке расписания
|
Симулировать таймаут при загрузке расписания
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<ToggleSwitch
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.debug?.forceTimeout ?? false}
|
checked={settings.debug?.forceTimeout ?? false}
|
||||||
onChange={(e) => handleUpdateSettings({
|
onChange={(checked) => handleUpdateSettings({
|
||||||
...settings,
|
...settings,
|
||||||
debug: {
|
debug: {
|
||||||
...settings.debug,
|
...settings.debug,
|
||||||
forceTimeout: e.target.checked
|
forceTimeout: checked
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
disabled={loading}
|
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
@@ -564,22 +679,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Показать дополнительную информацию о кэше в интерфейсе
|
Показать дополнительную информацию о кэше в интерфейсе
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<ToggleSwitch
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.debug?.showCacheInfo ?? false}
|
checked={settings.debug?.showCacheInfo ?? false}
|
||||||
onChange={(e) => handleUpdateSettings({
|
onChange={(checked) => handleUpdateSettings({
|
||||||
...settings,
|
...settings,
|
||||||
debug: {
|
debug: {
|
||||||
...settings.debug,
|
...settings.debug,
|
||||||
showCacheInfo: e.target.checked
|
showCacheInfo: checked
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
disabled={loading}
|
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -639,21 +749,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="add-course">Курс</Label>
|
<Label htmlFor="add-course">Курс</Label>
|
||||||
<Select
|
<CourseSelect
|
||||||
value={formData.course}
|
value={formData.course}
|
||||||
onValueChange={(value) => setFormData({ ...formData, course: value })}
|
onChange={(value) => setFormData({ ...formData, course: value })}
|
||||||
>
|
id="add-course"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -712,21 +812,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-course">Курс</Label>
|
<Label htmlFor="edit-course">Курс</Label>
|
||||||
<Select
|
<CourseSelect
|
||||||
value={formData.course}
|
value={formData.course}
|
||||||
onValueChange={(value) => setFormData({ ...formData, course: value })}
|
onChange={(value) => setFormData({ ...formData, course: value })}
|
||||||
>
|
id="edit-course"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -751,17 +841,161 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Это действие нельзя отменить.
|
Это действие нельзя отменить.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooterButtons
|
||||||
<Button type="button" variant="outline" onClick={() => setShowDeleteDialog(false)}>
|
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>
|
</Button>
|
||||||
<Button type="button" variant="destructive" onClick={handleDeleteGroup} disabled={loading}>
|
</div>
|
||||||
{loading ? 'Удаление...' : 'Удалить'}
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowLogsDialog(false)}>
|
||||||
|
Закрыть
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 уведомления */}
|
{/* Toast уведомления */}
|
||||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||||
</>
|
</>
|
||||||
@@ -771,11 +1005,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
|
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
|
||||||
const groups = loadGroups()
|
const groups = loadGroups()
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
|
|
||||||
|
// Проверяем, используется ли дефолтный пароль
|
||||||
|
const { isDefaultPassword } = await import('@/shared/data/database')
|
||||||
|
const isDefault = await isDefaultPassword()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
groups,
|
groups,
|
||||||
settings
|
settings,
|
||||||
|
isDefaultPassword: isDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
src/pages/api/admin/change-password.ts
Normal file
39
src/pages/api/admin/change-password.ts
Normal 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'])
|
||||||
|
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { requireAuth } from '@/shared/utils/auth'
|
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||||
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
|
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
|
||||||
|
import { validateGroupId, validateCourse } from '@/shared/utils/validation'
|
||||||
|
|
||||||
type ResponseData = {
|
type ResponseData = ApiResponse<{
|
||||||
groups?: GroupsData
|
groups?: GroupsData
|
||||||
success?: boolean
|
}>
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handler(
|
async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<ResponseData>
|
res: NextApiResponse<ResponseData>
|
||||||
) {
|
) {
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
// Получение списка групп
|
// Получение списка групп (всегда свежие данные для админ-панели)
|
||||||
const groups = loadGroups()
|
clearGroupsCache()
|
||||||
|
const groups = loadGroups(true)
|
||||||
res.status(200).json({ groups })
|
res.status(200).json({ groups })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,11 @@ async function handler(
|
|||||||
return
|
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') {
|
if (!parseId || typeof parseId !== 'number') {
|
||||||
res.status(400).json({ error: 'Parse ID must be a number' })
|
res.status(400).json({ error: 'Parse ID must be a number' })
|
||||||
return
|
return
|
||||||
@@ -40,17 +45,11 @@ async function handler(
|
|||||||
|
|
||||||
// Валидация курса (1-5)
|
// Валидация курса (1-5)
|
||||||
const groupCourse = course !== undefined ? Number(course) : 1
|
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' })
|
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
|
||||||
return
|
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()
|
const groups = loadGroups()
|
||||||
|
|
||||||
// Проверка на дубликат
|
// Проверка на дубликат
|
||||||
@@ -66,23 +65,14 @@ async function handler(
|
|||||||
course: groupCourse
|
course: groupCourse
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
saveGroups(groups)
|
saveGroups(groups)
|
||||||
res.status(200).json({ success: true, groups })
|
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||||
} catch (error) {
|
clearGroupsCache()
|
||||||
console.error('Error saving groups:', error)
|
const updatedGroups = loadGroups(true)
|
||||||
res.status(500).json({ error: 'Failed to save groups' })
|
res.status(200).json({ success: true, groups: updatedGroups })
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(405).json({ error: 'Method not allowed' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function protectedHandler(
|
export default withAuth(handler, ['GET', 'POST'])
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse<ResponseData>
|
|
||||||
) {
|
|
||||||
return requireAuth(req, res, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { requireAuth } from '@/shared/utils/auth'
|
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||||
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
|
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
|
||||||
|
import { validateCourse } from '@/shared/utils/validation'
|
||||||
|
|
||||||
type ResponseData = {
|
type ResponseData = ApiResponse<{
|
||||||
success?: boolean
|
|
||||||
groups?: GroupsData
|
groups?: GroupsData
|
||||||
error?: string
|
}>
|
||||||
}
|
|
||||||
|
|
||||||
async function handler(
|
async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
@@ -19,7 +18,8 @@ async function handler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = loadGroups()
|
// Загружаем группы с проверкой кеша
|
||||||
|
let groups = loadGroups()
|
||||||
|
|
||||||
if (req.method === 'PUT') {
|
if (req.method === 'PUT') {
|
||||||
// Редактирование группы
|
// Редактирование группы
|
||||||
@@ -40,13 +40,10 @@ async function handler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (course !== undefined) {
|
if (course !== undefined && !validateCourse(course)) {
|
||||||
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' })
|
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем группу
|
// Обновляем группу
|
||||||
const currentGroup = groups[id]
|
const currentGroup = groups[id]
|
||||||
@@ -56,13 +53,11 @@ async function handler(
|
|||||||
course: course !== undefined ? Number(course) : currentGroup.course
|
course: course !== undefined ? Number(course) : currentGroup.course
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
saveGroups(groups)
|
saveGroups(groups)
|
||||||
res.status(200).json({ success: true, groups })
|
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||||
} catch (error) {
|
clearGroupsCache()
|
||||||
console.error('Error saving groups:', error)
|
const updatedGroups = loadGroups(true)
|
||||||
res.status(500).json({ error: 'Failed to save groups' })
|
res.status(200).json({ success: true, groups: updatedGroups })
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,23 +70,14 @@ async function handler(
|
|||||||
|
|
||||||
delete groups[id]
|
delete groups[id]
|
||||||
|
|
||||||
try {
|
|
||||||
saveGroups(groups)
|
saveGroups(groups)
|
||||||
res.status(200).json({ success: true, groups })
|
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||||
} catch (error) {
|
clearGroupsCache()
|
||||||
console.error('Error saving groups:', error)
|
const updatedGroups = loadGroups(true)
|
||||||
res.status(500).json({ error: 'Failed to save groups' })
|
res.status(200).json({ success: true, groups: updatedGroups })
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(405).json({ error: 'Method not allowed' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function protectedHandler(
|
export default withAuth(handler, ['PUT', 'DELETE'])
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse<ResponseData>
|
|
||||||
) {
|
|
||||||
return requireAuth(req, res, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function recordFailedAttempt(ip: string): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<ResponseData>
|
res: NextApiResponse<ResponseData>
|
||||||
) {
|
) {
|
||||||
@@ -109,7 +109,8 @@ export default function handler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (verifyPassword(password)) {
|
const isValid = await verifyPassword(password)
|
||||||
|
if (isValid) {
|
||||||
// Успешный вход - сбрасываем rate limit
|
// Успешный вход - сбрасываем rate limit
|
||||||
rateLimitMap.delete(clientIP)
|
rateLimitMap.delete(clientIP)
|
||||||
setSessionCookie(res)
|
setSessionCookie(res)
|
||||||
|
|||||||
36
src/pages/api/admin/logs.ts
Normal file
36
src/pages/api/admin/logs.ts
Normal 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'])
|
||||||
|
|
||||||
@@ -1,33 +1,37 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { requireAuth } from '@/shared/utils/auth'
|
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||||
import { loadSettings, saveSettings, AppSettings } from '@/shared/data/settings-loader'
|
import { loadSettings, saveSettings, clearSettingsCache, AppSettings } from '@/shared/data/settings-loader'
|
||||||
|
|
||||||
type ResponseData = {
|
type ResponseData = ApiResponse<{
|
||||||
settings?: AppSettings
|
settings?: AppSettings
|
||||||
success?: boolean
|
}>
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handler(
|
async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<ResponseData>
|
res: NextApiResponse<ResponseData>
|
||||||
) {
|
) {
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
// Получение настроек
|
// Получение настроек (всегда свежие данные для админ-панели)
|
||||||
const settings = loadSettings()
|
clearSettingsCache()
|
||||||
|
const settings = loadSettings(true)
|
||||||
res.status(200).json({ settings })
|
res.status(200).json({ settings })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'PUT') {
|
if (req.method === 'PUT') {
|
||||||
// Обновление настроек
|
// Обновление настроек
|
||||||
const { weekNavigationEnabled, debug } = req.body
|
const { weekNavigationEnabled, showAddGroupButton, debug } = req.body
|
||||||
|
|
||||||
if (typeof weekNavigationEnabled !== 'boolean') {
|
if (typeof weekNavigationEnabled !== 'boolean') {
|
||||||
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showAddGroupButton !== undefined && typeof showAddGroupButton !== 'boolean') {
|
||||||
|
res.status(400).json({ error: 'showAddGroupButton must be a boolean' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Валидация debug опций (только в dev режиме)
|
// Валидация debug опций (только в dev режиме)
|
||||||
if (debug !== undefined) {
|
if (debug !== undefined) {
|
||||||
if (typeof debug !== 'object' || debug === null) {
|
if (typeof debug !== 'object' || debug === null) {
|
||||||
@@ -51,32 +55,20 @@ async function handler(
|
|||||||
|
|
||||||
const settings: AppSettings = {
|
const settings: AppSettings = {
|
||||||
weekNavigationEnabled,
|
weekNavigationEnabled,
|
||||||
|
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : true,
|
||||||
...(debug !== undefined && { debug })
|
...(debug !== undefined && { debug })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
saveSettings(settings)
|
saveSettings(settings)
|
||||||
// Сбрасываем кеш и загружаем свежие настройки для подтверждения
|
// Сбрасываем кеш и загружаем свежие настройки для подтверждения
|
||||||
const { clearSettingsCache } = await import('@/shared/data/settings-loader')
|
|
||||||
clearSettingsCache()
|
clearSettingsCache()
|
||||||
const savedSettings = loadSettings()
|
const savedSettings = loadSettings(true)
|
||||||
res.status(200).json({ success: true, settings: savedSettings })
|
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' })
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(405).json({ error: 'Method not allowed' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function protectedHandler(
|
export default withAuth(handler, ['GET', 'PUT'])
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse<ResponseData>
|
|
||||||
) {
|
|
||||||
return requireAuth(req, res, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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' })
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { GetServerSideProps } from 'next'
|
import { GetServerSideProps } from 'next'
|
||||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
|
||||||
import { Button } from '@/shadcn/ui/button'
|
import { Button } from '@/shadcn/ui/button'
|
||||||
import { ThemeSwitcher } from '@/features/theme-switch'
|
import { ThemeSwitcher } from '@/features/theme-switch'
|
||||||
@@ -24,10 +25,11 @@ import { BsTelegram } from 'react-icons/bs'
|
|||||||
type HomePageProps = {
|
type HomePageProps = {
|
||||||
groups: GroupsData
|
groups: GroupsData
|
||||||
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
|
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
|
||||||
|
showAddGroupButton: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
|
export default function HomePage({ groups, groupsByCourse, showAddGroupButton }: HomePageProps) {
|
||||||
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set([1]))
|
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
|
||||||
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
||||||
|
|
||||||
// Подсчитываем смещения для каждого курса для последовательной анимации
|
// Подсчитываем смещения для каждого курса для последовательной анимации
|
||||||
@@ -148,6 +150,7 @@ 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="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
||||||
|
{showAddGroupButton && (
|
||||||
<div
|
<div
|
||||||
className="stagger-card"
|
className="stagger-card"
|
||||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
||||||
@@ -161,15 +164,16 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
|
|||||||
Добавить группу
|
Добавить группу
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="stagger-card"
|
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 />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="stagger-card"
|
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">
|
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className="gap-2">
|
||||||
@@ -208,7 +212,9 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps<HomePageProps> = async () => {
|
export const getServerSideProps: GetServerSideProps<HomePageProps> = async () => {
|
||||||
|
// Используем кеш (обновляется каждую минуту автоматически)
|
||||||
const groups = loadGroups()
|
const groups = loadGroups()
|
||||||
|
const settings = loadSettings()
|
||||||
|
|
||||||
// Группируем группы по курсам
|
// Группируем группы по курсам
|
||||||
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}
|
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}
|
||||||
@@ -229,7 +235,8 @@ export const getServerSideProps: GetServerSideProps<HomePageProps> = async () =>
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
groups,
|
groups,
|
||||||
groupsByCourse
|
groupsByCourse,
|
||||||
|
showAddGroupButton: settings.showAddGroupButton ?? true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadGroups } from '@/shared/data/groups-loader'
|
|||||||
import { SITEMAP_SITE_URL } from '@/shared/constants/urls'
|
import { SITEMAP_SITE_URL } from '@/shared/constants/urls'
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||||
|
// Используем кеш (обновляется каждую минуту автоматически)
|
||||||
const groups = loadGroups()
|
const groups = loadGroups()
|
||||||
const fields = Object.keys(groups).map<ISitemapField>(group => (
|
const fields = Object.keys(groups).map<ISitemapField>(group => (
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-foreground text-center">
|
<p className="text-sm text-foreground text-center">
|
||||||
⚠️ Не удается получить актуальное расписание с официального сайта. Возможно, сервер временно недоступен. Будут показаны данные из кэша. Попробуйте обновить страницу позже.
|
⚠️ Продолжаем попытку получения расписания с официального сайта. Возможно, сервер временно недоступен и будут показаны данные из кэша. Пожалуйста, подождите...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
389
src/shared/data/database.ts
Normal file
389
src/shared/data/database.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from 'fs'
|
import { getAllGroups as getAllGroupsFromDB, createGroup, updateGroup, deleteGroup, getGroup } from './database'
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export type GroupInfo = {
|
export type GroupInfo = {
|
||||||
parseId: number
|
parseId: number
|
||||||
@@ -9,120 +8,74 @@ export type GroupInfo = {
|
|||||||
|
|
||||||
export type GroupsData = { [group: string]: GroupInfo }
|
export type GroupsData = { [group: string]: GroupInfo }
|
||||||
|
|
||||||
// Старый формат для миграции
|
|
||||||
type OldGroupsData = { [group: string]: [number, string] | GroupInfo }
|
|
||||||
|
|
||||||
let cachedGroups: GroupsData | null = null
|
let cachedGroups: GroupsData | null = null
|
||||||
|
let cacheTimestamp: number = 0
|
||||||
|
const CACHE_TTL_MS = 1000 * 60 // 1 минута
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Мигрирует старый формат данных в новый
|
* Загружает группы из базы данных
|
||||||
|
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости
|
||||||
*/
|
*/
|
||||||
function migrateGroups(oldGroups: OldGroupsData): GroupsData {
|
export function loadGroups(forceRefresh: boolean = false): GroupsData {
|
||||||
const migrated: GroupsData = {}
|
const now = Date.now()
|
||||||
|
const isCacheValid = cachedGroups !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
|
||||||
|
|
||||||
for (const [id, data] of Object.entries(oldGroups)) {
|
if (isCacheValid && cachedGroups !== null) {
|
||||||
// Проверяем, является ли это старым форматом [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) {
|
|
||||||
return cachedGroups
|
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 {
|
try {
|
||||||
if (fs.existsSync(filePath)) {
|
cachedGroups = getAllGroupsFromDB()
|
||||||
const fileContents = fs.readFileSync(filePath, 'utf8')
|
cacheTimestamp = now
|
||||||
const rawGroups = JSON.parse(fileContents) as OldGroupsData
|
return cachedGroups
|
||||||
|
|
||||||
// Проверяем, нужна ли миграция
|
|
||||||
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) {
|
} catch (error) {
|
||||||
// Пробуем следующий путь
|
console.error('Error loading groups from database:', error)
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Error loading groups.json: file not found in any of the expected locations')
|
|
||||||
// Fallback к пустому объекту
|
// Fallback к пустому объекту
|
||||||
return {}
|
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(groups, null, 2), 'utf8')
|
/**
|
||||||
// Сбрасываем кеш
|
* Сохраняет группы в базу данных
|
||||||
|
*/
|
||||||
|
export function saveGroups(groups: GroupsData): void {
|
||||||
|
try {
|
||||||
|
const existingGroups = getAllGroupsFromDB()
|
||||||
|
|
||||||
|
// Определяем, какие группы нужно добавить, обновить или удалить
|
||||||
|
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
|
cachedGroups = null
|
||||||
|
cacheTimestamp = 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving groups.json:', error)
|
console.error('Error saving groups to database:', error)
|
||||||
throw new Error('Failed to save groups')
|
throw new Error('Failed to save groups')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сбрасывает кеш групп (полезно после обновления файла)
|
* Сбрасывает кеш групп (полезно после обновления)
|
||||||
*/
|
*/
|
||||||
export function clearGroupsCache(): void {
|
export function clearGroupsCache(): void {
|
||||||
cachedGroups = null
|
cachedGroups = null
|
||||||
|
cacheTimestamp = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import fs from 'fs'
|
import { getSettings as getSettingsFromDB, updateSettings as updateSettingsInDB } from './database'
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export type AppSettings = {
|
export type AppSettings = {
|
||||||
weekNavigationEnabled: boolean
|
weekNavigationEnabled: boolean
|
||||||
|
showAddGroupButton: boolean
|
||||||
debug?: {
|
debug?: {
|
||||||
forceCache?: boolean
|
forceCache?: boolean
|
||||||
forceEmpty?: boolean
|
forceEmpty?: boolean
|
||||||
@@ -13,11 +13,31 @@ export type AppSettings = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cachedSettings: AppSettings | null = null
|
let cachedSettings: AppSettings | null = null
|
||||||
let cachedSettingsPath: string | null = null
|
let cacheTimestamp: number = 0
|
||||||
let cachedSettingsMtime: number | null = null
|
const CACHE_TTL_MS = 1000 * 60 // 1 минута
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает настройки из базы данных
|
||||||
|
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости
|
||||||
|
*/
|
||||||
|
export function loadSettings(forceRefresh: boolean = false): AppSettings {
|
||||||
|
const now = Date.now()
|
||||||
|
const isCacheValid = cachedSettings !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
|
||||||
|
|
||||||
|
if (isCacheValid && cachedSettings !== null) {
|
||||||
|
return cachedSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cachedSettings = getSettingsFromDB()
|
||||||
|
cacheTimestamp = now
|
||||||
|
return cachedSettings
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings from database:', error)
|
||||||
|
// Возвращаем настройки по умолчанию
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: true,
|
weekNavigationEnabled: false,
|
||||||
|
showAddGroupButton: true,
|
||||||
debug: {
|
debug: {
|
||||||
forceCache: false,
|
forceCache: false,
|
||||||
forceEmpty: false,
|
forceEmpty: false,
|
||||||
@@ -26,177 +46,31 @@ const defaultSettings: AppSettings = {
|
|||||||
showCacheInfo: false
|
showCacheInfo: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Загружает настройки из JSON файла
|
|
||||||
* Проверяет время модификации файла для инвалидации кеша
|
|
||||||
*/
|
|
||||||
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'),
|
|
||||||
]
|
|
||||||
|
|
||||||
// Ищем существующий файл
|
|
||||||
let foundPath: string | null = null
|
|
||||||
for (const filePath of possiblePaths) {
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
foundPath = filePath
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если файл найден, проверяем, изменился ли он
|
|
||||||
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
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating settings.json:', error)
|
|
||||||
// Возвращаем настройки по умолчанию
|
|
||||||
return defaultSettings
|
return defaultSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохраняет настройки в JSON файл
|
* Сохраняет настройки в базу данных
|
||||||
*/
|
*/
|
||||||
export function saveSettings(settings: AppSettings): void {
|
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 {
|
try {
|
||||||
// Создаем директорию, если её нет
|
updateSettingsInDB(settings)
|
||||||
const dir = path.dirname(targetPath)
|
// Сбрасываем кеш и timestamp
|
||||||
if (!fs.existsSync(dir)) {
|
cachedSettings = null
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
cacheTimestamp = 0
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем файл
|
|
||||||
fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
|
|
||||||
|
|
||||||
// Обновляем кеш с новыми метаданными
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(targetPath)
|
|
||||||
cachedSettings = mergedSettings
|
|
||||||
cachedSettingsPath = targetPath
|
|
||||||
cachedSettingsMtime = stats.mtimeMs
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Если не удалось получить stats, просто обновляем кеш
|
console.error('Error saving settings to database:', error)
|
||||||
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) {
|
|
||||||
// Игнорируем ошибки при сохранении в дополнительные пути
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving settings.json:', error)
|
|
||||||
throw new Error('Failed to save settings')
|
throw new Error('Failed to save settings')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сбрасывает кеш настроек (полезно после обновления файла)
|
* Сбрасывает кеш настроек (полезно после обновления)
|
||||||
*/
|
*/
|
||||||
export function clearSettingsCache(): void {
|
export function clearSettingsCache(): void {
|
||||||
cachedSettings = null
|
cachedSettings = null
|
||||||
cachedSettingsPath = null
|
cacheTimestamp = 0
|
||||||
cachedSettingsMtime = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
65
src/shared/utils/api-wrapper.ts
Normal file
65
src/shared/utils/api-wrapper.ts
Normal 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>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { verifyPassword as verifyPasswordFromDB } from '@/shared/data/database'
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'admin_session'
|
const SESSION_COOKIE_NAME = 'admin_session'
|
||||||
const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET
|
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 {
|
export async function verifyPassword(password: string): Promise<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
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const passwordBuffer = Buffer.from(password, 'utf8')
|
return await verifyPasswordFromDB(password)
|
||||||
const adminPasswordBuffer = Buffer.from(adminPassword, 'utf8')
|
} catch (error) {
|
||||||
// Buffer в Node.js наследуется от Uint8Array и совместим с ArrayBufferView
|
console.error('Error verifying password:', error)
|
||||||
return crypto.timingSafeEqual(
|
|
||||||
passwordBuffer as Uint8Array,
|
|
||||||
adminPasswordBuffer as Uint8Array
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,6 +131,10 @@ export function requireAuth(
|
|||||||
res.status(401).json({ error: 'Unauthorized' })
|
res.status(401).json({ error: 'Unauthorized' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return handler(req, res)
|
const result = handler(req, res)
|
||||||
|
// Если handler возвращает Promise, возвращаем его
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
src/shared/utils/validation.ts
Normal file
25
src/shared/utils/validation.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user