diff --git a/.gitignore b/.gitignore index bb0596a..137a3b7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,13 @@ next-env.d.ts .env # dependency hash (installation-specific) -.dependencies.hash \ No newline at end of file +.dependencies.hash + +# error logs +error.log + +# database files +data/ +*.db +*.db-shm +*.db-wal \ No newline at end of file diff --git a/README.md b/README.md index d784fd5..fd0e150 100644 --- a/README.md +++ b/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) - JSDOM for parsing scraped pages, rehydration strategy for cache - 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 - Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c) - Accessibility & tab navigation support - Dark theme with automatic switching based on system settings - Admin panel for managing groups and settings +- 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 @@ -32,38 +64,57 @@ Workaround: Locate to next week, then enter previous twice. ``` kspguti-schedule/ ├── src/ # Source code -│ ├── app/ # App router (Next.js 13+) +│ ├── app/ # App-level code │ │ ├── agregator/ # Schedule fetching logic │ │ ├── parser/ # HTML parsing for schedule │ │ └── utils/ # App-level utilities │ ├── pages/ # Pages router (Next.js) │ │ ├── api/ # API routes │ │ │ └── admin/ # Admin panel API endpoints +│ │ │ ├── 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 │ │ ├── admin.tsx # Admin panel page │ │ └── index.tsx # Home page -│ ├── entities/ # Entities +│ ├── entities/ # Business entities │ ├── features/ # Feature modules +│ │ ├── add-group/ # Add group feature +│ │ └── theme-switch/ # Theme switcher │ ├── shared/ # Shared code │ │ ├── constants/ # App constants │ │ ├── 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 │ │ ├── providers/ # React providers │ │ ├── ui/ # Shared UI components │ │ └── utils/ # Utility functions +│ │ ├── auth.ts # Authentication utilities +│ │ ├── api-wrapper.ts # API route wrappers +│ │ └── validation.ts # Validation utilities │ ├── shadcn/ # shadcn/ui components │ └── widgets/ # Complex UI widgets +│ ├── navbar/ # Navigation bar +│ └── schedule/ # Schedule display components ├── public/ # Static assets │ └── 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 ├── systemd/ # Systemd service file +├── data/ # SQLite database files ├── components.json # shadcn/ui config ├── docker-compose.yml # Docker Compose config -├── Dockerfile # Docker image definition -├── next.config.js # Next.js configuration -├── tailwind.config.js # Tailwind CSS config +├── Dockerfile # Docker image definition +├── next.config.js # Next.js configuration +├── tailwind.config.js # Tailwind CSS config └── tsconfig.json # TypeScript config ``` @@ -91,13 +142,40 @@ The application includes an admin panel for managing groups and application sett **Features:** - Manage groups (add, edit, delete) - 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:** -- `ADMIN_PASSWORD` - Password for admin panel access (required) - `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 @@ -128,10 +206,19 @@ docker-compose down - `PROXY_URL` - URL for schedule parsing (optional) - `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token (optional) - `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID (optional) -- `ADMIN_PASSWORD` - Password for admin panel access (required for admin features) - `ADMIN_SESSION_SECRET` - Secret key for session tokens (optional, but recommended in production) - `NEXT_PUBLIC_SITE_URL` - Site URL for canonical links and sitemap (optional) +**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 #### 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) **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) +**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:** - `NEXT_PUBLIC_SITE_URL` - Site URL for canonical links and sitemap (optional, defaults to https://schedule.itlxrd.space) diff --git a/package-lock.json b/package-lock.json index 647ed0e..1658690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@types/content-type": "^1.1.6", + "bcrypt": "^5.1.1", + "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.0", "classnames": "^2.3.2", "clsx": "^2.0.0", @@ -38,6 +40,8 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/better-sqlite3": "^7.6.11", "@types/jsdom": "^21.1.3", "@types/node": "22.0.0", "@types/node-telegram-bot-api": "^0.61.8", @@ -1099,6 +1103,35 @@ "@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": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3138,6 +3171,26 @@ "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": { "version": "0.12.5", "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", "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": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4028,6 +4087,40 @@ "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": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -4395,7 +4488,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/bare-events": { @@ -4519,6 +4611,20 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -4528,6 +4634,23 @@ "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4541,6 +4664,15 @@ "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", @@ -4848,6 +4980,15 @@ "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": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4874,9 +5015,14 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "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": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -5171,6 +5317,12 @@ "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": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -6272,6 +6424,12 @@ "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": { "version": "7.1.1", "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==", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -6462,6 +6649,74 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -6793,6 +7048,12 @@ "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": { "version": "2.0.2", "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", "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.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -7183,7 +7443,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7788,6 +8047,30 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7899,6 +8182,49 @@ "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": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -8168,6 +8494,48 @@ "license": "MIT", "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": { "version": "6.1.13", "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==", "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8240,6 +8623,19 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -8271,7 +8667,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8531,7 +8926,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9380,7 +9774,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -9396,7 +9789,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -9408,7 +9800,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -9429,7 +9820,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9607,6 +9997,12 @@ "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": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10414,6 +10810,23 @@ "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", @@ -10449,6 +10862,30 @@ "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", @@ -11374,6 +11811,56 @@ "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": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 65b538c..bb9a1f1 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,13 @@ "sharp": "^0.32.6", "tailwind-merge": "^1.14.0", "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": { + "@types/bcrypt": "^5.0.2", + "@types/better-sqlite3": "^7.6.11", "@types/jsdom": "^21.1.3", "@types/node": "22.0.0", "@types/node-telegram-bot-api": "^0.61.8", diff --git a/src/app/agregator/schedule.ts b/src/app/agregator/schedule.ts index a647076..bed70e6 100644 --- a/src/app/agregator/schedule.ts +++ b/src/app/agregator/schedule.ts @@ -2,7 +2,7 @@ import { Day } from '@/shared/model/day' import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule' import contentTypeParser from 'content-type' import { JSDOM } from 'jsdom' -import { reportParserError } from '@/app/logger' +import { reportParserError, logErrorToFile } from '@/app/logger' import { PROXY_URL } from '@/shared/constants/urls' export type ScheduleResult = { @@ -60,19 +60,78 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe dom.window.close() } console.error(`Error while parsing ${PROXY_URL}`) + const error = e instanceof Error ? e : new Error(String(e)) + logErrorToFile(error, { + type: 'parsing_error', + groupName, + url, + groupID + }) reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName) throw e } } else { // Логируем только метаданные, без содержимого ответа console.error(`Failed to fetch schedule: status=${page.status}, contentType=${contentType}, contentLength=${content.length}`) + const error = new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`) + logErrorToFile(error, { + type: 'fetch_error', + groupName, + url, + groupID, + status: page.status, + contentType + }) reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName) - throw new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`) + throw error } } catch (error) { clearTimeout(timeoutId) if (error instanceof Error && error.name === 'AbortError') { - throw new ScheduleTimeoutError(`Request timeout while fetching ${PROXY_URL}`) + const timeoutError = new ScheduleTimeoutError(`Request timeout while fetching ${PROXY_URL}`) + logErrorToFile(timeoutError, { + type: 'timeout_error', + groupName, + url, + groupID + }) + throw timeoutError + } + // Улучшенная обработка сетевых ошибок для диагностики + const errorObj = error instanceof Error ? error : new Error(String(error)) + if (errorObj && 'cause' in errorObj && errorObj.cause instanceof Error) { + const networkError = errorObj.cause as Error & { code?: string } + if (networkError.code === 'ECONNRESET' || networkError.code === 'ECONNREFUSED' || networkError.code === 'ETIMEDOUT') { + console.error(`Network error while fetching ${PROXY_URL}:`, { + code: networkError.code, + message: networkError.message, + url + }) + logErrorToFile(errorObj, { + type: 'network_error', + groupName, + url, + groupID, + networkErrorCode: networkError.code, + networkErrorMessage: networkError.message + }) + } else { + // Логируем другие ошибки тоже + logErrorToFile(errorObj, { + type: 'unknown_error', + groupName, + url, + groupID + }) + } + } else { + // Логируем ошибки без cause + logErrorToFile(errorObj, { + type: 'unknown_error', + groupName, + url, + groupID + }) } throw error } diff --git a/src/app/logger.ts b/src/app/logger.ts index 5696588..c520321 100644 --- a/src/app/logger.ts +++ b/src/app/logger.ts @@ -1,4 +1,6 @@ import TelegramBot from 'node-telegram-bot-api' +import fs from 'fs' +import path from 'path' const token = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID @@ -10,6 +12,46 @@ if (!token || !ownerID) { bot = new TelegramBot(token, { polling: false }) } +// Путь к файлу логов (в корне проекта) +const getErrorLogPath = () => { + // В production (standalone) используем текущую рабочую директорию + // В development используем корень проекта (process.cwd()) + return path.join(process.cwd(), 'error.log') +} + +/** + * Логирует ошибку в файл error.log + * @param error - Объект ошибки или строка с описанием ошибки + * @param context - Дополнительный контекст (опционально) + */ +export function logErrorToFile(error: Error | string, context?: Record): void { + try { + const logPath = getErrorLogPath() + const timestamp = new Date().toISOString() + const errorMessage = error instanceof Error ? error.message : error + const errorStack = error instanceof Error ? error.stack : undefined + const errorName = error instanceof Error ? error.name : 'Error' + + let logEntry = `[${timestamp}] ${errorName}: ${errorMessage}\n` + + if (errorStack) { + logEntry += `Stack: ${errorStack}\n` + } + + if (context && Object.keys(context).length > 0) { + logEntry += `Context: ${JSON.stringify(context, null, 2)}\n` + } + + logEntry += '---\n' + + // Используем appendFileSync для надежности (не блокирует надолго) + fs.appendFileSync(logPath, logEntry, 'utf8') + } catch (logError) { + // Если не удалось записать в файл, выводим в консоль + console.error('Failed to write to error.log:', logError) + } +} + export async function reportParserError(...text: string[]) { if (!token || !ownerID) return diff --git a/src/app/parser/schedule.ts b/src/app/parser/schedule.ts index d46bf95..42bd67c 100644 --- a/src/app/parser/schedule.ts +++ b/src/app/parser/schedule.ts @@ -275,10 +275,101 @@ const parseLesson = (row: Element): Lesson | null => { const isFreeTimeReplacement = lesson.isChange && (cellText.includes('Свободное время') && cellText.includes('Замена') && cellText.includes('на:')) + // Проверяем, является ли это заменой предмета на предмет + const isSubjectReplacement = lesson.isChange && + !isFreeTimeReplacement && + cellText.includes('Замена') && + cellText.includes('на:') + if (isFreeTimeReplacement) { // Для замены "свободное время" на пару нужно парсить данные после "на:" // Структура: "Замена Свободное время на:
название
преподаватель адрес
кабинет
+ // Используем HTML парсинг для извлечения данных после "на:" + const afterOnIndex = cellHTML.indexOf('на:') + if (afterOnIndex !== -1) { + const afterOn = cellHTML.substring(afterOnIndex + 3) // +3 для "на:" + + // Пропускаем первый
(он идет сразу после "на:") + const firstBrIndex = afterOn.indexOf(' тега + const firstBrEnd = afterOn.indexOf('>', firstBrIndex) + 1 + const afterFirstBr = afterOn.substring(firstBrEnd) + + // Извлекаем название предмета (текст до следующего
) + const secondBrIndex = afterFirstBr.indexOf(']+>/g, '').trim() + + // Извлекаем преподавателя (текст между вторым
и или следующим
) + const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1 + const afterSecondBr = afterFirstBr.substring(secondBrEnd) + + const fontIndex = afterSecondBr.indexOf(']+>/g, '').trim() + } else { + // Если нет , преподаватель может быть до следующего
или до конца + const thirdBrIndex = afterSecondBr.indexOf(']+>/g, '').trim() + } else { + lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim() + } + } + } else { + // Если нет второго
, название предмета может быть до или до конца + const fontIndex = afterFirstBr.indexOf(']+>/g, '').trim() + } else { + lesson.subject = afterFirstBr.replace(/<[^>]+>/g, '').trim() + } + } + } + + // Ищем адрес и кабинет внутри + const fontMatch = afterOn.match(/]*>([\s\S]*?)<\/font>/i) + if (fontMatch) { + const fontContent = fontMatch[1] + // Ищем паттерн:
адрес
Кабинет: номер + // Сначала убираем все теги и разбиваем по
+ 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 { + // Если нет , ищем адрес и кабинет напрямую в тексте после "на:" + const addressMatch = afterOn.match(/([^<]+?)(?:]*>|\s+)Кабинет:\s*([^<\s]+)/i) + if (addressMatch) { + lesson.place = { + address: addressMatch[1].replace(/<[^>]+>/g, '').trim(), + classroom: addressMatch[2].trim() + } + } + } + } + } else if (isSubjectReplacement) { + // Для замены предмета на предмет нужно парсить данные после "на:" + // Структура: "Замена [старый предмет] на:
[новый предмет]
[преподаватель] [адрес]
Кабинет: [номер]
+ // Используем HTML парсинг для извлечения данных после "на:" const afterOnIndex = cellHTML.indexOf('на:') if (afterOnIndex !== -1) { diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index 3b2ce8b..db0d1bd 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -155,6 +155,7 @@ function cleanupCache() { } export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise>> { + // Используем кеш (обновляется каждую минуту автоматически) const groups = loadGroups() const settings = loadSettings() const group = context.params?.group diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx index 2030d46..3dfae38 100644 --- a/src/pages/admin.tsx +++ b/src/pages/admin.tsx @@ -27,9 +27,74 @@ import { type AdminPageProps = { groups: GroupsData settings: AppSettings + isDefaultPassword: boolean } -export default function AdminPage({ groups: initialGroups, settings: initialSettings }: AdminPageProps) { +// Компонент Toggle Switch +function ToggleSwitch({ checked, onChange, disabled }: { + checked: boolean + onChange: (checked: boolean) => void + disabled?: boolean +}) { + return ( + + ) +} + +// Компонент выбора курса +function CourseSelect({ value, onChange, id }: { + value: string + onChange: (value: string) => void + id: string +}) { + return ( + + ) +} + +// Компонент для DialogFooter с кнопками +function DialogFooterButtons({ onCancel, onSubmit, submitLabel, loading, submitVariant = 'default' }: { + onCancel: () => void + onSubmit?: () => void + submitLabel: string + loading?: boolean + submitVariant?: 'default' | 'destructive' +}) { + return ( + + + {onSubmit && ( + + )} + + ) +} + +export default function AdminPage({ groups: initialGroups, settings: initialSettings, isDefaultPassword: initialIsDefaultPassword }: AdminPageProps) { const [authenticated, setAuthenticated] = React.useState(null) const [password, setPassword] = React.useState('') const [loading, setLoading] = React.useState(false) @@ -40,8 +105,18 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett const [showAddDialog, setShowAddDialog] = React.useState(false) const [showEditDialog, setShowEditDialog] = React.useState(false) const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + const [showLogsDialog, setShowLogsDialog] = React.useState(false) + const [logs, setLogs] = React.useState('') + const [logsLoading, setLogsLoading] = React.useState(false) const [groupToDelete, setGroupToDelete] = React.useState(null) const [toasts, setToasts] = React.useState([]) + const [showChangePasswordDialog, setShowChangePasswordDialog] = React.useState(false) + const [isDefaultPassword, setIsDefaultPassword] = React.useState(initialIsDefaultPassword) + const [passwordFormData, setPasswordFormData] = React.useState({ + oldPassword: '', + newPassword: '', + confirmPassword: '' + }) const showToast = (message: string, type: 'success' | 'error' = 'success') => { const id = Date.now().toString() @@ -105,29 +180,22 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett } } - const loadGroupsList = async () => { + const loadData = async (endpoint: string, setter: (data: T) => void) => { try { - const res = await fetch('/api/admin/groups') + const res = await fetch(endpoint) const data = await res.json() if (data.groups) { - setGroups(data.groups) + setter(data.groups as T) + } else if (data.settings) { + setter(data.settings as T) } } catch (err) { - console.error('Error loading groups:', err) + console.error(`Error loading data from ${endpoint}:`, err) } } - const loadSettingsList = async () => { - try { - const res = await fetch('/api/admin/settings') - const data = await res.json() - if (data.settings) { - setSettings(data.settings) - } - } catch (err) { - console.error('Error loading settings:', err) - } - } + const loadGroupsList = () => loadData('/api/admin/groups', setGroups) + const loadSettingsList = () => loadData('/api/admin/settings', setSettings) const handleUpdateSettings = async (newSettings: AppSettings) => { // Сохраняем предыдущее состояние для отката при ошибке @@ -289,6 +357,29 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett setShowDeleteDialog(true) } + const loadLogs = async () => { + setLogsLoading(true) + try { + const res = await fetch('/api/admin/logs') + const data = await res.json() + if (data.success && data.logs) { + setLogs(data.logs) + } else { + setLogs(data.error || 'Не удалось загрузить логи') + } + } catch (err) { + setLogs('Ошибка при загрузке логов') + console.error('Error loading logs:', err) + } finally { + setLogsLoading(false) + } + } + + const handleOpenLogsDialog = () => { + setShowLogsDialog(true) + loadLogs() + } + if (authenticated === null) { return (
@@ -350,19 +441,27 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett

Админ-панель

- +
+ + +
{error && ( @@ -371,6 +470,34 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
)} + {isDefaultPassword && ( + + + Внимание: используется стандартный пароль + + Для безопасности рекомендуется сменить пароль на более надежный + + + + + + + )} + + + + Безопасность + Управление паролем администратора + + + + + + Настройки @@ -385,16 +512,24 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett Включить или выключить навигацию по неделям в расписании
- + handleUpdateSettings({ ...settings, weekNavigationEnabled: checked })} + disabled={loading} + /> + +
+
+
Кнопка "Добавить группу"
+
+ Отображать кнопку "Добавить группу" на главной странице +
+
+ handleUpdateSettings({ ...settings, showAddGroupButton: checked })} + disabled={loading} + />
@@ -468,22 +603,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett Принудительно использовать кэш, даже если он свежий (симулирует ошибку парсинга) - + handleUpdateSettings({ + ...settings, + debug: { + ...settings.debug, + forceCache: checked + } + })} + disabled={loading} + />
@@ -492,22 +622,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett Показать пустое расписание независимо от реальных данных
- + handleUpdateSettings({ + ...settings, + debug: { + ...settings.debug, + forceEmpty: checked + } + })} + disabled={loading} + />
@@ -516,22 +641,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett Показать страницу ошибки независимо от реальных данных
- + handleUpdateSettings({ + ...settings, + debug: { + ...settings.debug, + forceError: checked + } + })} + disabled={loading} + />
@@ -540,22 +660,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett Симулировать таймаут при загрузке расписания
- + handleUpdateSettings({ + ...settings, + debug: { + ...settings.debug, + forceTimeout: checked + } + })} + disabled={loading} + />
@@ -564,22 +679,17 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett Показать дополнительную информацию о кэше в интерфейсе
- + handleUpdateSettings({ + ...settings, + debug: { + ...settings.debug, + showCacheInfo: checked + } + })} + disabled={loading} + /> @@ -639,21 +749,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
- + onChange={(value) => setFormData({ ...formData, course: value })} + id="add-course" + />
@@ -712,21 +812,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
- + onChange={(value) => setFormData({ ...formData, course: value })} + id="edit-course" + />
@@ -751,17 +841,161 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett Это действие нельзя отменить. + setShowDeleteDialog(false)} + onSubmit={handleDeleteGroup} + submitLabel="Удалить" + loading={loading} + submitVariant="destructive" + /> + + + + {/* Диалог просмотра логов */} + + + + Логи ошибок + + Содержимое файла error.log + + +
+ {logsLoading ? ( +
Загрузка логов...
+ ) : ( +
+
+                  {logs || 'Логи пусты'}
+                
+ +
+ )} +
- -
+ {/* Диалог смены пароля */} + + + + Сменить пароль + + Введите старый пароль и новый пароль (минимум 8 символов) + + +
{ + 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) + } + }} + > +
+
+ + setPasswordFormData({ ...passwordFormData, oldPassword: e.target.value })} + required + autoFocus + /> +
+
+ + setPasswordFormData({ ...passwordFormData, newPassword: e.target.value })} + required + minLength={8} + /> +

