diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac0613d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +*.db +*.db-journal +*.db-shm +*.db-wal +dist/ +.env +backend/uploads/videos/* +backend/uploads/files/* +!backend/uploads/videos/.gitkeep +!backend/uploads/files/.gitkeep diff --git a/README.md b/README.md index 9be96fe..14d45e6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,110 @@ -# Edus -- +# EduScale — Online Learning Platform MVP + +A full-stack online learning platform built with Node.js + Express + SQLite (backend) and React + Vite (frontend). + +## Features + +- **Auth**: Register/Login with JWT, roles: `student`, `instructor`, `admin` +- **Courses**: Create, publish, search/filter course catalog +- **Video Streaming**: Upload & stream videos with Range request support, progress tracking +- **Enrollments**: Enroll/unenroll in courses +- **Assignments**: Create, submit, and grade assignments +- **Discussions**: Course discussion threads with replies +- **Announcements**: Instructor announcements per course +- **Quizzes**: Create quizzes with auto-grading +- **Certificates**: PDF certificates on 100% course completion +- **Admin Dashboard**: User management, role changes, platform stats + +## Tech Stack + +| Layer | Technology | +|-----------|------------------------------------| +| Backend | Node.js, Express, SQLite (better-sqlite3) | +| Auth | bcryptjs, jsonwebtoken | +| File Upload | multer | +| PDF | pdfkit | +| Frontend | React 18, React Router v6, Vite | +| HTTP | Axios | + +## Project Structure + +``` +Edus/ +├── backend/ +│ ├── routes/ # API route handlers +│ ├── middleware/ # JWT auth middleware +│ ├── uploads/ # Uploaded files (videos, files) +│ ├── db.js # SQLite initialization +│ ├── schema.sql # Database schema +│ └── server.js # Express entry point +└── frontend/ + └── src/ + ├── pages/ # React pages + ├── components/ # Shared components + ├── context/ # Auth context + ├── api.js # Axios instance + └── index.css # Global styles +``` + +## Setup & Running + +### Prerequisites +- Node.js v18+ (v20+ recommended) +- npm + +### Backend + +```bash +cd backend +npm install +npm start +# Server runs on http://localhost:5000 +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +# App runs on http://localhost:3000 +``` + +### Environment Variables + +Backend `.env` (already created): +``` +JWT_SECRET=eduscale_super_secret_jwt_key_2024 +PORT=5000 +FRONTEND_URL=http://localhost:3000 +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | /api/register | Register user | +| POST | /api/login | Login | +| GET | /api/me | Current user | +| GET | /api/courses | List published courses | +| POST | /api/courses | Create course (instructor) | +| GET | /api/courses/:id | Course detail | +| POST | /api/enrollments | Enroll in course | +| POST | /api/videos/upload | Upload video | +| GET | /api/videos/:id | Stream video | +| PUT | /api/videos/:id/progress | Update watch progress | +| GET | /api/assignments/:courseId | List assignments | +| POST | /api/assignments/:id/submit | Submit assignment | +| GET | /api/discussions/:courseId | List discussions | +| POST | /api/quizzes/:id/attempt | Submit quiz | +| POST | /api/certificates/:courseId | Issue certificate | +| GET | /api/certificates/:courseId/download | Download PDF | +| GET | /api/admin/stats | Platform statistics (admin) | + +## Creating an Admin User + +Register normally, then update the role via SQLite or through an existing admin account: + +```bash +sqlite3 backend/eduscale.db "UPDATE users SET role='admin' WHERE email='your@email.com';" +``` diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..885cded --- /dev/null +++ b/backend/db.js @@ -0,0 +1,16 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const DB_PATH = path.join(__dirname, 'eduscale.db'); +const SCHEMA_PATH = path.join(__dirname, 'schema.sql'); + +const db = new Database(DB_PATH); + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +const schema = fs.readFileSync(SCHEMA_PATH, 'utf8'); +db.exec(schema); + +module.exports = db; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..a7299f9 --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,54 @@ +const jwt = require('jsonwebtoken'); +const db = require('../db'); + +function getJwtSecret() { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET environment variable is required'); + return secret; +} + +function authenticate(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No token provided' }); + } + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, getJwtSecret()); + const user = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(payload.id); + if (!user) return res.status(401).json({ error: 'User not found' }); + req.user = user; + next(); + } catch { + return res.status(401).json({ error: 'Invalid token' }); + } +} + +function optionalAuth(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + req.user = null; + return next(); + } + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, getJwtSecret()); + const user = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(payload.id); + req.user = user || null; + } catch { + req.user = null; + } + next(); +} + +function requireRole(...roles) { + return (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); + if (!roles.includes(req.user.role)) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + next(); + }; +} + +module.exports = { authenticate, optionalAuth, requireRole }; diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..3c3aaea --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2321 @@ +{ + "name": "eduscale-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eduscale-backend", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^12.9.0", + "cors": "^2.8.5", + "dotenv": "^16.4.1", + "express": "^4.18.2", + "express-rate-limit": "^8.3.2", + "jsonwebtoken": "^9.0.2", + "multer": "^2.1.1", + "pdfkit": "^0.14.0", + "uuid": "^9.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", + "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "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": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "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/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fontkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz", + "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.3.13", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "deep-equal": "^2.0.5", + "dfa": "^1.2.0", + "restructure": "^2.0.1", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pdfkit": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.14.0.tgz", + "integrity": "sha512-Hnor8/78jhHm6ONrxWhrqOwAVALlBnFyWOF8sstBZMiqHZgZ5A6RU+Q3yahhw82plxpT7LOfH3b3qcOX6rzMQg==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^1.8.1", + "linebreak": "^1.0.2", + "png-js": "^1.0.0" + } + }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "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/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/restructure": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", + "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..cd12c4a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "eduscale-backend", + "version": "1.0.0", + "type": "commonjs", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^12.9.0", + "cors": "^2.8.5", + "dotenv": "^16.4.1", + "express": "^4.18.2", + "express-rate-limit": "^8.3.2", + "jsonwebtoken": "^9.0.2", + "multer": "^2.1.1", + "pdfkit": "^0.14.0", + "uuid": "^9.0.0" + } +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..4b59c8c --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,49 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate, requireRole } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/admin/users', authenticate, requireRole('admin'), (req, res) => { + const users = db.prepare('SELECT id, name, email, role, created_at FROM users ORDER BY created_at DESC').all(); + res.json({ users }); +}); + +router.put('/admin/users/:id/role', authenticate, requireRole('admin'), (req, res) => { + const { role } = req.body; + if (!['student', 'instructor', 'admin'].includes(role)) { + return res.status(400).json({ error: 'Invalid role' }); + } + const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'User not found' }); + + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id); + const updated = db.prepare('SELECT id, name, email, role, created_at FROM users WHERE id = ?').get(req.params.id); + res.json({ user: updated }); +}); + +router.get('/admin/stats', authenticate, requireRole('admin'), (req, res) => { + const userCount = db.prepare('SELECT COUNT(*) AS cnt FROM users').get().cnt; + const courseCount = db.prepare('SELECT COUNT(*) AS cnt FROM courses').get().cnt; + const publishedCount = db.prepare("SELECT COUNT(*) AS cnt FROM courses WHERE status='published'").get().cnt; + const enrollmentCount = db.prepare('SELECT COUNT(*) AS cnt FROM enrollments').get().cnt; + const studentCount = db.prepare("SELECT COUNT(*) AS cnt FROM users WHERE role='student'").get().cnt; + const instructorCount = db.prepare("SELECT COUNT(*) AS cnt FROM users WHERE role='instructor'").get().cnt; + const submissionCount = db.prepare('SELECT COUNT(*) AS cnt FROM submissions').get().cnt; + const certificateCount = db.prepare('SELECT COUNT(*) AS cnt FROM certificates').get().cnt; + + res.json({ + stats: { + userCount, + courseCount, + publishedCount, + enrollmentCount, + studentCount, + instructorCount, + submissionCount, + certificateCount + } + }); +}); + +module.exports = router; diff --git a/backend/routes/announcements.js b/backend/routes/announcements.js new file mode 100644 index 0000000..0c2b8ca --- /dev/null +++ b/backend/routes/announcements.js @@ -0,0 +1,36 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate, requireRole } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/announcements/:courseId', authenticate, (req, res) => { + const announcements = db.prepare(` + SELECT a.*, u.name AS instructor_name + FROM announcements a + JOIN users u ON a.instructor_id = u.id + WHERE a.course_id = ? + ORDER BY a.created_at DESC + `).all(req.params.courseId); + res.json({ announcements }); +}); + +router.post('/announcements', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const { course_id, title, body } = req.body; + if (!course_id || !title) return res.status(400).json({ error: 'course_id and title are required' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(course_id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const result = db.prepare( + 'INSERT INTO announcements (course_id, instructor_id, title, body) VALUES (?, ?, ?, ?)' + ).run(course_id, req.user.id, title, body || null); + + const announcement = db.prepare('SELECT * FROM announcements WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ announcement }); +}); + +module.exports = router; diff --git a/backend/routes/assignments.js b/backend/routes/assignments.js new file mode 100644 index 0000000..5d6fbde --- /dev/null +++ b/backend/routes/assignments.js @@ -0,0 +1,152 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const db = require('../db'); +const { authenticate, requireRole } = require('../middleware/auth'); + +const router = express.Router(); + +const MAX_ASSIGNMENT_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB + +const storage = multer.diskStorage({ + destination: path.join(__dirname, '../uploads/files'), + filename: (req, file, cb) => { + const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + cb(null, unique + path.extname(file.originalname)); + } +}); +const upload = multer({ storage, limits: { fileSize: MAX_ASSIGNMENT_FILE_SIZE_BYTES } }); + +router.get('/assignments/:courseId', authenticate, (req, res) => { + const assignments = db.prepare( + 'SELECT * FROM assignments WHERE course_id = ? ORDER BY due_date ASC' + ).all(req.params.courseId); + res.json({ assignments }); +}); + +router.post('/assignments', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const { course_id, section_id, title, description, due_date, max_points } = req.body; + if (!course_id || !title) return res.status(400).json({ error: 'course_id and title are required' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(course_id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const result = db.prepare( + `INSERT INTO assignments (course_id, section_id, title, description, due_date, max_points) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(course_id, section_id || null, title, description || null, due_date || null, max_points || 100); + + const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ assignment }); +}); + +router.put('/assignments/:id', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); + if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(assignment.course_id); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const { title, description, due_date, max_points } = req.body; + db.prepare( + 'UPDATE assignments SET title=?, description=?, due_date=?, max_points=? WHERE id=?' + ).run( + title ?? assignment.title, + description ?? assignment.description, + due_date ?? assignment.due_date, + max_points ?? assignment.max_points, + assignment.id + ); + const updated = db.prepare('SELECT * FROM assignments WHERE id = ?').get(assignment.id); + res.json({ assignment: updated }); +}); + +router.delete('/assignments/:id', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); + if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(assignment.course_id); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + db.prepare('DELETE FROM assignments WHERE id = ?').run(assignment.id); + res.json({ message: 'Assignment deleted' }); +}); + +router.post('/assignments/:id/submit', authenticate, upload.single('file'), (req, res) => { + const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); + if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + + const enrollment = db.prepare('SELECT * FROM enrollments WHERE user_id = ? AND course_id = ?') + .get(req.user.id, assignment.course_id); + if (!enrollment) return res.status(403).json({ error: 'Not enrolled in this course' }); + + const { content } = req.body; + const file_path = req.file ? req.file.filename : null; + + const existing = db.prepare('SELECT * FROM submissions WHERE assignment_id = ? AND user_id = ?') + .get(assignment.id, req.user.id); + + let submission; + if (existing) { + db.prepare( + "UPDATE submissions SET content=?, file_path=?, submitted_at=datetime('now') WHERE id=?" + ).run(content || null, file_path || existing.file_path, existing.id); + submission = db.prepare('SELECT * FROM submissions WHERE id = ?').get(existing.id); + } else { + const result = db.prepare( + 'INSERT INTO submissions (assignment_id, user_id, content, file_path) VALUES (?, ?, ?, ?)' + ).run(assignment.id, req.user.id, content || null, file_path); + submission = db.prepare('SELECT * FROM submissions WHERE id = ?').get(result.lastInsertRowid); + } + res.status(201).json({ submission }); +}); + +router.get('/assignments/:id/submissions', authenticate, (req, res) => { + const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); + if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(assignment.course_id); + const isInstructor = course.instructor_id === req.user.id || req.user.role === 'admin'; + + let submissions; + if (isInstructor) { + submissions = db.prepare(` + SELECT s.*, u.name AS student_name, u.email AS student_email + FROM submissions s + JOIN users u ON s.user_id = u.id + WHERE s.assignment_id = ? + ORDER BY s.submitted_at DESC + `).all(assignment.id); + } else { + submissions = db.prepare('SELECT * FROM submissions WHERE assignment_id = ? AND user_id = ?') + .all(assignment.id, req.user.id); + } + res.json({ submissions }); +}); + +router.put('/assignments/:id/grade/:submissionId', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); + if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(assignment.course_id); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const { grade, feedback } = req.body; + db.prepare( + "UPDATE submissions SET grade=?, feedback=?, graded_at=datetime('now') WHERE id=? AND assignment_id=?" + ).run(grade, feedback || null, req.params.submissionId, assignment.id); + + const submission = db.prepare('SELECT * FROM submissions WHERE id = ?').get(req.params.submissionId); + res.json({ submission }); +}); + +module.exports = router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..5f488ec --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,72 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +function getJwtSecret() { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET environment variable is required'); + return secret; +} + +function signToken(user) { + return jwt.sign({ id: user.id, role: user.role }, getJwtSecret(), { expiresIn: '7d' }); +} + +function isValidEmail(email) { + if (typeof email !== 'string') return false; + const atIndex = email.indexOf('@') + if (atIndex < 1) return false; + const lastDotIndex = email.lastIndexOf('.') + return lastDotIndex > atIndex + 1 && lastDotIndex < email.length - 1; +} + +router.post('/register', (req, res) => { + const { name, email, password, role } = req.body; + if (!name || !email || !password) { + return res.status(400).json({ error: 'name, email, and password are required' }); + } + if (!isValidEmail(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + const allowedRoles = ['student', 'instructor']; + const userRole = allowedRoles.includes(role) ? role : 'student'; + + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existing) return res.status(409).json({ error: 'Email already registered' }); + + const password_hash = bcrypt.hashSync(password, 10); + const result = db.prepare( + 'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)' + ).run(name, email, password_hash, userRole); + + const user = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(result.lastInsertRowid); + const token = signToken(user); + res.status(201).json({ token, user }); +}); + +router.post('/login', (req, res) => { + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ error: 'email and password are required' }); + } + const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email); + if (!user || !bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + const { password_hash, ...safeUser } = user; + const token = signToken(safeUser); + res.json({ token, user: safeUser }); +}); + +router.get('/me', authenticate, (req, res) => { + res.json({ user: req.user }); +}); + +module.exports = router; diff --git a/backend/routes/certificates.js b/backend/routes/certificates.js new file mode 100644 index 0000000..76ef818 --- /dev/null +++ b/backend/routes/certificates.js @@ -0,0 +1,114 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const PDFDocument = require('pdfkit'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +router.post('/certificates/:courseId', authenticate, (req, res) => { + const { courseId } = req.params; + + const enrollment = db.prepare('SELECT * FROM enrollments WHERE user_id = ? AND course_id = ?') + .get(req.user.id, courseId); + if (!enrollment) return res.status(403).json({ error: 'Not enrolled in this course' }); + + const totalVideos = db.prepare('SELECT COUNT(*) AS cnt FROM videos WHERE course_id = ?').get(courseId).cnt; + const completedVideos = db.prepare(` + SELECT COUNT(*) AS cnt + FROM progress p + JOIN videos v ON p.video_id = v.id + WHERE p.user_id = ? AND v.course_id = ? AND p.completed = 1 + `).get(req.user.id, courseId).cnt; + + if (totalVideos > 0 && completedVideos < totalVideos) { + return res.status(400).json({ error: 'Course not 100% completed yet', progress: (completedVideos / totalVideos) * 100 }); + } + + const existing = db.prepare('SELECT * FROM certificates WHERE user_id = ? AND course_id = ?') + .get(req.user.id, courseId); + if (existing) return res.json({ certificate: existing }); + + const cert_uuid = uuidv4(); + const result = db.prepare( + 'INSERT INTO certificates (user_id, course_id, cert_uuid) VALUES (?, ?, ?)' + ).run(req.user.id, courseId, cert_uuid); + + const certificate = db.prepare('SELECT * FROM certificates WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ certificate }); +}); + +router.get('/certificates/:courseId', authenticate, (req, res) => { + const certificate = db.prepare('SELECT * FROM certificates WHERE user_id = ? AND course_id = ?') + .get(req.user.id, req.params.courseId); + if (!certificate) return res.status(404).json({ error: 'Certificate not found' }); + res.json({ certificate }); +}); + +router.get('/certificates/:courseId/download', authenticate, (req, res) => { + const certificate = db.prepare('SELECT * FROM certificates WHERE user_id = ? AND course_id = ?') + .get(req.user.id, req.params.courseId); + if (!certificate) return res.status(404).json({ error: 'Certificate not found' }); + + const user = db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id); + const course = db.prepare('SELECT title FROM courses WHERE id = ?').get(req.params.courseId); + + const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 50 }); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="certificate-${certificate.cert_uuid}.pdf"`); + doc.pipe(res); + + doc.rect(0, 0, doc.page.width, doc.page.height).fill('#f8f6ff'); + + doc.rect(20, 20, doc.page.width - 40, doc.page.height - 40) + .lineWidth(3).stroke('#4f46e5'); + + doc.rect(30, 30, doc.page.width - 60, doc.page.height - 60) + .lineWidth(1).stroke('#818cf8'); + + doc.fillColor('#4f46e5') + .fontSize(42) + .font('Helvetica-Bold') + .text('EduScale', 0, 80, { align: 'center' }); + + doc.fillColor('#374151') + .fontSize(20) + .font('Helvetica') + .text('Certificate of Completion', 0, 140, { align: 'center' }); + + doc.fillColor('#6b7280') + .fontSize(14) + .text('This is to certify that', 0, 200, { align: 'center' }); + + doc.fillColor('#111827') + .fontSize(32) + .font('Helvetica-Bold') + .text(user.name, 0, 230, { align: 'center' }); + + doc.fillColor('#6b7280') + .fontSize(14) + .font('Helvetica') + .text('has successfully completed the course', 0, 290, { align: 'center' }); + + doc.fillColor('#4f46e5') + .fontSize(24) + .font('Helvetica-Bold') + .text(course.title, 0, 320, { align: 'center' }); + + const issuedDate = new Date(certificate.issued_at).toLocaleDateString('en-US', { + year: 'numeric', month: 'long', day: 'numeric' + }); + + doc.fillColor('#6b7280') + .fontSize(12) + .font('Helvetica') + .text(`Issued on: ${issuedDate}`, 0, 390, { align: 'center' }); + + doc.fillColor('#9ca3af') + .fontSize(9) + .text(`Certificate ID: ${certificate.cert_uuid}`, 0, 420, { align: 'center' }); + + doc.end(); +}); + +module.exports = router; diff --git a/backend/routes/courses.js b/backend/routes/courses.js new file mode 100644 index 0000000..c745ca8 --- /dev/null +++ b/backend/routes/courses.js @@ -0,0 +1,124 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate, optionalAuth, requireRole } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/courses', optionalAuth, (req, res) => { + const { search, category } = req.query; + let query = ` + SELECT c.*, u.name AS instructor_name + FROM courses c + JOIN users u ON c.instructor_id = u.id + WHERE c.status = 'published' + `; + const params = []; + if (search) { + query += ` AND (c.title LIKE ? OR c.description LIKE ?)`; + params.push(`%${search}%`, `%${search}%`); + } + if (category) { + query += ` AND c.category = ?`; + params.push(category); + } + query += ' ORDER BY c.created_at DESC'; + const courses = db.prepare(query).all(...params); + res.json({ courses }); +}); + +router.post('/courses', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const { title, description, category, status, price, thumbnail } = req.body; + if (!title) return res.status(400).json({ error: 'title is required' }); + const result = db.prepare( + `INSERT INTO courses (instructor_id, title, description, category, status, price, thumbnail) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(req.user.id, title, description || null, category || null, + status === 'published' ? 'published' : 'draft', + price || 0, thumbnail || null); + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ course }); +}); + +router.get('/courses/:id', optionalAuth, (req, res) => { + const course = db.prepare(` + SELECT c.*, u.name AS instructor_name + FROM courses c + JOIN users u ON c.instructor_id = u.id + WHERE c.id = ? + `).get(req.params.id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + + const sections = db.prepare('SELECT * FROM sections WHERE course_id = ? ORDER BY order_index').all(course.id); + for (const section of sections) { + section.videos = db.prepare('SELECT * FROM videos WHERE section_id = ? ORDER BY order_index').all(section.id); + section.assignments = db.prepare('SELECT * FROM assignments WHERE section_id = ? ORDER BY created_at').all(section.id); + section.quizzes = db.prepare('SELECT * FROM quizzes WHERE section_id = ? ORDER BY created_at').all(section.id); + } + + let enrolled = false; + if (req.user) { + const enrollment = db.prepare('SELECT id FROM enrollments WHERE user_id = ? AND course_id = ?').get(req.user.id, course.id); + enrolled = !!enrollment; + } + + res.json({ course, sections, enrolled }); +}); + +router.put('/courses/:id', authenticate, (req, res) => { + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + const { title, description, category, status, price, thumbnail } = req.body; + db.prepare(` + UPDATE courses SET title=?, description=?, category=?, status=?, price=?, thumbnail=? WHERE id=? + `).run( + title ?? course.title, + description ?? course.description, + category ?? course.category, + status ?? course.status, + price ?? course.price, + thumbnail ?? course.thumbnail, + course.id + ); + const updated = db.prepare('SELECT * FROM courses WHERE id = ?').get(course.id); + res.json({ course: updated }); +}); + +router.delete('/courses/:id', authenticate, (req, res) => { + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + db.prepare('DELETE FROM courses WHERE id = ?').run(course.id); + res.json({ message: 'Course deleted' }); +}); + +router.post('/courses/:id/sections', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + const { title, order_index } = req.body; + if (!title) return res.status(400).json({ error: 'title is required' }); + const result = db.prepare( + 'INSERT INTO sections (course_id, title, order_index) VALUES (?, ?, ?)' + ).run(course.id, title, order_index || 0); + const section = db.prepare('SELECT * FROM sections WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ section }); +}); + +router.delete('/courses/:id/sections/:sectionId', authenticate, (req, res) => { + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + db.prepare('DELETE FROM sections WHERE id = ? AND course_id = ?').run(req.params.sectionId, course.id); + res.json({ message: 'Section deleted' }); +}); + +module.exports = router; diff --git a/backend/routes/discussions.js b/backend/routes/discussions.js new file mode 100644 index 0000000..306bc2f --- /dev/null +++ b/backend/routes/discussions.js @@ -0,0 +1,75 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/discussions/:courseId', authenticate, (req, res) => { + const discussions = db.prepare(` + SELECT d.*, u.name AS author_name, + (SELECT COUNT(*) FROM discussion_replies WHERE discussion_id = d.id) AS reply_count + FROM discussions d + JOIN users u ON d.user_id = u.id + WHERE d.course_id = ? + ORDER BY d.created_at DESC + `).all(req.params.courseId); + res.json({ discussions }); +}); + +router.post('/discussions', authenticate, (req, res) => { + const { course_id, title, body } = req.body; + if (!course_id || !title) return res.status(400).json({ error: 'course_id and title are required' }); + + const enrollment = db.prepare('SELECT * FROM enrollments WHERE user_id = ? AND course_id = ?') + .get(req.user.id, course_id); + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(course_id); + const isInstructor = course && (course.instructor_id === req.user.id || req.user.role === 'admin'); + + if (!enrollment && !isInstructor) { + return res.status(403).json({ error: 'Must be enrolled or instructor to post discussions' }); + } + + const result = db.prepare( + 'INSERT INTO discussions (course_id, user_id, title, body) VALUES (?, ?, ?, ?)' + ).run(course_id, req.user.id, title, body || null); + + const discussion = db.prepare('SELECT * FROM discussions WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ discussion }); +}); + +router.get('/discussions/:courseId/:discussionId', authenticate, (req, res) => { + const discussion = db.prepare(` + SELECT d.*, u.name AS author_name + FROM discussions d + JOIN users u ON d.user_id = u.id + WHERE d.id = ? AND d.course_id = ? + `).get(req.params.discussionId, req.params.courseId); + if (!discussion) return res.status(404).json({ error: 'Discussion not found' }); + + const replies = db.prepare(` + SELECT r.*, u.name AS author_name + FROM discussion_replies r + JOIN users u ON r.user_id = u.id + WHERE r.discussion_id = ? + ORDER BY r.created_at ASC + `).all(discussion.id); + + res.json({ discussion, replies }); +}); + +router.post('/discussions/:discussionId/reply', authenticate, (req, res) => { + const discussion = db.prepare('SELECT * FROM discussions WHERE id = ?').get(req.params.discussionId); + if (!discussion) return res.status(404).json({ error: 'Discussion not found' }); + + const { body } = req.body; + if (!body) return res.status(400).json({ error: 'body is required' }); + + const result = db.prepare( + 'INSERT INTO discussion_replies (discussion_id, user_id, body) VALUES (?, ?, ?)' + ).run(discussion.id, req.user.id, body); + + const reply = db.prepare('SELECT * FROM discussion_replies WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ reply }); +}); + +module.exports = router; diff --git a/backend/routes/enrollments.js b/backend/routes/enrollments.js new file mode 100644 index 0000000..2c32579 --- /dev/null +++ b/backend/routes/enrollments.js @@ -0,0 +1,47 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +router.post('/enrollments', authenticate, (req, res) => { + const { course_id } = req.body; + if (!course_id) return res.status(400).json({ error: 'course_id is required' }); + + const course = db.prepare("SELECT * FROM courses WHERE id = ? AND status = 'published'").get(course_id); + if (!course) return res.status(404).json({ error: 'Course not found or not published' }); + + const existing = db.prepare('SELECT * FROM enrollments WHERE user_id = ? AND course_id = ?').get(req.user.id, course_id); + if (existing) return res.status(409).json({ error: 'Already enrolled' }); + + const result = db.prepare('INSERT INTO enrollments (user_id, course_id) VALUES (?, ?)').run(req.user.id, course_id); + const enrollment = db.prepare('SELECT * FROM enrollments WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ enrollment }); +}); + +router.get('/enrollments', authenticate, (req, res) => { + const enrollments = db.prepare(` + SELECT e.*, c.title AS course_title, c.description, c.thumbnail, c.category, + u.name AS instructor_name + FROM enrollments e + JOIN courses c ON e.course_id = c.id + JOIN users u ON c.instructor_id = u.id + WHERE e.user_id = ? + ORDER BY e.enrolled_at DESC + `).all(req.user.id); + res.json({ enrollments }); +}); + +router.get('/enrollments/:courseId', authenticate, (req, res) => { + const enrollment = db.prepare('SELECT * FROM enrollments WHERE user_id = ? AND course_id = ?') + .get(req.user.id, req.params.courseId); + res.json({ enrolled: !!enrollment, enrollment: enrollment || null }); +}); + +router.delete('/enrollments/:courseId', authenticate, (req, res) => { + db.prepare('DELETE FROM enrollments WHERE user_id = ? AND course_id = ?') + .run(req.user.id, req.params.courseId); + res.json({ message: 'Unenrolled successfully' }); +}); + +module.exports = router; diff --git a/backend/routes/progress.js b/backend/routes/progress.js new file mode 100644 index 0000000..20c2438 --- /dev/null +++ b/backend/routes/progress.js @@ -0,0 +1,41 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/progress/:courseId', authenticate, (req, res) => { + const { courseId } = req.params; + + const totalVideos = db.prepare('SELECT COUNT(*) AS cnt FROM videos WHERE course_id = ?').get(courseId).cnt; + + if (totalVideos === 0) { + return res.json({ progress: 0, completed: 0, total: 0 }); + } + + const completedVideos = db.prepare(` + SELECT COUNT(*) AS cnt + FROM progress p + JOIN videos v ON p.video_id = v.id + WHERE p.user_id = ? AND v.course_id = ? AND p.completed = 1 + `).get(req.user.id, courseId).cnt; + + const percentage = (completedVideos / totalVideos) * 100; + + const videoProgress = db.prepare(` + SELECT v.id, v.title, v.duration, p.watched_seconds, p.completed, p.last_watched + FROM videos v + LEFT JOIN progress p ON p.video_id = v.id AND p.user_id = ? + WHERE v.course_id = ? + ORDER BY v.order_index + `).all(req.user.id, courseId); + + res.json({ + progress: Math.round(percentage * 100) / 100, + completed: completedVideos, + total: totalVideos, + videos: videoProgress + }); +}); + +module.exports = router; diff --git a/backend/routes/quizzes.js b/backend/routes/quizzes.js new file mode 100644 index 0000000..5e8abf4 --- /dev/null +++ b/backend/routes/quizzes.js @@ -0,0 +1,112 @@ +const express = require('express'); +const db = require('../db'); +const { authenticate, requireRole } = require('../middleware/auth'); + +const router = express.Router(); + +router.get('/quizzes/:courseId', authenticate, (req, res) => { + const quizzes = db.prepare('SELECT * FROM quizzes WHERE course_id = ? ORDER BY created_at').all(req.params.courseId); + res.json({ quizzes }); +}); + +router.post('/quizzes', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const { course_id, section_id, title } = req.body; + if (!course_id || !title) return res.status(400).json({ error: 'course_id and title are required' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(course_id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const result = db.prepare('INSERT INTO quizzes (course_id, section_id, title) VALUES (?, ?, ?)') + .run(course_id, section_id || null, title); + const quiz = db.prepare('SELECT * FROM quizzes WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ quiz }); +}); + +router.post('/quizzes/:id/questions', authenticate, requireRole('instructor', 'admin'), (req, res) => { + const quiz = db.prepare('SELECT * FROM quizzes WHERE id = ?').get(req.params.id); + if (!quiz) return res.status(404).json({ error: 'Quiz not found' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(quiz.course_id); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const { question, options, correct_answer, points } = req.body; + if (!question || !options || !correct_answer) { + return res.status(400).json({ error: 'question, options, and correct_answer are required' }); + } + + const options_json = Array.isArray(options) ? JSON.stringify(options) : options; + const result = db.prepare( + 'INSERT INTO quiz_questions (quiz_id, question, options_json, correct_answer, points) VALUES (?, ?, ?, ?, ?)' + ).run(quiz.id, question, options_json, correct_answer, points || 1); + + const qq = db.prepare('SELECT * FROM quiz_questions WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ question: qq }); +}); + +router.post('/quizzes/:id/attempt', authenticate, (req, res) => { + const quiz = db.prepare('SELECT * FROM quizzes WHERE id = ?').get(req.params.id); + if (!quiz) return res.status(404).json({ error: 'Quiz not found' }); + + const enrollment = db.prepare('SELECT * FROM enrollments WHERE user_id = ? AND course_id = ?') + .get(req.user.id, quiz.course_id); + if (!enrollment) return res.status(403).json({ error: 'Not enrolled in this course' }); + + const { answers } = req.body; + if (!answers || typeof answers !== 'object') { + return res.status(400).json({ error: 'answers object is required (questionId -> answer)' }); + } + + const questions = db.prepare('SELECT * FROM quiz_questions WHERE quiz_id = ?').all(quiz.id); + let score = 0; + let total_points = 0; + const gradedAnswers = {}; + + for (const q of questions) { + total_points += q.points; + const userAnswer = answers[String(q.id)]; + const correct = userAnswer === q.correct_answer; + if (correct) score += q.points; + gradedAnswers[q.id] = { userAnswer, correct, correct_answer: q.correct_answer, points: q.points }; + } + + const answers_json = JSON.stringify(answers); + const result = db.prepare( + 'INSERT INTO quiz_attempts (quiz_id, user_id, answers_json, score, total_points) VALUES (?, ?, ?, ?, ?)' + ).run(quiz.id, req.user.id, answers_json, score, total_points); + + const attempt = db.prepare('SELECT * FROM quiz_attempts WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ attempt, gradedAnswers, score, total_points }); +}); + +router.get('/quizzes/:id/results', authenticate, (req, res) => { + const quiz = db.prepare('SELECT * FROM quizzes WHERE id = ?').get(req.params.id); + if (!quiz) return res.status(404).json({ error: 'Quiz not found' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(quiz.course_id); + const isInstructor = course.instructor_id === req.user.id || req.user.role === 'admin'; + + const questions = db.prepare('SELECT * FROM quiz_questions WHERE quiz_id = ?').all(quiz.id); + + let attempts; + if (isInstructor) { + attempts = db.prepare(` + SELECT a.*, u.name AS student_name + FROM quiz_attempts a + JOIN users u ON a.user_id = u.id + WHERE a.quiz_id = ? + ORDER BY a.attempted_at DESC + `).all(quiz.id); + } else { + attempts = db.prepare('SELECT * FROM quiz_attempts WHERE quiz_id = ? AND user_id = ? ORDER BY attempted_at DESC') + .all(quiz.id, req.user.id); + } + + res.json({ quiz, questions, attempts }); +}); + +module.exports = router; diff --git a/backend/routes/videos.js b/backend/routes/videos.js new file mode 100644 index 0000000..708259f --- /dev/null +++ b/backend/routes/videos.js @@ -0,0 +1,110 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const multer = require('multer'); +const db = require('../db'); +const { authenticate, optionalAuth } = require('../middleware/auth'); + +const router = express.Router(); + +const MAX_VIDEO_SIZE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB + +const storage = multer.diskStorage({ + destination: path.join(__dirname, '../uploads/videos'), + filename: (req, file, cb) => { + const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + cb(null, unique + path.extname(file.originalname)); + } +}); +const upload = multer({ storage, limits: { fileSize: MAX_VIDEO_SIZE_BYTES } }); + +router.post('/videos/upload', authenticate, upload.single('video'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'No video file provided' }); + const { course_id, section_id, title, duration, order_index } = req.body; + if (!course_id || !title) return res.status(400).json({ error: 'course_id and title are required' }); + + const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(course_id); + if (!course) return res.status(404).json({ error: 'Course not found' }); + if (course.instructor_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden' }); + } + + const file_path = req.file.filename; + const result = db.prepare( + `INSERT INTO videos (course_id, section_id, title, file_path, duration, order_index) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(course_id, section_id || null, title, file_path, duration || 0, order_index || 0); + + const video = db.prepare('SELECT * FROM videos WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ video }); +}); + +router.get('/videos/:id', optionalAuth, (req, res) => { + const video = db.prepare('SELECT * FROM videos WHERE id = ?').get(req.params.id); + if (!video || !video.file_path) return res.status(404).json({ error: 'Video not found' }); + + const filePath = path.join(__dirname, '../uploads/videos', video.file_path); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Video file not found' }); + + const stat = fs.statSync(filePath); + const fileSize = stat.size; + const range = req.headers.range; + + if (range) { + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunkSize = end - start + 1; + const file = fs.createReadStream(filePath, { start, end }); + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunkSize, + 'Content-Type': 'video/mp4' + }); + file.pipe(res); + } else { + res.writeHead(200, { + 'Content-Length': fileSize, + 'Content-Type': 'video/mp4', + 'Accept-Ranges': 'bytes' + }); + fs.createReadStream(filePath).pipe(res); + } +}); + +router.put('/videos/:id/progress', authenticate, (req, res) => { + const { watched_seconds, completed } = req.body; + const video = db.prepare('SELECT * FROM videos WHERE id = ?').get(req.params.id); + if (!video) return res.status(404).json({ error: 'Video not found' }); + + db.prepare(` + INSERT INTO progress (user_id, video_id, watched_seconds, completed, last_watched) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id, video_id) DO UPDATE SET + watched_seconds = excluded.watched_seconds, + completed = excluded.completed, + last_watched = excluded.last_watched + `).run(req.user.id, video.id, watched_seconds || 0, completed ? 1 : 0); + + const totalVideos = db.prepare('SELECT COUNT(*) AS cnt FROM videos WHERE course_id = ?').get(video.course_id).cnt; + const completedVideos = db.prepare(` + SELECT COUNT(*) AS cnt FROM progress p + JOIN videos v ON p.video_id = v.id + WHERE p.user_id = ? AND v.course_id = ? AND p.completed = 1 + `).get(req.user.id, video.course_id).cnt; + + const courseProgress = totalVideos > 0 ? (completedVideos / totalVideos) * 100 : 0; + db.prepare('UPDATE enrollments SET progress = ? WHERE user_id = ? AND course_id = ?') + .run(courseProgress, req.user.id, video.course_id); + + res.json({ message: 'Progress updated', courseProgress }); +}); + +router.get('/videos/:id/progress', authenticate, (req, res) => { + const p = db.prepare('SELECT * FROM progress WHERE user_id = ? AND video_id = ?') + .get(req.user.id, req.params.id); + res.json({ progress: p || { watched_seconds: 0, completed: false } }); +}); + +module.exports = router; diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..d0428a3 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,142 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'student' CHECK(role IN ('student','instructor','admin')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS courses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instructor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + category TEXT, + status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','published')), + price REAL NOT NULL DEFAULT 0, + thumbnail TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS enrollments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + progress REAL NOT NULL DEFAULT 0, + enrolled_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, course_id) +); + +CREATE TABLE IF NOT EXISTS sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + title TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS videos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL, + title TEXT NOT NULL, + file_path TEXT, + duration INTEGER DEFAULT 0, + order_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT, + due_date TEXT, + max_points REAL NOT NULL DEFAULT 100, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + assignment_id INTEGER NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT, + file_path TEXT, + grade REAL, + feedback TEXT, + submitted_at TEXT NOT NULL DEFAULT (datetime('now')), + graded_at TEXT +); + +CREATE TABLE IF NOT EXISTS discussions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS discussion_replies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discussion_id INTEGER NOT NULL REFERENCES discussions(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS announcements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + instructor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE, + watched_seconds INTEGER NOT NULL DEFAULT 0, + completed INTEGER NOT NULL DEFAULT 0, + last_watched TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, video_id) +); + +CREATE TABLE IF NOT EXISTS certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + issued_at TEXT NOT NULL DEFAULT (datetime('now')), + cert_uuid TEXT NOT NULL UNIQUE, + UNIQUE(user_id, course_id) +); + +CREATE TABLE IF NOT EXISTS quizzes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL, + title TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS quiz_questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quiz_id INTEGER NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options_json TEXT NOT NULL, + correct_answer TEXT NOT NULL, + points REAL NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS quiz_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quiz_id INTEGER NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + answers_json TEXT NOT NULL, + score REAL NOT NULL DEFAULT 0, + total_points REAL NOT NULL DEFAULT 0, + attempted_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..2558811 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,62 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +const rateLimit = require('express-rate-limit'); + +const app = express(); +const PORT = process.env.PORT || 5000; + +app.use(cors({ + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true +})); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later' } +}); + +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later' } +}); + +app.use('/api/login', authLimiter); +app.use('/api/register', authLimiter); +app.use('/api', apiLimiter); + +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); + +app.use('/api', require('./routes/auth')); +app.use('/api', require('./routes/courses')); +app.use('/api', require('./routes/videos')); +app.use('/api', require('./routes/enrollments')); +app.use('/api', require('./routes/assignments')); +app.use('/api', require('./routes/discussions')); +app.use('/api', require('./routes/announcements')); +app.use('/api', require('./routes/progress')); +app.use('/api', require('./routes/certificates')); +app.use('/api', require('./routes/quizzes')); +app.use('/api', require('./routes/admin')); + +app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); + +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Internal server error' }); +}); + +app.listen(PORT, () => { + console.log(`EduScale backend running on port ${PORT}`); +}); + +module.exports = app; diff --git a/backend/uploads/files/.gitkeep b/backend/uploads/files/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/uploads/videos/.gitkeep b/backend/uploads/videos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0ebb5fe --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + +
+ + +| ID | +Name | +Role | +Joined | +Actions | +|
|---|---|---|---|---|---|
| {u.id} | +{u.name} | +{u.email} | ++ + {u.role} + + | +{new Date(u.created_at).toLocaleDateString()} | ++ + | +
Your earned certificates from completed courses
+Complete a course to earn your first certificate!
+Instructor: {cert.instructor_name}
++ Issued: {new Date(cert.issued_at).toLocaleDateString('en-US', { + year: 'numeric', month: 'long', day: 'numeric' + })} +
+ID: {cert.cert_uuid?.slice(0, 8)}...
+{course.description}
+Instructor: {course.instructor_name}
+{a.body}
+ by {a.instructor_name} · {new Date(a.created_at).toLocaleDateString()} +{d.body}
+ by {d.author_name} · {new Date(d.created_at).toLocaleDateString()} · {d.reply_count} replies +Explore thousands of courses taught by expert instructors.
+No courses found. {search || category ? 'Try different filters.' : 'Check back soon!'}
+{course.description?.slice(0, 100)}{course.description?.length > 100 ? '...' : ''}
+By {course.instructor_name}
+{course.description?.slice(0, 80)}
+ + {course.status} + + {course.price === 0 ? 'Free' : `$${course.price}`} +{sub.student_name} — {sub.content?.slice(0, 80)}
+ {sub.grade !== null && sub.grade !== undefined + ? Graded: {sub.grade}/{a.max_points} + :Sign in to continue learning
+ {error &&+ Don't have an account? Sign up +
+Join EduScale and start learning
+ {error &&+ Already have an account? Sign in +
+Welcome back, {user?.name}!
+ +No courses yet. Browse courses
+By {e.instructor_name}
+ {e.category && {e.category}} +No upcoming assignments
+ ) : ( +{a.title}
+{a.course_title}
+ {a.due_date && ( +Due: {new Date(a.due_date).toLocaleDateString()}
+ )} +Complete a course to earn a certificate!
+ ) : ( +{c.course_title}
+ + Download PDF + +