+ Минимум 8 символов +

+
+
+ + setPasswordFormData({ ...passwordFormData, confirmPassword: e.target.value })} + required + minLength={8} + /> +
+
+ + + + +
+
+
+ {/* Toast уведомления */} @@ -771,11 +1005,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett export const getServerSideProps: GetServerSideProps = async () => { const groups = loadGroups() const settings = loadSettings() + + // Проверяем, используется ли дефолтный пароль + const { isDefaultPassword } = await import('@/shared/data/database') + const isDefault = await isDefaultPassword() + return { props: { groups, - settings + settings, + isDefaultPassword: isDefault } } } - diff --git a/src/pages/api/admin/change-password.ts b/src/pages/api/admin/change-password.ts new file mode 100644 index 0000000..4485b4f --- /dev/null +++ b/src/pages/api/admin/change-password.ts @@ -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 +) { + 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']) + diff --git a/src/pages/api/admin/groups.ts b/src/pages/api/admin/groups.ts index 926332d..d8297b9 100644 --- a/src/pages/api/admin/groups.ts +++ b/src/pages/api/admin/groups.ts @@ -1,20 +1,20 @@ import type { NextApiRequest, NextApiResponse } from 'next' -import { requireAuth } from '@/shared/utils/auth' -import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader' +import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper' +import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader' +import { validateGroupId, validateCourse } from '@/shared/utils/validation' -type ResponseData = { +type ResponseData = ApiResponse<{ groups?: GroupsData - success?: boolean - error?: string -} +}> async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method === 'GET') { - // Получение списка групп - const groups = loadGroups() + // Получение списка групп (всегда свежие данные для админ-панели) + clearGroupsCache() + const groups = loadGroups(true) res.status(200).json({ groups }) return } @@ -28,6 +28,11 @@ async function handler( return } + if (!validateGroupId(id)) { + res.status(400).json({ error: 'Group ID must contain only lowercase letters, numbers, dashes and underscores' }) + return + } + if (!parseId || typeof parseId !== 'number') { res.status(400).json({ error: 'Parse ID must be a number' }) return @@ -40,17 +45,11 @@ async function handler( // Валидация курса (1-5) const groupCourse = course !== undefined ? Number(course) : 1 - if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) { + if (!validateCourse(groupCourse)) { res.status(400).json({ error: 'Course must be a number between 1 and 5' }) return } - // Валидация ID (только латинские буквы, цифры, дефисы и подчеркивания) - if (!/^[a-z0-9_-]+$/.test(id)) { - res.status(400).json({ error: 'Group ID must contain only lowercase letters, numbers, dashes and underscores' }) - return - } - const groups = loadGroups() // Проверка на дубликат @@ -66,23 +65,14 @@ async function handler( course: groupCourse } - try { - saveGroups(groups) - res.status(200).json({ success: true, groups }) - } catch (error) { - console.error('Error saving groups:', error) - res.status(500).json({ error: 'Failed to save groups' }) - } + saveGroups(groups) + // Сбрасываем кеш и загружаем свежие данные из БД + clearGroupsCache() + const updatedGroups = loadGroups(true) + res.status(200).json({ success: true, groups: updatedGroups }) return } - - res.status(405).json({ error: 'Method not allowed' }) } -export default function protectedHandler( - req: NextApiRequest, - res: NextApiResponse -) { - return requireAuth(req, res, handler) -} +export default withAuth(handler, ['GET', 'POST']) diff --git a/src/pages/api/admin/groups/[id].ts b/src/pages/api/admin/groups/[id].ts index 7595f60..bef53a3 100644 --- a/src/pages/api/admin/groups/[id].ts +++ b/src/pages/api/admin/groups/[id].ts @@ -1,12 +1,11 @@ import type { NextApiRequest, NextApiResponse } from 'next' -import { requireAuth } from '@/shared/utils/auth' -import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader' +import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper' +import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader' +import { validateCourse } from '@/shared/utils/validation' -type ResponseData = { - success?: boolean +type ResponseData = ApiResponse<{ groups?: GroupsData - error?: string -} +}> async function handler( req: NextApiRequest, @@ -19,7 +18,8 @@ async function handler( return } - const groups = loadGroups() + // Загружаем группы с проверкой кеша + let groups = loadGroups() if (req.method === 'PUT') { // Редактирование группы @@ -40,12 +40,9 @@ async function handler( return } - if (course !== undefined) { - const groupCourse = Number(course) - if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) { - res.status(400).json({ error: 'Course must be a number between 1 and 5' }) - return - } + if (course !== undefined && !validateCourse(course)) { + res.status(400).json({ error: 'Course must be a number between 1 and 5' }) + return } // Обновляем группу @@ -56,13 +53,11 @@ async function handler( course: course !== undefined ? Number(course) : currentGroup.course } - try { - saveGroups(groups) - res.status(200).json({ success: true, groups }) - } catch (error) { - console.error('Error saving groups:', error) - res.status(500).json({ error: 'Failed to save groups' }) - } + saveGroups(groups) + // Сбрасываем кеш и загружаем свежие данные из БД + clearGroupsCache() + const updatedGroups = loadGroups(true) + res.status(200).json({ success: true, groups: updatedGroups }) return } @@ -75,23 +70,14 @@ async function handler( delete groups[id] - try { - saveGroups(groups) - res.status(200).json({ success: true, groups }) - } catch (error) { - console.error('Error saving groups:', error) - res.status(500).json({ error: 'Failed to save groups' }) - } + saveGroups(groups) + // Сбрасываем кеш и загружаем свежие данные из БД + clearGroupsCache() + const updatedGroups = loadGroups(true) + res.status(200).json({ success: true, groups: updatedGroups }) return } - - res.status(405).json({ error: 'Method not allowed' }) } -export default function protectedHandler( - req: NextApiRequest, - res: NextApiResponse -) { - return requireAuth(req, res, handler) -} +export default withAuth(handler, ['PUT', 'DELETE']) diff --git a/src/pages/api/admin/login.ts b/src/pages/api/admin/login.ts index 6b4881f..bd7af63 100644 --- a/src/pages/api/admin/login.ts +++ b/src/pages/api/admin/login.ts @@ -80,7 +80,7 @@ function recordFailedAttempt(ip: string): void { }) } -export default function handler( +export default async function handler( req: NextApiRequest, res: NextApiResponse ) { @@ -109,7 +109,8 @@ export default function handler( return } - if (verifyPassword(password)) { + const isValid = await verifyPassword(password) + if (isValid) { // Успешный вход - сбрасываем rate limit rateLimitMap.delete(clientIP) setSessionCookie(res) diff --git a/src/pages/api/admin/logs.ts b/src/pages/api/admin/logs.ts new file mode 100644 index 0000000..e1668a2 --- /dev/null +++ b/src/pages/api/admin/logs.ts @@ -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 +) { + // Путь к файлу логов (в корне проекта) + 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']) + diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index e3b9328..9ef880b 100644 --- a/src/pages/api/admin/settings.ts +++ b/src/pages/api/admin/settings.ts @@ -1,33 +1,37 @@ import type { NextApiRequest, NextApiResponse } from 'next' -import { requireAuth } from '@/shared/utils/auth' -import { loadSettings, saveSettings, AppSettings } from '@/shared/data/settings-loader' +import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper' +import { loadSettings, saveSettings, clearSettingsCache, AppSettings } from '@/shared/data/settings-loader' -type ResponseData = { +type ResponseData = ApiResponse<{ settings?: AppSettings - success?: boolean - error?: string -} +}> async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method === 'GET') { - // Получение настроек - const settings = loadSettings() + // Получение настроек (всегда свежие данные для админ-панели) + clearSettingsCache() + const settings = loadSettings(true) res.status(200).json({ settings }) return } if (req.method === 'PUT') { // Обновление настроек - const { weekNavigationEnabled, debug } = req.body + const { weekNavigationEnabled, showAddGroupButton, debug } = req.body if (typeof weekNavigationEnabled !== 'boolean') { res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' }) return } + if (showAddGroupButton !== undefined && typeof showAddGroupButton !== 'boolean') { + res.status(400).json({ error: 'showAddGroupButton must be a boolean' }) + return + } + // Валидация debug опций (только в dev режиме) if (debug !== undefined) { if (typeof debug !== 'object' || debug === null) { @@ -51,32 +55,20 @@ async function handler( const settings: AppSettings = { weekNavigationEnabled, + showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : true, ...(debug !== undefined && { debug }) } - try { - saveSettings(settings) - // Сбрасываем кеш и загружаем свежие настройки для подтверждения - const { clearSettingsCache } = await import('@/shared/data/settings-loader') - clearSettingsCache() - const savedSettings = loadSettings() - res.status(200).json({ success: true, settings: savedSettings }) - } catch (error) { - console.error('Error saving settings:', error) - res.status(500).json({ error: 'Failed to save settings' }) - } + saveSettings(settings) + // Сбрасываем кеш и загружаем свежие настройки для подтверждения + clearSettingsCache() + const savedSettings = loadSettings(true) + res.status(200).json({ success: true, settings: savedSettings }) return } - - res.status(405).json({ error: 'Method not allowed' }) } -export default function protectedHandler( - req: NextApiRequest, - res: NextApiResponse -) { - return requireAuth(req, res, handler) -} +export default withAuth(handler, ['GET', 'PUT']) diff --git a/src/pages/api/hello.ts b/src/pages/api/hello.ts deleted file mode 100644 index f8bcc7e..0000000 --- a/src/pages/api/hello.ts +++ /dev/null @@ -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 -) { - res.status(200).json({ name: 'John Doe' }) -} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ee499ea..708c766 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,6 +1,7 @@ import React from 'react' import { GetServerSideProps } from 'next' import { loadGroups, GroupsData } from '@/shared/data/groups-loader' +import { loadSettings } from '@/shared/data/settings-loader' import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card' import { Button } from '@/shadcn/ui/button' import { ThemeSwitcher } from '@/features/theme-switch' @@ -24,10 +25,11 @@ import { BsTelegram } from 'react-icons/bs' type HomePageProps = { groups: GroupsData groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } + showAddGroupButton: boolean } -export default function HomePage({ groups, groupsByCourse }: HomePageProps) { - const [openCourses, setOpenCourses] = React.useState>(new Set([1])) +export default function HomePage({ groups, groupsByCourse, showAddGroupButton }: HomePageProps) { + const [openCourses, setOpenCourses] = React.useState>(new Set()) const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false) // Подсчитываем смещения для каждого курса для последовательной анимации @@ -148,28 +150,30 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) { )}
-
- -
+ +
+ )}
)} diff --git a/src/shared/data/database.ts b/src/shared/data/database.ts new file mode 100644 index 0000000..7e6fdd8 --- /dev/null +++ b/src/shared/data/database.ts @@ -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): 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 + // Всегда добавляем дефолтные 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 { + 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 { + // Проверяем старый пароль + 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 { + 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 + } +} + diff --git a/src/shared/data/groups-loader.ts b/src/shared/data/groups-loader.ts index f60bf06..65b662c 100644 --- a/src/shared/data/groups-loader.ts +++ b/src/shared/data/groups-loader.ts @@ -1,5 +1,4 @@ -import fs from 'fs' -import path from 'path' +import { getAllGroups as getAllGroupsFromDB, createGroup, updateGroup, deleteGroup, getGroup } from './database' export type GroupInfo = { parseId: number @@ -9,120 +8,74 @@ export type GroupInfo = { export type GroupsData = { [group: string]: GroupInfo } -// Старый формат для миграции -type OldGroupsData = { [group: string]: [number, string] | GroupInfo } - let cachedGroups: GroupsData | null = null +let cacheTimestamp: number = 0 +const CACHE_TTL_MS = 1000 * 60 // 1 минута /** - * Мигрирует старый формат данных в новый + * Загружает группы из базы данных + * Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости */ -function migrateGroups(oldGroups: OldGroupsData): GroupsData { - const migrated: GroupsData = {} +export function loadGroups(forceRefresh: boolean = false): GroupsData { + const now = Date.now() + const isCacheValid = cachedGroups !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS - for (const [id, data] of Object.entries(oldGroups)) { - // Проверяем, является ли это старым форматом [parseId, name] - if (Array.isArray(data) && data.length === 2 && typeof data[0] === 'number' && typeof data[1] === 'string') { - // Старый формат - мигрируем - migrated[id] = { - parseId: data[0], - name: data[1], - course: 1 // По умолчанию курс 1 - } - } else if (typeof data === 'object' && 'parseId' in data && 'name' in data) { - // Уже новый формат - migrated[id] = data as GroupInfo - } - } - - return migrated -} - -/** - * Загружает группы из JSON файла - * Использует кеш для оптимизации в production - * Автоматически мигрирует старый формат в новый - */ -export function loadGroups(): GroupsData { - if (cachedGroups) { + if (isCacheValid && cachedGroups !== null) { return cachedGroups } - // В production Next.js может использовать другую структуру директорий - // Пробуем несколько путей - const possiblePaths = [ - path.join(process.cwd(), 'src/shared/data/groups.json'), - path.join(process.cwd(), '.next/standalone/src/shared/data/groups.json'), - path.join(process.cwd(), 'groups.json'), - ] - - for (const filePath of possiblePaths) { - try { - if (fs.existsSync(filePath)) { - const fileContents = fs.readFileSync(filePath, 'utf8') - const rawGroups = JSON.parse(fileContents) as OldGroupsData - - // Проверяем, нужна ли миграция - const needsMigration = Object.values(rawGroups).some( - data => Array.isArray(data) && data.length === 2 - ) - - let groups: GroupsData - if (needsMigration) { - // Мигрируем старый формат - groups = migrateGroups(rawGroups) - // Сохраняем мигрированные данные - const mainPath = path.join(process.cwd(), 'src/shared/data/groups.json') - if (filePath === mainPath) { - // Сохраняем только если это основной файл - fs.writeFileSync(mainPath, JSON.stringify(groups, null, 2), 'utf8') - console.log('Groups data migrated to new format') - } - } else { - groups = rawGroups as GroupsData - } - - cachedGroups = groups - return groups - } - } catch (error) { - // Пробуем следующий путь - continue - } + try { + cachedGroups = getAllGroupsFromDB() + cacheTimestamp = now + return cachedGroups + } catch (error) { + console.error('Error loading groups from database:', error) + // Fallback к пустому объекту + return {} } - - console.error('Error loading groups.json: file not found in any of the expected locations') - // Fallback к пустому объекту - return {} } /** - * Сохраняет группы в JSON файл + * Сохраняет группы в базу данных */ export function saveGroups(groups: GroupsData): void { - // Всегда сохраняем в основной путь - const filePath = path.join(process.cwd(), 'src/shared/data/groups.json') - try { - // Создаем директорию, если её нет - const dir = path.dirname(filePath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } + const existingGroups = getAllGroupsFromDB() - fs.writeFileSync(filePath, JSON.stringify(groups, null, 2), 'utf8') - // Сбрасываем кеш + // Определяем, какие группы нужно добавить, обновить или удалить + const existingIds = new Set(Object.keys(existingGroups)) + const newIds = new Set(Object.keys(groups)) + + // Добавляем или обновляем группы + for (const [id, group] of Object.entries(groups)) { + if (existingIds.has(id)) { + updateGroup(id, group) + } else { + createGroup(id, group) + } + } + + // Удаляем группы, которых больше нет + for (const id of existingIds) { + if (!newIds.has(id)) { + deleteGroup(id) + } + } + + // Сбрасываем кеш и timestamp cachedGroups = null + cacheTimestamp = 0 } catch (error) { - console.error('Error saving groups.json:', error) + console.error('Error saving groups to database:', error) throw new Error('Failed to save groups') } } /** - * Сбрасывает кеш групп (полезно после обновления файла) + * Сбрасывает кеш групп (полезно после обновления) */ export function clearGroupsCache(): void { cachedGroups = null + cacheTimestamp = 0 } diff --git a/src/shared/data/groups.json b/src/shared/data/groups.json deleted file mode 100644 index 826cc62..0000000 --- a/src/shared/data/groups.json +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/src/shared/data/groups.ts b/src/shared/data/groups.ts deleted file mode 100644 index 94f9c3e..0000000 --- a/src/shared/data/groups.ts +++ /dev/null @@ -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 } diff --git a/src/shared/data/settings-loader.ts b/src/shared/data/settings-loader.ts index a5d97a4..c7c78e6 100644 --- a/src/shared/data/settings-loader.ts +++ b/src/shared/data/settings-loader.ts @@ -1,8 +1,8 @@ -import fs from 'fs' -import path from 'path' +import { getSettings as getSettingsFromDB, updateSettings as updateSettingsInDB } from './database' export type AppSettings = { weekNavigationEnabled: boolean + showAddGroupButton: boolean debug?: { forceCache?: boolean forceEmpty?: boolean @@ -13,190 +13,64 @@ export type AppSettings = { } let cachedSettings: AppSettings | null = null -let cachedSettingsPath: string | null = null -let cachedSettingsMtime: number | null = null - -const defaultSettings: AppSettings = { - weekNavigationEnabled: true, - debug: { - forceCache: false, - forceEmpty: false, - forceError: false, - forceTimeout: false, - showCacheInfo: false - } -} +let cacheTimestamp: number = 0 +const CACHE_TTL_MS = 1000 * 60 // 1 минута /** - * Загружает настройки из JSON файла - * Проверяет время модификации файла для инвалидации кеша + * Загружает настройки из базы данных + * Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости */ -export function loadSettings(): AppSettings { - // В production Next.js может использовать другую структуру директорий - // Пробуем несколько путей - const possiblePaths = [ - path.join(process.cwd(), 'src/shared/data/settings.json'), - path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'), - path.join(process.cwd(), 'settings.json'), - ] +export function loadSettings(forceRefresh: boolean = false): AppSettings { + const now = Date.now() + const isCacheValid = cachedSettings !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS - // Ищем существующий файл - let foundPath: string | null = null - for (const filePath of possiblePaths) { - if (fs.existsSync(filePath)) { - foundPath = filePath - break - } + if (isCacheValid && cachedSettings !== null) { + return cachedSettings } - - // Если файл найден, проверяем, изменился ли он - if (foundPath) { - try { - const stats = fs.statSync(foundPath) - const mtime = stats.mtimeMs - - // Если файл изменился или путь изменился, сбрасываем кеш - if (cachedSettings && (cachedSettingsPath !== foundPath || cachedSettingsMtime !== mtime)) { - cachedSettings = null - cachedSettingsPath = null - cachedSettingsMtime = null - } - - // Если кеш валиден, возвращаем его - if (cachedSettings && cachedSettingsPath === foundPath && cachedSettingsMtime === mtime) { - return cachedSettings - } - - // Загружаем файл заново - const fileContents = fs.readFileSync(foundPath, 'utf8') - const settings = JSON.parse(fileContents) as AppSettings - - // Убеждаемся, что все обязательные поля присутствуют - const mergedSettings: AppSettings = { - ...defaultSettings, - ...settings, - debug: { - ...defaultSettings.debug, - ...settings.debug - } - } - - cachedSettings = mergedSettings - cachedSettingsPath = foundPath - cachedSettingsMtime = mtime - - return mergedSettings - } catch (error) { - console.error('Error reading settings.json:', error) - // Продолжаем дальше, чтобы создать файл с настройками по умолчанию - } - } - - // Если файл не найден, создаем его с настройками по умолчанию - const mainPath = path.join(process.cwd(), 'src/shared/data/settings.json') + try { - // Создаем директорию, если её нет - const dir = path.dirname(mainPath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - - fs.writeFileSync(mainPath, JSON.stringify(defaultSettings, null, 2), 'utf8') - - const stats = fs.statSync(mainPath) - cachedSettings = defaultSettings - cachedSettingsPath = mainPath - cachedSettingsMtime = stats.mtimeMs - - return defaultSettings + cachedSettings = getSettingsFromDB() + cacheTimestamp = now + return cachedSettings } catch (error) { - console.error('Error creating settings.json:', error) + console.error('Error loading settings from database:', error) // Возвращаем настройки по умолчанию + const defaultSettings: AppSettings = { + weekNavigationEnabled: false, + showAddGroupButton: true, + debug: { + forceCache: false, + forceEmpty: false, + forceError: false, + forceTimeout: false, + showCacheInfo: false + } + } return defaultSettings } } /** - * Сохраняет настройки в JSON файл + * Сохраняет настройки в базу данных */ export function saveSettings(settings: AppSettings): void { - // Сначала пытаемся найти существующий файл - const possiblePaths = [ - path.join(process.cwd(), 'src/shared/data/settings.json'), - path.join(process.cwd(), '.next/standalone/src/shared/data/settings.json'), - path.join(process.cwd(), 'settings.json'), - ] - - // Объединяем с настройками по умолчанию для сохранения всех полей - const mergedSettings: AppSettings = { - ...defaultSettings, - ...settings, - debug: { - ...defaultSettings.debug, - ...settings.debug - } - } - - // Ищем существующий файл - let targetPath: string | null = null - for (const filePath of possiblePaths) { - if (fs.existsSync(filePath)) { - targetPath = filePath - break - } - } - - // Если файл не найден, используем основной путь - if (!targetPath) { - targetPath = path.join(process.cwd(), 'src/shared/data/settings.json') - } - try { - // Создаем директорию, если её нет - const dir = path.dirname(targetPath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - - // Сохраняем файл - fs.writeFileSync(targetPath, JSON.stringify(mergedSettings, null, 2), 'utf8') - - // Обновляем кеш с новыми метаданными - try { - const stats = fs.statSync(targetPath) - cachedSettings = mergedSettings - cachedSettingsPath = targetPath - cachedSettingsMtime = stats.mtimeMs - } catch (error) { - // Если не удалось получить stats, просто обновляем кеш - cachedSettings = mergedSettings - cachedSettingsPath = targetPath - cachedSettingsMtime = null - } - - // Также сохраняем в другие возможные пути для совместимости (если они существуют) - for (const filePath of possiblePaths) { - if (filePath !== targetPath && fs.existsSync(path.dirname(filePath))) { - try { - fs.writeFileSync(filePath, JSON.stringify(mergedSettings, null, 2), 'utf8') - } catch (error) { - // Игнорируем ошибки при сохранении в дополнительные пути - } - } - } + updateSettingsInDB(settings) + // Сбрасываем кеш и timestamp + cachedSettings = null + cacheTimestamp = 0 } catch (error) { - console.error('Error saving settings.json:', error) + console.error('Error saving settings to database:', error) throw new Error('Failed to save settings') } } /** - * Сбрасывает кеш настроек (полезно после обновления файла) + * Сбрасывает кеш настроек (полезно после обновления) */ export function clearSettingsCache(): void { cachedSettings = null - cachedSettingsPath = null - cachedSettingsMtime = null + cacheTimestamp = 0 } diff --git a/src/shared/utils/api-wrapper.ts b/src/shared/utils/api-wrapper.ts new file mode 100644 index 0000000..a295f47 --- /dev/null +++ b/src/shared/utils/api-wrapper.ts @@ -0,0 +1,65 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import { requireAuth } from './auth' + +export type ApiHandler = ( + req: NextApiRequest, + res: NextApiResponse +) => void | Promise + +export type ApiResponse> = { + success?: boolean + error?: string +} & (T extends Record ? {} : Partial) + +/** + * Общий wrapper для защищенных API роутов + * Автоматически проверяет авторизацию и обрабатывает ошибки + */ +export function withAuth( + handler: ApiHandler>, + allowedMethods: string[] = ['GET', 'POST', 'PUT', 'DELETE'] +) { + return async (req: NextApiRequest, res: NextApiResponse>) => { + // Проверка метода + if (!allowedMethods.includes(req.method || '')) { + res.status(405).json({ error: 'Method not allowed' } as ApiResponse) + 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) + } + }) + } +} + +/** + * Общий wrapper для незащищенных API роутов + */ +export function withMethods( + handler: ApiHandler>, + allowedMethods: string[] = ['GET', 'POST', 'PUT', 'DELETE'] +) { + return async (req: NextApiRequest, res: NextApiResponse>) => { + // Проверка метода + if (!allowedMethods.includes(req.method || '')) { + res.status(405).json({ error: 'Method not allowed' } as ApiResponse) + 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) + } + } +} + diff --git a/src/shared/utils/auth.ts b/src/shared/utils/auth.ts index 521a819..b9ab54e 100644 --- a/src/shared/utils/auth.ts +++ b/src/shared/utils/auth.ts @@ -1,5 +1,6 @@ import { NextApiRequest, NextApiResponse } from 'next' import crypto from 'crypto' +import { verifyPassword as verifyPasswordFromDB } from '@/shared/data/database' const SESSION_COOKIE_NAME = 'admin_session' const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET @@ -23,29 +24,13 @@ function getSessionSecret(): string { /** * Проверяет пароль администратора - * Использует timing-safe сравнение для защиты от timing attacks + * Использует bcrypt для проверки хэшированного пароля из БД */ -export function verifyPassword(password: string): boolean { - const adminPassword = process.env.ADMIN_PASSWORD - if (!adminPassword) { - console.error('ADMIN_PASSWORD is not set') - return false - } - - // Используем timing-safe сравнение для защиты от timing attacks - if (password.length !== adminPassword.length) { - return false - } - +export async function verifyPassword(password: string): Promise { try { - const passwordBuffer = Buffer.from(password, 'utf8') - const adminPasswordBuffer = Buffer.from(adminPassword, 'utf8') - // Buffer в Node.js наследуется от Uint8Array и совместим с ArrayBufferView - return crypto.timingSafeEqual( - passwordBuffer as Uint8Array, - adminPasswordBuffer as Uint8Array - ) - } catch { + return await verifyPasswordFromDB(password) + } catch (error) { + console.error('Error verifying password:', error) return false } } @@ -146,6 +131,10 @@ export function requireAuth( res.status(401).json({ error: 'Unauthorized' }) return } - return handler(req, res) + const result = handler(req, res) + // Если handler возвращает Promise, возвращаем его + if (result instanceof Promise) { + return result + } } diff --git a/src/shared/utils/validation.ts b/src/shared/utils/validation.ts new file mode 100644 index 0000000..48c9c57 --- /dev/null +++ b/src/shared/utils/validation.ts @@ -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 +} +