From 0068785924e762c396a2a77cf45000c526cd845e Mon Sep 17 00:00:00 2001 From: Marius Rometsch Date: Thu, 5 Feb 2026 22:42:30 +0100 Subject: [PATCH] initial commit --- .dockerignore | 18 + .env.example | 22 + .gitignore | 57 + LESSON_QUICK_REFERENCE.md | 412 ++++ PLATFORM_DOCUMENTATION.md | 896 +++++++ README.md | 309 +++ backend/Dockerfile | 40 + backend/lessons/LESSONS_DOCUMENTATION.md | 930 +++++++ backend/lessons/README.md | 310 +++ .../configs/browser-in-browser-attack.yaml | 174 ++ .../configs/phishing-email-basics.yaml | 117 + .../lessons/configs/sql-injection-shop.yaml | 143 ++ backend/lessons/modules/base/LessonModule.js | 230 ++ .../browser-in-browser-attack/index.js | 83 + .../modules/phishing-email-basics/index.js | 16 + .../modules/sql-injection-shop/index.js | 209 ++ backend/package-lock.json | 2186 +++++++++++++++++ backend/package.json | 35 + backend/seed-new-lessons.js | 78 + backend/src/config/database.js | 60 + backend/src/config/environment.js | 33 + backend/src/controllers/admin.controller.js | 96 + .../src/controllers/adminLesson.controller.js | 156 ++ backend/src/controllers/event.controller.js | 190 ++ backend/src/controllers/lesson.controller.js | 287 +++ .../src/controllers/participant.controller.js | 129 + backend/src/index.js | 106 + backend/src/middleware/auth.js | 168 ++ backend/src/middleware/errorHandler.js | 73 + backend/src/models/queries/event.queries.js | 167 ++ backend/src/models/queries/lesson.queries.js | 229 ++ .../src/models/queries/participant.queries.js | 142 ++ .../src/models/queries/progress.queries.js | 206 ++ backend/src/routes/admin.routes.js | 32 + backend/src/routes/lesson.routes.js | 31 + backend/src/routes/participant.routes.js | 15 + backend/src/services/lessonLoader.service.js | 131 + backend/src/services/scoring.service.js | 155 ++ backend/src/utils/seedLessons.js | 61 + database/init/01-schema.sql | 138 ++ docker-compose.yml | 80 + frontend/Dockerfile | 41 + frontend/index.html | 14 + frontend/nginx.conf | 44 + frontend/package-lock.json | 2033 +++++++++++++++ frontend/package.json | 23 + frontend/src/App.jsx | 19 + .../lessons/InteractiveContent/BitBDemo.jsx | 367 +++ .../InteractiveContent/SQLShopDemo.jsx | 272 ++ frontend/src/contexts/AdminContext.jsx | 80 + frontend/src/contexts/ParticipantContext.jsx | 81 + frontend/src/main.jsx | 10 + frontend/src/pages/EventLanding.jsx | 120 + frontend/src/pages/Hub.jsx | 116 + frontend/src/pages/LessonView.jsx | 258 ++ frontend/src/pages/ParticipantProgress.jsx | 18 + frontend/src/pages/admin/AdminDashboard.jsx | 84 + frontend/src/pages/admin/AdminLogin.jsx | 87 + frontend/src/pages/admin/EventManagement.jsx | 232 ++ .../src/pages/admin/LessonConfiguration.jsx | 271 ++ frontend/src/pages/admin/ParticipantData.jsx | 234 ++ frontend/src/routes/AppRoutes.jsx | 112 + frontend/src/services/api.service.js | 141 ++ frontend/src/services/session.service.js | 71 + frontend/src/styles/index.css | 95 + frontend/vite.config.js | 22 + 66 files changed, 13795 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LESSON_QUICK_REFERENCE.md create mode 100644 PLATFORM_DOCUMENTATION.md create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/lessons/LESSONS_DOCUMENTATION.md create mode 100644 backend/lessons/README.md create mode 100644 backend/lessons/configs/browser-in-browser-attack.yaml create mode 100644 backend/lessons/configs/phishing-email-basics.yaml create mode 100644 backend/lessons/configs/sql-injection-shop.yaml create mode 100644 backend/lessons/modules/base/LessonModule.js create mode 100644 backend/lessons/modules/browser-in-browser-attack/index.js create mode 100644 backend/lessons/modules/phishing-email-basics/index.js create mode 100644 backend/lessons/modules/sql-injection-shop/index.js create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/seed-new-lessons.js create mode 100644 backend/src/config/database.js create mode 100644 backend/src/config/environment.js create mode 100644 backend/src/controllers/admin.controller.js create mode 100644 backend/src/controllers/adminLesson.controller.js create mode 100644 backend/src/controllers/event.controller.js create mode 100644 backend/src/controllers/lesson.controller.js create mode 100644 backend/src/controllers/participant.controller.js create mode 100644 backend/src/index.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/middleware/errorHandler.js create mode 100644 backend/src/models/queries/event.queries.js create mode 100644 backend/src/models/queries/lesson.queries.js create mode 100644 backend/src/models/queries/participant.queries.js create mode 100644 backend/src/models/queries/progress.queries.js create mode 100644 backend/src/routes/admin.routes.js create mode 100644 backend/src/routes/lesson.routes.js create mode 100644 backend/src/routes/participant.routes.js create mode 100644 backend/src/services/lessonLoader.service.js create mode 100644 backend/src/services/scoring.service.js create mode 100644 backend/src/utils/seedLessons.js create mode 100644 database/init/01-schema.sql create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx create mode 100644 frontend/src/components/lessons/InteractiveContent/SQLShopDemo.jsx create mode 100644 frontend/src/contexts/AdminContext.jsx create mode 100644 frontend/src/contexts/ParticipantContext.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/EventLanding.jsx create mode 100644 frontend/src/pages/Hub.jsx create mode 100644 frontend/src/pages/LessonView.jsx create mode 100644 frontend/src/pages/ParticipantProgress.jsx create mode 100644 frontend/src/pages/admin/AdminDashboard.jsx create mode 100644 frontend/src/pages/admin/AdminLogin.jsx create mode 100644 frontend/src/pages/admin/EventManagement.jsx create mode 100644 frontend/src/pages/admin/LessonConfiguration.jsx create mode 100644 frontend/src/pages/admin/ParticipantData.jsx create mode 100644 frontend/src/routes/AppRoutes.jsx create mode 100644 frontend/src/services/api.service.js create mode 100644 frontend/src/services/session.service.js create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..09d4b3e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +node_modules +npm-debug.log +.env +.env.local +.git +.gitignore +README.md +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store +dist +build +coverage +.nyc_output +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd5b3bc --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Database Configuration +DB_NAME=lernplattform +DB_USER=lernplattform_user +DB_PASSWORD=your_secure_password_here +DB_PORT=5432 + +# Backend Configuration +NODE_ENV=production +BACKEND_PORT=3000 +JWT_SECRET=your_jwt_secret_key_here_min_32_characters +SESSION_SECRET=your_session_secret_here_min_32_characters +ADMIN_DEFAULT_PASSWORD=your_admin_password_here + +# Frontend Configuration +FRONTEND_PORT=80 +VITE_API_URL=http://localhost:3000/api + +# Session Configuration +SESSION_TIMEOUT=3600000 + +# Logging +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1b06bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Environment variables +.env +.env.local +.env.*.local + +# Dependencies +node_modules/ +*/node_modules/ + +# Build outputs +dist/ +build/ +*/dist/ +*/build/ + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Docker +*.pid +*.seed +*.pid.lock + +# Database +*.sql~ +*.sqlite +*.db + +# Temporary files +tmp/ +temp/ +*.tmp + +# Coverage +coverage/ +.nyc_output/ + +# Production +/backend/dist +/frontend/dist diff --git a/LESSON_QUICK_REFERENCE.md b/LESSON_QUICK_REFERENCE.md new file mode 100644 index 0000000..4566509 --- /dev/null +++ b/LESSON_QUICK_REFERENCE.md @@ -0,0 +1,412 @@ +# Security Awareness Lessons - Quick Reference + +A quick reference guide for instructors and administrators using the learning platform. + +--- + +## Available Lessons Overview + +| Lesson | Difficulty | Duration | Topics | Interactive | +|--------|-----------|----------|--------|-------------| +| Phishing Email Detection | Beginner | 15 min | Email security, social engineering | No | +| SQL Injection Shop Demo | Intermediate | 20 min | Web security, OWASP Top 10 | Yes | +| Browser-in-the-Browser | Advanced | 25 min | Advanced phishing, OAuth | Yes | + +--- + +## Phishing Email Detection Basics + +### Quick Facts +- **Best for:** All employees, security awareness foundation +- **Prerequisites:** None +- **Key Takeaway:** How to identify and report phishing emails + +### What Students Learn +1. Common phishing indicators (suspicious domains, urgent language) +2. Email analysis techniques (hover over links, check headers) +3. Organizational reporting procedures + +### Question Breakdown +- **Q1 (50 pts):** Identify phishing red flags (multiple choice) +- **Q2 (25 pts):** Safe email practices (single choice) +- **Q3 (25 pts):** Explain reporting procedures (free text) + +### Teaching Tips +- Use real examples from your organization +- Emphasize cost of successful attacks +- Make reporting easy and encouraged +- Follow up with participants who fail + +### Follow-Up Activities +- Conduct simulated phishing exercises +- Share recent phishing attempts with team +- Review organizational policies + +--- + +## SQL Injection Attack - Online Shop Demo + +### Quick Facts +- **Best for:** Developers, QA engineers, technical staff +- **Prerequisites:** Basic understanding of databases and web applications +- **Key Takeaway:** SQL injection is preventable with parameterized queries + +### What Students Learn +1. How SQL injection vulnerabilities work +2. Types of SQL injection (OR, UNION, destructive) +3. Impact of successful attacks (data theft, deletion) +4. Defense mechanisms (parameterized queries, input validation) + +### Interactive Component: Fake Shop +Students can: +- Search products normally +- Execute SQL injection attacks safely +- See real-time query visualization +- Compare vulnerable vs secure queries + +**Attack Examples Demonstrated:** +- `' OR '1'='1` - View all products +- `' UNION SELECT ...` - Extract user credentials +- `'; DROP TABLE ...` - Destructive attack + +### Question Breakdown +- **Q1 (40 pts):** Identify SQL injection payloads (multiple choice) +- **Q2 (30 pts):** Best prevention method (single choice) +- **Q3 (30 pts):** Explain parameterized queries (free text) + +### Teaching Tips +- Let students experiment with injections +- Emphasize parameterized queries over filtering +- Show real breach examples (British Airways, etc.) +- Discuss OWASP Top 10 context +- Connect to secure coding standards + +### Follow-Up Activities +- Code review session for SQL vulnerabilities +- Scan existing applications for SQL injection +- Implement parameterized queries in projects +- Add SQL injection tests to CI/CD pipeline + +### For Developers +This lesson directly applies to: +- Backend API development +- Database query construction +- Security code reviews +- Penetration testing + +--- + +## Browser-in-the-Browser (BitB) Attack + +### Quick Facts +- **Best for:** All staff, especially those using SSO/OAuth +- **Prerequisites:** Understanding of web browsers and login flows +- **Key Takeaway:** Physical testing (drag window) detects fake popups + +### What Students Learn +1. How BitB attacks mimic legitimate browser windows +2. Why traditional phishing training doesn't catch this +3. Detection techniques (drag test, inspect element) +4. Why password managers provide protection + +### Interactive Component: Fake Browser Popups +Students can: +- Launch realistic fake OAuth popups (Google, Microsoft) +- Compare real vs fake browser windows +- Test detection techniques (drag, right-click) +- See educational feedback when testing + +**Two Scenarios:** +1. **Legitimate OAuth** - Shows how real popups behave +2. **BitB Attack** - Demonstrates fake trapped popup + +### Question Breakdown +- **Q1 (40 pts):** Detection indicators (multiple choice) +- **Q2 (35 pts):** Safest approach to popups (single choice) +- **Q3 (25 pts):** Why password managers help (free text) + +### Teaching Tips +- Emphasize this is NEW and sophisticated +- Practice the "drag test" multiple times +- Explain OAuth/SSO context for relevance +- Recommend password managers strongly +- Show real-world attack examples (2022+) + +### Follow-Up Activities +- Test SSO popups in your organization +- Deploy password manager to all staff +- Enable 2FA/MFA on all accounts +- Consider hardware security keys (FIDO2) +- Review OAuth implementation security + +### For Security Teams +This lesson supports: +- Advanced phishing awareness +- SSO security strategy +- Password manager adoption +- Zero-trust implementation +- Security tool evaluation + +--- + +## Scoring System + +### Point Distribution Philosophy +- **Easy questions:** 20-30% of total +- **Medium questions:** 40-50% of total +- **Hard questions:** 20-30% of total + +### Passing Scores +- **70%** - Demonstrates basic competency +- **80%** - Strong understanding +- **90%+** - Expert level + +### Partial Credit (Multiple Choice) +- Points awarded per correct selection +- Incorrect selections don't subtract points +- Encourages selecting all correct answers + +### Free Text Validation +- Keyword-based scoring +- Partial credit if some keywords present +- Minimum length requirements +- Case-insensitive matching + +--- + +## Lesson Recommendations by Role + +### All Employees +1. ✅ **Phishing Email Detection** (required) +2. ⚠️ **Browser-in-the-Browser** (recommended) + +### Developers / Technical Staff +1. ✅ **SQL Injection Shop** (required) +2. ✅ **Phishing Email Detection** (required) +3. ⚠️ **Browser-in-the-Browser** (recommended) + +### Security Team +1. ✅ All lessons (required) +2. Use as train-the-trainer material + +### Management / Executives +1. ✅ **Phishing Email Detection** (required) +2. ✅ **Browser-in-the-Browser** (recommended - targets high-value accounts) + +--- + +## Creating Training Events + +### Recommended Event Structures + +#### New Hire Security Basics +``` +Event: "Security Awareness Onboarding" +Duration: 1 week access +Lessons: + 1. Phishing Email Detection (weight: 1.0) + 2. Browser-in-the-Browser (weight: 1.0) +Passing: 70% overall +``` + +#### Developer Security Training +``` +Event: "Secure Coding Fundamentals" +Duration: 2 weeks access +Lessons: + 1. Phishing Email Detection (weight: 0.5) + 2. SQL Injection Shop (weight: 2.0) + 3. Browser-in-the-Browser (weight: 0.5) +Passing: 75% overall +``` + +#### Quarterly Security Refresher +``` +Event: "Q1 Security Updates" +Duration: 1 week access +Lessons: + - Rotate lessons each quarter + - Include new lessons as available +Passing: 70% overall +``` + +### Event Configuration Tips + +**Lesson Weights:** +- Weight = 1.0: Normal importance +- Weight = 2.0: Double importance +- Weight = 0.5: Lower priority/bonus + +**Lesson Order:** +- Beginner → Intermediate → Advanced +- Required lessons first +- Interactive lessons for engagement + +**Access Duration:** +- Minimum: 3 days (allows flexible completion) +- Typical: 1-2 weeks +- Ongoing training: No end date + +**Point Configuration:** +- Use default 100 max points per lesson +- Adjust weights instead of max points +- Keep passing score consistent (70-75%) + +--- + +## Troubleshooting Common Issues + +### "Lesson not showing up" +- ✅ Check lesson is assigned to event +- ✅ Verify event is active +- ✅ Confirm participant joined correct event + +### "Can't complete lesson" +- ✅ Ensure all questions answered +- ✅ Check for validation errors +- ✅ Verify lesson was started +- ✅ Try refreshing page + +### "Interactive component not working" +- ✅ Check browser console for errors +- ✅ Try different browser +- ✅ Verify JavaScript enabled +- ✅ Clear browser cache + +### "Score seems wrong" +- ✅ Review partial credit rules +- ✅ Check question weights +- ✅ Verify lesson weight in event +- ✅ See participant answers in admin panel + +### "Assigning lesson fails" +- ✅ Check lesson isn't already assigned +- ✅ Verify event exists +- ✅ Order index auto-increments now (fixed) +- ✅ Try different order index + +--- + +## Keyboard Shortcuts (Lesson Player) + +| Action | Shortcut | Notes | +|--------|----------|-------| +| Next step | → or Enter | Only if current step complete | +| Previous step | ← | Always available | +| Submit answer | Enter | In text input | +| Complete lesson | - | Click button when on last step | + +--- + +## Best Practices for Admins + +### Event Planning +- Schedule events with advance notice +- Provide clear deadline communication +- Send reminder emails at 50% and 90% of time +- Celebrate completion publicly +- Review analytics after event + +### Lesson Assignment +- Start with easier lessons +- Mix content types (text + interactive) +- Don't overload with too many lessons +- Consider time required for completion +- Weight by organizational priority + +### Participant Support +- Provide help desk contact info +- Monitor completion rates +- Follow up with non-completers +- Review common wrong answers +- Adjust training based on feedback + +### Monitoring Progress +- Check completion rates weekly +- Identify struggling participants +- Review average scores per lesson +- Look for common failure points +- Export data for reporting + +--- + +## Integration Ideas + +### With Existing Training +- Part of onboarding checklist +- Annual security training requirement +- Post-incident remediation +- Role-based training tracks + +### With Security Tools +- Password manager deployment +- 2FA enrollment campaign +- Phishing simulation platform +- Security awareness metrics + +### With HR/Compliance +- Track completion for compliance +- Report to leadership quarterly +- Include in performance reviews +- Tie to security culture initiatives + +--- + +## Metrics to Track + +### Completion Metrics +- % of participants who completed +- Average time to complete +- Completion rate by department +- Deadline adherence + +### Performance Metrics +- Average score per lesson +- Pass/fail rates +- Most missed questions +- Improvement over time + +### Engagement Metrics +- Time spent per lesson +- Interactive component usage +- Repeat attempts +- Question feedback ratings + +--- + +## Quick Access URLs + +Assuming platform at `http://localhost`: + +- **Participant Hub:** `/` +- **Admin Login:** `/admin/login` +- **Event Management:** `/admin/events` +- **Lesson Configuration:** `/admin/events/{id}/lessons` +- **Participant Data:** `/admin/events/{id}/participants` + +--- + +## Support Resources + +### For Lesson Content Questions +- Review lesson documentation +- Check existing lesson examples +- Test in development environment + +### For Technical Issues +- Check browser console +- Review backend logs +- Verify container health +- Check database connectivity + +### For Training Strategy +- Consult security team +- Review industry standards +- Benchmark against similar organizations +- Gather participant feedback + +--- + +**Last Updated:** 2026-01-12 +**Platform Version:** 1.0.0 +**Total Lessons:** 3 diff --git a/PLATFORM_DOCUMENTATION.md b/PLATFORM_DOCUMENTATION.md new file mode 100644 index 0000000..27700db --- /dev/null +++ b/PLATFORM_DOCUMENTATION.md @@ -0,0 +1,896 @@ +# Security Awareness Learning Platform - Documentation + +Complete technical documentation for the containerized security awareness training platform. + +--- + +## Table of Contents + +1. [Platform Overview](#platform-overview) +2. [Architecture](#architecture) +3. [Getting Started](#getting-started) +4. [User Roles & Workflows](#user-roles--workflows) +5. [Lessons System](#lessons-system) +6. [API Reference](#api-reference) +7. [Database Schema](#database-schema) +8. [Frontend Components](#frontend-components) +9. [Deployment](#deployment) +10. [Development Guide](#development-guide) + +--- + +## Platform Overview + +### Purpose +A containerized web application for security awareness training featuring: +- **Hub-based architecture** - Participants join events with pseudonyms +- **Modular lessons** - Easily expandable lesson system +- **Interactive content** - Hands-on security demonstrations +- **Weighted scoring** - Flexible point configuration per event + +### Technology Stack +- **Backend:** Node.js 18 + Express +- **Frontend:** React 18 + Vite 5 +- **Database:** PostgreSQL 15 +- **Deployment:** Docker Compose +- **Lesson Format:** YAML configs + JavaScript modules + +### Key Features +- ✅ No registration required for participants (pseudonym-based) +- ✅ Admin panel with password authentication +- ✅ Interactive security lessons (SQL injection, phishing demos) +- ✅ Progress tracking and analytics +- ✅ Weighted scoring system +- ✅ Containerized deployment (production-ready) + +--- + +## Architecture + +### High-Level Architecture + +``` +┌─────────────────────────────────────────────┐ +│ User (Browser) │ +└─────────────┬───────────────────────────────┘ + │ + ├─── http://localhost (Frontend - Nginx) + │ ├─ Hub (Join Events) + │ ├─ Lesson Player + │ ├─ Admin Panel + │ └─ Progress Dashboard + │ + ├─── http://localhost:3000/api (Backend - Express) + │ ├─ Participant API + │ ├─ Admin API + │ ├─ Lesson API + │ └─ Lesson Modules + │ + └─── PostgreSQL:5432 (Database) + ├─ Events & Participants + ├─ Lessons & Progress + └─ Answers & Scores +``` + +### Container Architecture + +```yaml +services: + database: + - PostgreSQL 15 Alpine + - Volume: lesson_platform_data + - Health checks enabled + + backend: + - Node.js 18 Alpine + - Depends on: database (healthy) + - Volumes: ./backend/lessons (lessons) + - Health endpoint: /health + + frontend: + - Nginx Alpine + - Serves: React SPA + - Reverse proxy: API calls to backend +``` + +### Data Flow + +``` +Participant Flow: + Hub → Join Event → View Lessons → Complete Lesson → View Progress + +Admin Flow: + Login → Dashboard → Manage Events → Assign Lessons → View Analytics + +Lesson Execution: + Start → Navigate Steps → Answer Questions → Submit → Complete +``` + +--- + +## Getting Started + +### Prerequisites +- Docker Desktop or Docker Engine + Docker Compose +- 2GB RAM minimum +- Ports 80, 3000, 5432 available + +### Quick Start + +1. **Clone and Setup:** + ```bash + cd lernplattform + cp .env.example .env + # Edit .env with your settings + ``` + +2. **Start Platform:** + ```bash + docker-compose up -d + ``` + +3. **Verify Health:** + ```bash + docker-compose ps # All should be "healthy" + curl http://localhost:3000/health + ``` + +4. **Access:** + - **Participants:** http://localhost + - **Admin:** http://localhost/admin/login + - Username: `admin` + - Password: `admin123` + +5. **Seed Lessons (if needed):** + ```bash + docker exec lernplattform_backend node seed-lessons.js + ``` + +### First Time Setup + +1. **Create an Event:** + - Login to admin panel + - Click "Manage Events" + - Create new event with name and dates + +2. **Assign Lessons:** + - Click "Manage Lessons" on event + - Assign lessons with weights + - Configure max points + +3. **Test as Participant:** + - Go to http://localhost + - Join the event with a pseudonym + - Complete a lesson + +--- + +## User Roles & Workflows + +### Participant Role + +**Capabilities:** +- Join events with pseudonym (no registration) +- View assigned lessons +- Complete interactive lessons +- Submit answers and receive immediate feedback +- View personal progress and score +- Complete lessons in any order (unless locked) + +**Workflow:** +1. Navigate to hub page +2. Select event from dropdown +3. Enter pseudonym +4. Click "Join Event" +5. View lesson list +6. Click lesson to start +7. Navigate through steps +8. Answer questions +9. Complete lesson +10. View updated score + +**Session Management:** +- Session token stored in localStorage +- Remains logged in across browser sessions +- Can logout to join different event + +### Admin Role + +**Capabilities:** +- Create, edit, delete events +- Assign lessons to events +- Configure lesson weights and points +- View participant data and progress +- See detailed answers submitted +- Export analytics + +**Workflow:** + +**Event Management:** +1. Login to admin panel +2. Navigate to Event Management +3. Create/edit/delete events +4. Set dates and active status + +**Lesson Configuration:** +1. Select event +2. Click "Manage Lessons" +3. Assign lessons from catalog +4. Set order, weights, points +5. Mark as required/optional + +**Monitor Progress:** +1. Select event +2. Click "View Participants" +3. See completion rates +4. Click participant for details +5. Review submitted answers + +--- + +## Lessons System + +### Architecture + +Every lesson consists of: +1. **YAML Configuration** - Structure, questions, scoring +2. **JavaScript Module** - Validation, interactive data +3. **Database Entry** - Metadata, catalog reference + +### Lesson Flow + +``` +Load Config → Initialize Module → Render Steps → Validate Answers → Calculate Score +``` + +### Step Types + +**1. Content Step** +```yaml +- id: "intro" + type: "content" + title: "Introduction" + content: "Educational content..." +``` + +**2. Question Step** +```yaml +- id: "question-1" + type: "question" + questionType: "single_choice|multiple_choice|free_text" + question: "Question text?" + options: [...] + maxPoints: 25 +``` + +**3. Interactive Step** +```yaml +- id: "demo" + type: "interactive" + title: "Interactive Demo" + interactiveComponent: "ComponentName" + content: "Instructions..." +``` + +### Available Lessons + +| Lesson | Key | Difficulty | Duration | Interactive | +|--------|-----|-----------|----------|-------------| +| Phishing Email Detection | `phishing-email-basics` | Beginner | 15 min | No | +| SQL Injection Shop | `sql-injection-shop` | Intermediate | 20 min | Yes | +| Browser-in-the-Browser | `browser-in-browser-attack` | Advanced | 25 min | Yes | + +**Detailed Documentation:** +- [Comprehensive Lesson Docs](backend/lessons/LESSONS_DOCUMENTATION.md) +- [Quick Reference](LESSON_QUICK_REFERENCE.md) +- [Lesson README](backend/lessons/README.md) + +--- + +## API Reference + +### Base URL +``` +http://localhost:3000/api +``` + +### Authentication + +**Participant:** +- Header: `Authorization: Bearer ` +- Token obtained on event join + +**Admin:** +- Header: `Authorization: Bearer ` +- Token obtained on login + +### Participant Endpoints + +``` +POST /participant/join + Body: { pseudonym, eventId } + → { sessionToken, participant, event } + +GET /participant/events + → { events: [...] } + +GET /participant/progress + Auth: Bearer session_token + → { totalScore, completedLessons, ... } +``` + +### Lesson Endpoints + +``` +GET /lesson/event/:eventId/lessons + Auth: Bearer session_token + → { lessons: [...] } + +GET /lesson/:eventLessonId + Auth: Bearer session_token + → { title, steps, maxPoints, progress } + +POST /lesson/:eventLessonId/start + Auth: Bearer session_token + → { progressId, status } + +POST /lesson/:eventLessonId/answer + Auth: Bearer session_token + Body: { questionId, answer } + → { isCorrect, pointsAwarded, totalScore } + +POST /lesson/:eventLessonId/complete + Auth: Bearer session_token + → { completedAt, finalScore } + +POST /lesson/:eventLessonId/action/:action + Auth: Bearer session_token + Body: { ...actionData } + → { result } +``` + +### Admin Endpoints + +``` +POST /admin/login + Body: { username, password } + → { token, admin } + +GET /admin/events + Auth: Bearer admin_token + → { events: [...] } + +POST /admin/events + Auth: Bearer admin_token + Body: { name, description, startDate, endDate } + → { event } + +GET /admin/events/:eventId/participants + Auth: Bearer admin_token + → { participants: [...] } + +GET /admin/lessons + Auth: Bearer admin_token + → { lessons: [...] } + +POST /admin/events/:eventId/lessons + Auth: Bearer admin_token + Body: { lessonId, orderIndex, maxPoints, weight } + → { eventLesson } +``` + +### Error Responses + +```json +{ + "success": false, + "error": { + "message": "Error description", + "statusCode": 400 + } +} +``` + +--- + +## Database Schema + +### Core Tables + +**events** +```sql +id, name, description, start_date, end_date, is_active, +created_at, updated_at +``` + +**participants** +```sql +id, pseudonym, event_id, session_token, +created_at, last_active +UNIQUE(pseudonym, event_id) +``` + +**lessons** +```sql +id, lesson_key, title, description, +module_path, config_path, difficulty_level, estimated_duration, +created_at, updated_at +``` + +**event_lessons** +```sql +id, event_id, lesson_id, order_index, max_points, weight, +is_required, unlock_after_lesson_id, +created_at, updated_at +UNIQUE(event_id, order_index) +``` + +**lesson_progress** +```sql +id, participant_id, event_lesson_id, status, +started_at, completed_at, score, attempts, current_step +``` + +**lesson_answers** +```sql +id, lesson_progress_id, question_key, answer_data (JSONB), +is_correct, points_awarded, feedback, submitted_at +``` + +**admin_users** +```sql +id, username, password_hash, created_at, updated_at +``` + +### Relationships + +``` +events (1) → (*) event_lessons → (*) lesson_progress +events (1) → (*) participants → (*) lesson_progress +lessons (1) → (*) event_lessons +lesson_progress (1) → (*) lesson_answers +``` + +### Indexes + +- `participants(session_token)` - Session lookup +- `event_lessons(event_id, order_index)` - Lesson ordering +- `lesson_progress(participant_id, event_lesson_id)` - Progress tracking +- `lesson_answers(lesson_progress_id)` - Answer retrieval + +--- + +## Frontend Components + +### Page Structure + +``` +src/ +├── pages/ +│ ├── Hub.jsx # Join event +│ ├── EventLanding.jsx # Lesson list +│ ├── LessonView.jsx # Lesson player +│ ├── ParticipantProgress.jsx # Progress view +│ └── admin/ +│ ├── AdminLogin.jsx +│ ├── AdminDashboard.jsx +│ ├── EventManagement.jsx +│ ├── LessonConfiguration.jsx +│ └── ParticipantData.jsx +├── components/ +│ ├── common/ # Shared components +│ └── lessons/ +│ └── InteractiveContent/ +│ ├── SQLShopDemo.jsx +│ └── BitBDemo.jsx +├── contexts/ +│ ├── ParticipantContext.jsx # Participant auth +│ └── AdminContext.jsx # Admin auth +├── services/ +│ ├── api.service.js # API client +│ └── session.service.js # Session mgmt +└── routes/ + └── AppRoutes.jsx # Route config +``` + +### State Management + +**Context Providers:** +- `ParticipantContext` - Session, authentication, profile +- `AdminContext` - Admin auth, profile + +**Local State:** +- Component-level useState for UI state +- No Redux/Zustand needed for current scale + +### Routing + +```javascript +/ → Hub (public) +/event → EventLanding (protected: participant) +/lesson/:id → LessonView (protected: participant) +/progress → ParticipantProgress (protected: participant) + +/admin/login → AdminLogin (public) +/admin → AdminDashboard (protected: admin) +/admin/events → EventManagement (protected: admin) +/admin/events/:id/lessons → LessonConfiguration (protected: admin) +/admin/events/:id/participants → ParticipantData (protected: admin) +``` + +### Interactive Components + +**SQLShopDemo:** +- Fake e-commerce search interface +- Executes vulnerable SQL queries +- Real-time injection detection +- Safe vs vulnerable comparison + +**BitBDemo:** +- Fake OAuth popup simulator +- Drag-test demonstration +- Right-click inspection +- Educational feedback system + +--- + +## Deployment + +### Environment Variables + +```bash +# Database +DB_HOST=database +DB_PORT=5432 +DB_NAME=lernplattform_db +DB_USER=lernplattform_user +DB_PASSWORD=your_secure_password + +# Backend +NODE_ENV=production +PORT=3000 +JWT_SECRET=your_jwt_secret_key +SESSION_SECRET=your_session_secret + +# Admin +ADMIN_PASSWORD=your_admin_password +``` + +### Docker Compose Commands + +```bash +# Start all services +docker-compose up -d + +# Stop all services +docker-compose down + +# View logs +docker-compose logs -f [service] + +# Rebuild specific service +docker-compose up -d --build [service] + +# Check status +docker-compose ps + +# Execute command in container +docker exec [container] [command] +``` + +### Health Checks + +**Backend Health:** +```bash +curl http://localhost:3000/health +``` + +Expected response: +```json +{ + "status": "healthy", + "timestamp": "2026-01-12T...", + "environment": "production", + "database": "connected" +} +``` + +**Container Status:** +```bash +docker-compose ps +# All should show "Up" and "healthy" +``` + +### Backup & Restore + +**Backup Database:** +```bash +docker exec lernplattform_db pg_dump -U lernplattform_user lernplattform_db > backup.sql +``` + +**Restore Database:** +```bash +cat backup.sql | docker exec -i lernplattform_db psql -U lernplattform_user lernplattform_db +``` + +### Production Considerations + +1. **Change Default Passwords** in `.env` +2. **Enable HTTPS** with reverse proxy (nginx/traefik) +3. **Set Up Regular Backups** of database +4. **Configure CORS** for production domain +5. **Enable Rate Limiting** on API endpoints +6. **Set Up Monitoring** (logs, metrics) +7. **Configure Log Rotation** +8. **Use Strong JWT/Session Secrets** + +--- + +## Development Guide + +### Local Development Setup + +1. **Install Dependencies:** + ```bash + cd backend && npm install + cd ../frontend && npm install + ``` + +2. **Run Without Docker:** + ```bash + # Terminal 1: Database + docker run -p 5432:5432 -e POSTGRES_PASSWORD=pass postgres:15-alpine + + # Terminal 2: Backend + cd backend && npm run dev + + # Terminal 3: Frontend + cd frontend && npm run dev + ``` + +3. **Access Development Server:** + - Frontend: http://localhost:5173 + - Backend: http://localhost:3000 + +### Adding a New Lesson + +**Step-by-Step:** + +1. Create YAML config: + ```bash + nano backend/lessons/configs/my-lesson.yaml + ``` + +2. Create module directory: + ```bash + mkdir backend/lessons/modules/my-lesson + nano backend/lessons/modules/my-lesson/index.js + ``` + +3. Extend base class: + ```javascript + const LessonModule = require('../base/LessonModule'); + + class MyLesson extends LessonModule { + constructor(config) { + super(config); + } + } + + module.exports = MyLesson; + ``` + +4. Seed to database: + ```bash + # Add to seed script + docker exec lernplattform_backend node seed-lessons.js + ``` + +5. (Optional) Create interactive component: + ```bash + nano frontend/src/components/lessons/InteractiveContent/MyComponent.jsx + ``` + +6. Register in LessonView.jsx + +7. Test thoroughly + +### Code Style + +**Backend:** +- Use async/await (not callbacks) +- Validate inputs with ApiError +- Use prepared statements for SQL +- Handle errors in asyncHandler +- Comment complex logic + +**Frontend:** +- Functional components + hooks +- PropTypes for type checking (optional) +- CSS-in-JS for styling +- Destructure props +- Keep components small + +### Testing + +**Manual Testing Checklist:** +- [ ] Can join event +- [ ] Lessons appear correctly +- [ ] Questions validate properly +- [ ] Scoring calculates correctly +- [ ] Progress saves +- [ ] Admin can assign lessons +- [ ] Interactive components work +- [ ] Session persists + +**API Testing:** +```bash +# Health check +curl http://localhost:3000/health + +# Get events (as participant) +curl http://localhost:3000/api/participant/events +``` + +### Debugging + +**Backend Logs:** +```bash +docker logs -f lernplattform_backend +``` + +**Frontend Console:** +- Open browser DevTools (F12) +- Check Console tab for errors +- Check Network tab for API calls + +**Database Query:** +```bash +docker exec -it lernplattform_db psql -U lernplattform_user lernplattform_db +\dt -- List tables +SELECT * FROM lessons; +``` + +--- + +## Troubleshooting + +### Common Issues + +**1. Port already in use** +```bash +# Find process using port +lsof -i :80 # or 3000, 5432 +# Kill process or change port in docker-compose.yml +``` + +**2. Database connection failed** +```bash +# Check database is running +docker-compose ps database +# Check logs +docker logs lernplattform_db +# Verify credentials in .env +``` + +**3. Frontend shows blank page** +```bash +# Check frontend logs +docker logs lernplattform_frontend +# Rebuild frontend +docker-compose up -d --build frontend +# Check browser console +``` + +**4. Admin login fails** +```bash +# Verify password hash in database +docker exec lernplattform_db psql -U lernplattform_user lernplattform_db -c "SELECT username, password_hash FROM admin_users;" +# Regenerate hash if needed +``` + +**5. Lesson won't assign** +```bash +# Check backend logs for constraint errors +docker logs lernplattform_backend +# Verify lesson exists +docker exec lernplattform_db psql -U lernplattform_user lernplattform_db -c "SELECT * FROM lessons;" +# Check for duplicate order_index (fixed in latest version) +``` + +--- + +## Security Notes + +### Authentication +- Participants: UUID session tokens (stateless) +- Admins: JWT tokens with bcrypt password hashing +- No password recovery (admin must reset manually) + +### Data Protection +- No PII collected from participants (pseudonyms only) +- Session tokens in localStorage (XSS risk mitigated) +- SQL injection prevented (parameterized queries) +- Input validation on frontend and backend + +### Production Security +- Change all default passwords +- Use strong JWT secrets +- Enable HTTPS +- Configure CORS properly +- Implement rate limiting +- Regular security updates +- Monitor logs for attacks + +--- + +## Performance + +### Current Scale +- Handles ~100 concurrent users +- Database pool: 20 connections +- Frontend: Static files, fast load + +### Optimization Options +- Add Redis for session caching +- Enable frontend caching (service worker) +- CDN for static assets +- Database read replicas +- Horizontal scaling (multiple backend instances) + +--- + +## Future Enhancements + +### Planned Features +- [ ] More lessons (XSS, CSRF, password security) +- [ ] Email notifications +- [ ] Bulk participant import +- [ ] Certificate generation +- [ ] Advanced analytics dashboard +- [ ] Lesson scheduling +- [ ] Mobile responsive improvements +- [ ] Dark mode + +### Extension Points +- Custom lesson modules +- Additional interactive components +- Third-party integrations (LDAP, SSO) +- LMS integration (SCORM) +- API webhooks + +--- + +## Support & Resources + +### Documentation +- **This File:** Platform overview +- **[LESSONS_DOCUMENTATION.md](backend/lessons/LESSONS_DOCUMENTATION.md):** Comprehensive lesson guide +- **[LESSON_QUICK_REFERENCE.md](LESSON_QUICK_REFERENCE.md):** Quick reference for instructors +- **[lessons/README.md](backend/lessons/README.md):** Lesson development guide + +### Useful Commands +```bash +# View all containers +docker ps -a + +# Clean up +docker-compose down -v # Remove volumes +docker system prune # Clean unused resources + +# Database access +docker exec -it lernplattform_db psql -U lernplattform_user lernplattform_db + +# Backend shell +docker exec -it lernplattform_backend sh + +# Tail logs +docker-compose logs -f --tail=100 +``` + +--- + +**Version:** 1.0.0 +**Last Updated:** 2026-01-12 +**License:** MIT (or your license) +**Repository:** https://github.com/your-org/lernplattform (if applicable) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2704d0 --- /dev/null +++ b/README.md @@ -0,0 +1,309 @@ +# Security Awareness Learning Platform + +A containerized web application for security awareness training with modular, expandable lessons. Participants join events using pseudonyms and complete interactive security lessons including phishing detection, SQL injection demos, and more. + +## Features + +- **Hub-Based Architecture**: Participants join events with pseudonyms (no registration required) +- **Interactive Lessons**: Phishing demos, SQL injection sandboxes, fake login forms, and more +- **Modular Lesson System**: Easy to expand by adding YAML configs and JavaScript modules +- **Weighted Scoring**: Configurable points and weights per lesson +- **Admin Panel**: Complete event and lesson management, participant data viewing +- **Container-Based**: Easy deployment with Docker Compose + +## Technology Stack + +- **Backend**: Node.js + Express +- **Frontend**: React + Vite +- **Database**: PostgreSQL 15 +- **Containerization**: Docker + Docker Compose +- **Lesson Storage**: YAML/JSON configurations + JavaScript modules + +## Quick Start + +### Prerequisites + +- Docker and Docker Compose installed +- Git (optional, for version control) + +### Setup Instructions + +1. **Clone or download the project** + ```bash + cd lernplattform + ``` + +2. **Configure environment variables** + ```bash + cp .env.example .env + ``` + + Edit `.env` and set secure values for: + - `DB_PASSWORD` - Database password + - `JWT_SECRET` - JWT secret key (min 32 characters) + - `SESSION_SECRET` - Session secret (min 32 characters) + - `ADMIN_DEFAULT_PASSWORD` - Admin login password + +3. **Start the application** + ```bash + docker-compose up -d + ``` + +4. **Wait for services to be healthy** + ```bash + docker-compose ps + ``` + + All services should show "healthy" status. + +5. **Access the application** + - Frontend: http://localhost (port 80) + - Backend API: http://localhost:3000 + - Health check: http://localhost:3000/health + +### Default Credentials + +- **Admin Login**: + - Username: `admin` + - Password: Value set in `.env` (`ADMIN_DEFAULT_PASSWORD`) + +## Project Structure + +``` +lernplattform/ +├── database/ +│ └── init/ +│ └── 01-schema.sql # Database schema +├── backend/ +│ ├── src/ +│ │ ├── config/ # Configuration files +│ │ ├── middleware/ # Express middleware +│ │ ├── routes/ # API routes +│ │ ├── controllers/ # Route controllers +│ │ ├── services/ # Business logic +│ │ └── models/ # Database queries +│ ├── lessons/ +│ │ ├── configs/ # Lesson YAML configs +│ │ └── modules/ # Lesson JavaScript modules +│ ├── Dockerfile +│ └── package.json +├── frontend/ +│ ├── src/ +│ │ ├── pages/ # Page components +│ │ ├── components/ # Reusable components +│ │ ├── services/ # API services +│ │ └── styles/ # CSS styles +│ ├── Dockerfile +│ ├── nginx.conf +│ └── package.json +├── docker-compose.yml +└── .env.example +``` + +## Development + +### Running in Development Mode + +#### Backend Development +```bash +cd backend +npm install +npm run dev +``` + +The backend will run on port 3000 with hot reload. + +#### Frontend Development +```bash +cd frontend +npm install +npm run dev +``` + +The frontend will run on port 5173 with hot reload. + +### Database Access + +Connect to the PostgreSQL database: +```bash +docker-compose exec database psql -U lernplattform_user -d lernplattform +``` + +### Viewing Logs + +```bash +# All services +docker-compose logs -f + +# Specific service +docker-compose logs -f backend +docker-compose logs -f frontend +docker-compose logs -f database +``` + +## Adding New Lessons + +Lessons are modular and easy to add. Each lesson consists of: +1. A YAML configuration file (`backend/lessons/configs/*.yaml`) +2. A JavaScript module (`backend/lessons/modules/*/index.js`) + +### Step-by-Step Guide + +1. **Create YAML configuration** in `backend/lessons/configs/`: + +```yaml +lessonKey: "my-new-lesson" +title: "My New Lesson" +description: "Learn about security concept X" +difficultyLevel: "beginner" +estimatedDuration: 15 +module: "my-new-lesson" + +steps: + - id: "intro" + type: "content" + title: "Introduction" + content: "Educational content here..." + + - id: "question-1" + type: "question" + questionType: "single_choice" + question: "What is the answer?" + options: + - id: "option-1" + text: "Correct answer" + isCorrect: true + points: 50 + - id: "option-2" + text: "Wrong answer" + isCorrect: false + points: 0 + maxPoints: 50 + feedback: + correct: "Great job!" + incorrect: "Try again..." + +scoring: + passingScore: 70 + maxTotalPoints: 100 +``` + +2. **Create JavaScript module** in `backend/lessons/modules/my-new-lesson/`: + +```javascript +const LessonModule = require('../base/LessonModule'); + +class MyNewLesson extends LessonModule { + constructor(config) { + super(config); + } + + // Override methods if custom validation needed + // Otherwise, base class handles standard question types +} + +module.exports = MyNewLesson; +``` + +3. **Add lesson to database** (via admin panel or SQL): + +```sql +INSERT INTO lessons (lesson_key, title, description, module_path, config_path, difficulty_level, estimated_duration) +VALUES ('my-new-lesson', 'My New Lesson', 'Description', 'my-new-lesson', 'my-new-lesson.yaml', 'beginner', 15); +``` + +4. **Assign lesson to event** via admin panel. + +## API Documentation + +### Participant Endpoints + +- `POST /api/participant/join` - Join event with pseudonym +- `GET /api/participant/events` - List active events +- `GET /api/participant/event/:eventId/lessons` - Get lessons for event +- `GET /api/participant/lesson/:lessonId` - Get lesson content +- `POST /api/participant/lesson/:lessonId/answer` - Submit answer +- `GET /api/participant/progress` - Get progress + +### Admin Endpoints + +- `POST /api/admin/login` - Admin authentication +- `GET /api/admin/events` - List all events +- `POST /api/admin/events` - Create new event +- `PUT /api/admin/events/:eventId` - Update event +- `DELETE /api/admin/events/:eventId` - Delete event +- `POST /api/admin/events/:eventId/lessons` - Assign lesson to event +- `GET /api/admin/events/:eventId/participants` - View participant data + +## Security Considerations + +- All passwords are hashed with bcrypt +- JWT tokens for admin authentication +- Session tokens for participant authentication +- Parameterized SQL queries to prevent SQL injection +- CORS configured for security +- Security headers via Helmet.js +- Input validation on all endpoints +- Non-root user in Docker containers + +## Troubleshooting + +### Database Connection Issues + +```bash +# Check database status +docker-compose ps database + +# View database logs +docker-compose logs database + +# Restart database +docker-compose restart database +``` + +### Backend Not Starting + +```bash +# Check backend logs +docker-compose logs backend + +# Verify environment variables +docker-compose exec backend env | grep DB_ + +# Restart backend +docker-compose restart backend +``` + +### Frontend Not Loading + +```bash +# Check frontend logs +docker-compose logs frontend + +# Verify nginx configuration +docker-compose exec frontend nginx -t + +# Restart frontend +docker-compose restart frontend +``` + +### Reset Everything + +```bash +# Stop all services +docker-compose down + +# Remove volumes (WARNING: Deletes all data!) +docker-compose down -v + +# Rebuild and start +docker-compose up --build -d +``` + +## License + +ISC + +## Support + +For issues and questions, please open an issue in the project repository. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0a1397a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,40 @@ +# Multi-stage build for backend + +# Builder stage +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Production stage +FROM node:18-alpine + +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# Copy dependencies from builder +COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules + +# Copy application code +COPY --chown=nodejs:nodejs . . + +# Switch to non-root user +USER nodejs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +# Start application +CMD ["node", "src/index.js"] diff --git a/backend/lessons/LESSONS_DOCUMENTATION.md b/backend/lessons/LESSONS_DOCUMENTATION.md new file mode 100644 index 0000000..3368d9a --- /dev/null +++ b/backend/lessons/LESSONS_DOCUMENTATION.md @@ -0,0 +1,930 @@ +# Security Awareness Lessons Documentation + +This document provides comprehensive information about all available lessons in the platform, including learning objectives, content structure, interactive components, and implementation details. + +--- + +## Table of Contents + +1. [Phishing Email Detection Basics](#1-phishing-email-detection-basics) +2. [SQL Injection Attack - Online Shop Demo](#2-sql-injection-attack---online-shop-demo) +3. [Browser-in-the-Browser (BitB) Attack](#3-browser-in-the-browser-bitb-attack) +4. [Creating New Lessons](#creating-new-lessons) + +--- + +## 1. Phishing Email Detection Basics + +### Overview + +**Lesson Key:** `phishing-email-basics` +**Difficulty:** Beginner +**Duration:** 15 minutes +**Category:** Social Engineering / Email Security + +### Learning Objectives + +By the end of this lesson, participants will be able to: +- Identify common characteristics of phishing emails +- Recognize suspicious sender addresses and domains +- Detect urgency tactics used by attackers +- Understand link verification techniques +- Apply best practices for handling suspicious emails + +### Content Structure + +#### Steps: + +1. **Introduction to Phishing** (Content) + - Definition and impact of phishing + - Statistics on phishing attacks + - Why email is a primary attack vector + +2. **Common Red Flags** (Content) + - Suspicious sender addresses + - Spelling and grammar errors + - Urgent or threatening language + - Requests for sensitive information + - Suspicious links and attachments + +3. **Question 1: Identify Phishing Indicators** (Multiple Choice) + - **Points:** 50 total + - **Correct Answers:** + - Misspelled sender domain (15 pts) + - Generic greeting instead of name (10 pts) + - Urgent threat about account closure (15 pts) + - Suspicious link destination (10 pts) + - **Topic:** Recognition of multiple warning signs + +4. **Email Analysis Techniques** (Content) + - How to inspect sender information + - Hovering over links to check destinations + - Checking email headers + - Verifying legitimacy through official channels + +5. **Question 2: Safe Email Practices** (Single Choice) + - **Points:** 25 total + - **Correct Answer:** "Hover over links to verify destination before clicking" + - **Topic:** Proactive defense techniques + +6. **Question 3: Reporting Procedures** (Free Text) + - **Points:** 25 total + - **Validation:** Must mention "report", "IT", and "forward" + - **Topic:** Organizational response to phishing + +### Scoring + +- **Total Points:** 100 +- **Passing Score:** 70% +- **Question Distribution:** + - Multiple choice: 50 points (partial credit) + - Single choice: 25 points (all or nothing) + - Free text: 25 points (keyword-based) + +### Implementation Details + +**Files:** +- Config: `backend/lessons/configs/phishing-email-basics.yaml` +- Module: `backend/lessons/modules/phishing-email-basics/index.js` + +**Question Types:** +- Uses standard base class validation +- No custom interactive components +- Text-based content delivery + +### Best Practices for Teaching + +- Start with real-world examples +- Show actual phishing emails (sanitized) +- Emphasize the cost of successful attacks +- Practice with interactive email analysis +- Reinforce reporting procedures + +--- + +## 2. SQL Injection Attack - Online Shop Demo + +### Overview + +**Lesson Key:** `sql-injection-shop` +**Difficulty:** Intermediate +**Duration:** 20 minutes +**Category:** Web Application Security / OWASP Top 10 + +### Learning Objectives + +By the end of this lesson, participants will be able to: +- Understand how SQL injection vulnerabilities work +- Recognize vulnerable code patterns +- Execute SQL injection attacks in a safe environment +- Understand the difference between vulnerable and secure queries +- Apply parameterized queries as the primary defense + +### Content Structure + +#### Steps: + +1. **What is SQL Injection?** (Content) + - Definition and mechanism + - Types of damage (data theft, modification, deletion) + - Authentication bypass techniques + - Administrative operation exploitation + +2. **Vulnerable Online Shop** (Interactive) + - **Component:** `SQLShopDemo` + - Live product search with vulnerable SQL backend + - Real-time query visualization + - Injection detection and explanation + +3. **Question 1: Identify SQL Injection Payloads** (Multiple Choice) + - **Points:** 40 total + - **Correct Answers:** + - `' OR '1'='1` (15 pts) - Always-true condition + - `' UNION SELECT username, password FROM users--` (15 pts) - Data extraction + - `'; DROP TABLE products--` (10 pts) - Destructive attack + - **Topic:** Recognition of injection syntax + +4. **How SQL Injection Works** (Content) + - Query structure explanation + - Normal vs malicious input comparison + - Step-by-step breakdown of attacks + - Impact demonstration + +5. **Question 2: Prevention Methods** (Single Choice) + - **Points:** 30 total + - **Correct Answer:** "Use parameterized queries (prepared statements)" + - **Topic:** Gold-standard defense mechanism + +6. **Preventing SQL Injection** (Content) + - Parameterized queries (primary defense) + - Input validation strategies + - Least privilege principle + - Web Application Firewalls + - Security auditing + +7. **Question 3: Explain Parameterized Queries** (Free Text) + - **Points:** 30 total + - **Validation:** Must mention "parameter", "data", and "separate" + - **Minimum Length:** 50 characters + - **Topic:** Understanding separation of code and data + +### Interactive Component: SQLShopDemo + +#### Features: + +**Mock Database:** +```javascript +products: 8 items (laptops, accessories, office supplies) +users: 3 accounts (admin, john_doe, jane_smith) +orders: 2 sample orders +``` + +**Attack Scenarios:** + +1. **OR Injection:** + - Input: `' OR '1'='1` + - Result: Returns ALL products + - Explanation: Bypasses WHERE clause with always-true condition + +2. **UNION SELECT:** + - Input: `' UNION SELECT id, username, password, role, 'LEAKED' FROM users--` + - Result: Displays user credentials in product table + - Explanation: Combines product data with user table + +3. **DROP TABLE:** + - Input: `'; DROP TABLE products--` + - Result: Simulates table deletion + - Explanation: Executes destructive SQL command + +**UI Elements:** +- Search input with monospace font for code clarity +- "Vulnerable Search" button (red) - executes unsafe query +- "Safe Search" button (green) - uses parameterized query +- Quick-load example buttons +- Real-time SQL query display with syntax highlighting +- Injection detection warnings with emoji indicators +- Results table showing affected data +- Color-coded feedback (red for exploits, green for safe) + +#### Technical Implementation: + +**Backend Methods:** +```javascript +executeVulnerableQuery(searchTerm) + - Simulates vulnerable string concatenation + - Detects injection patterns + - Returns appropriate results based on attack type + +executeSafeQuery(searchTerm) + - Demonstrates parameterized approach + - Treats all input as literal data + - Shows query with placeholder syntax + +detectInjection(input) + - Regex-based pattern matching + - Identifies quotes, comments, SQL keywords + +analyzeInjection(input) + - Classifies injection type + - Generates educational explanation +``` + +**Frontend API Call:** +```javascript +participantAPI.executeLessonAction( + eventLessonId, + 'execute-query', + { searchTerm, mode: 'vulnerable' | 'safe' } +) +``` + +### Scoring + +- **Total Points:** 100 +- **Passing Score:** 70% +- **Question Distribution:** + - Multiple choice: 40 points (partial credit) + - Single choice: 30 points + - Free text: 30 points (keyword validation) + +### Implementation Details + +**Files:** +- Config: `backend/lessons/configs/sql-injection-shop.yaml` +- Module: `backend/lessons/modules/sql-injection-shop/index.js` +- Component: `frontend/src/components/lessons/InteractiveContent/SQLShopDemo.jsx` + +**Dependencies:** +- Extends `LessonModule` base class +- Custom `executeVulnerableQuery` method +- Custom `executeSafeQuery` method +- Interactive data provider + +**API Endpoint:** +- `POST /api/lesson/:eventLessonId/action/execute-query` +- Requires participant authentication +- Validates lesson is started + +### Best Practices for Teaching + +- Start with normal searches to establish baseline +- Progress from simple to complex injections +- Always compare vulnerable vs safe implementations +- Emphasize that filtering alone is insufficient +- Show real-world impact examples +- Demonstrate UNION attacks to highlight data exposure risk +- Use color coding to make injection obvious +- Provide immediate feedback on each attempt + +### Real-World Context + +**OWASP Ranking:** #3 in OWASP Top 10 (Injection) + +**Notable Incidents:** +- 2019: British Airways breach (380,000 transactions) +- 2020: Freepik SQL injection (8.3 million accounts) +- Ongoing: Automated scanning for vulnerable endpoints + +**Industry Standards:** +- OWASP recommends parameterized queries +- PCI DSS requires SQL injection prevention +- ISO 27001 covers secure coding practices + +--- + +## 3. Browser-in-the-Browser (BitB) Attack + +### Overview + +**Lesson Key:** `browser-in-browser-attack` +**Difficulty:** Advanced +**Duration:** 25 minutes +**Category:** Social Engineering / Advanced Phishing + +### Learning Objectives + +By the end of this lesson, participants will be able to: +- Understand Browser-in-the-Browser attack methodology +- Differentiate between real and fake browser windows +- Apply physical testing techniques to detect fake popups +- Recognize OAuth/SSO popup security implications +- Understand why password managers provide protection + +### Content Structure + +#### Steps: + +1. **What is Browser-in-the-Browser?** (Content) + - Definition and attack mechanism + - Why it's effective (mimics trusted UI) + - Comparison to traditional phishing + - Historical context (2022 emergence) + +2. **How the Attack Works** (Content) + - Traditional OAuth flow diagram + - BitB attack flow comparison + - Technical explanation (HTML/CSS fake browser) + - Visual deception techniques + +3. **Interactive BitB Demo** (Interactive) + - **Component:** `BitBDemo` + - Side-by-side real vs fake comparison + - Interactive detection testing + - Real-world attack examples + +4. **Question 1: Detection Indicators** (Multiple Choice) + - **Points:** 40 total + - **Correct Answers:** + - "Window cannot be dragged outside browser" (20 pts) + - "Right-click shows 'Inspect Element' on address bar" (20 pts) + - **Topic:** Physical behavior testing + +5. **Detection Techniques** (Content) + - Drag window test (primary method) + - Address bar selectability check + - Right-click inspection test + - Pixel-perfect detail examination + - Taskbar appearance verification + - Browser extension usage + +6. **Question 2: Safest Approach** (Single Choice) + - **Points:** 35 total + - **Correct Answer:** "Try to drag the popup outside browser window" + - **Topic:** Practical defense technique + +7. **Protecting Against BitB** (Content) + - User defenses (testing, 2FA, manual navigation) + - Developer responsibilities (education, redirect flow) + - Organizational measures (training, hardware keys) + +8. **Question 3: Password Manager Protection** (Free Text) + - **Points:** 25 total + - **Validation:** Must mention "domain", "autofill", and "real" + - **Minimum Length:** 40 characters + - **Topic:** Technical security controls + +### Interactive Component: BitBDemo + +#### Features: + +**Two Scenarios:** + +1. **Legitimate OAuth Popup (Simulated Real)** + - Provider: Google + - Domain: `accounts.google.com` + - Indicators shown for educational purposes + - Green "✅ REAL" badge + +2. **BitB Attack (Fake Popup)** + - Provider: Microsoft + - Domain: `login.microsoftonline.com` + - Trapped within page boundaries + - Red "⚠️ FAKE" badge + +**Interactive Tests:** + +1. **Drag Test:** + - Real: Would allow dragging (simulated) + - Fake: Cannot drag outside browser + - Feedback: Yellow warning appears when attempted + +2. **Right-Click Test:** + - Real: Browser context menu (simulated) + - Fake: Shows "Inspect Element" menu + - Feedback: Warning about HTML detection + +3. **Visual Inspection:** + - Fake window controls (non-functional buttons) + - Fake address bar (styled HTML div) + - Fake HTTPS lock icon (just an image) + +**UI Elements:** +- Side-by-side scenario cards +- Provider-specific styling (Google blue, Microsoft blue) +- Launch buttons to open fake popups +- Dark overlay when popup is active +- Educational indicators list +- Real-world attack timeline +- Test instructions panel + +**Realistic Browser Chrome:** +``` +- macOS-style window controls (red, yellow, green) +- Address bar with lock icon +- Provider-specific branding +- Login form (email + password) +- Sign-in button +``` + +#### Technical Implementation: + +**Frontend Structure:** +```javascript +renderFakeBrowser(scenario) + - Creates modal overlay + - Renders fake browser window + - Applies provider styling + - Handles drag attempts + - Handles right-click detection + - Shows feedback badges +``` + +**Drag Detection:** +```javascript +handleDragStart(e, isReal) + - Sets dragAttempted flag + - Prevents drag if fake (e.preventDefault) + - Shows educational feedback +``` + +**Right-Click Detection:** +```javascript +handleAddressBarRightClick(e, isReal) + - Sets inspectAttempted flag + - Allows context menu on fake popup + - Shows educational feedback +``` + +**Real-World Examples Data:** +```javascript +[ + { year: 2022, target: 'Corporate employees', provider: 'Microsoft OAuth' }, + { year: 2022, target: 'Cryptocurrency users', provider: 'Google Sign-in' }, + { year: 2023, target: 'GitHub developers', provider: 'GitHub OAuth' } +] +``` + +### Scoring + +- **Total Points:** 100 +- **Passing Score:** 75% +- **Question Distribution:** + - Multiple choice: 40 points (physical testing) + - Single choice: 35 points (best practice) + - Free text: 25 points (technical understanding) + +### Implementation Details + +**Files:** +- Config: `backend/lessons/configs/browser-in-browser-attack.yaml` +- Module: `backend/lessons/modules/browser-in-browser-attack/index.js` +- Component: `frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx` + +**Dependencies:** +- Extends `LessonModule` base class +- Custom `getInteractiveData` method +- React state management for popups +- CSS-in-JS for fake browser styling + +**Special Features:** +- Modal overlay system +- Drag prevention +- Context menu detection +- Provider theming +- Responsive design + +### Best Practices for Teaching + +- Emphasize that visual inspection alone is insufficient +- Demonstrate the drag test as the most reliable method +- Show how convincing the fake popups can be +- Discuss password manager benefits +- Explain why manual navigation is safest +- Reference real-world incidents +- Practice detection multiple times +- Warn about new variations + +### Real-World Context + +**Discovery:** 2022 by security researcher mr.d0x + +**Attack Campaign Examples:** +- **March 2022:** Steam account phishing +- **April 2022:** Cryptocurrency exchange targeting +- **May 2022:** Corporate credential harvesting +- **2023:** GitHub and GitLab developer targeting + +**Affected Platforms:** +- Any OAuth/SSO provider (Google, Microsoft, Facebook, GitHub) +- Banking sites with "secure" login popups +- Enterprise SSO systems +- Cryptocurrency wallets + +**Why It's Effective:** +- Mimics trusted UI perfectly +- Bypasses traditional phishing training +- Works on security-aware users +- No browser warnings triggered +- HTTPS indicators can be faked + +**Defense Evolution:** +- Hardware security keys (FIDO2/WebAuthn) immune +- Password managers check real domain +- Browser extensions can detect fake UI +- User education most critical + +--- + +## Creating New Lessons + +### Overview + +This section provides guidance for creating new lessons in the platform. + +### Lesson Structure + +Every lesson consists of two main components: + +1. **YAML Configuration File** (`lessons/configs/*.yaml`) + - Defines lesson metadata + - Structures content steps + - Configures questions and answers + - Sets scoring rules + +2. **JavaScript Module** (`lessons/modules/*/index.js`) + - Extends `LessonModule` base class + - Implements custom validation logic + - Provides interactive data + - Handles special behaviors + +### YAML Configuration Format + +```yaml +lessonKey: "unique-lesson-identifier" +title: "Lesson Display Title" +description: "Brief description for lesson catalog" +difficultyLevel: "beginner|intermediate|advanced" +estimatedDuration: 15 # minutes +module: "module-directory-name" + +steps: + - id: "step-1" + type: "content|question|interactive" + title: "Step Title" + content: "Step content (can be multiline)" + + - id: "question-1" + type: "question" + questionType: "single_choice|multiple_choice|free_text" + question: "The question text?" + options: # for choice questions + - id: "option-1" + text: "Option text" + isCorrect: true|false + points: 10 + validationRules: # for free_text questions + keywords: + required: ["keyword1", "keyword2"] + partialCredit: 5 + minLength: 50 + maxPoints: 25 + feedback: + correct: "Positive feedback" + incorrect: "Educational feedback" + +scoring: + passingScore: 70 + maxTotalPoints: 100 +``` + +### JavaScript Module Structure + +```javascript +const LessonModule = require('../base/LessonModule'); + +class YourLessonModule extends LessonModule { + constructor(config) { + super(config); + } + + // Optional: Custom answer validation + async validateAnswer(questionId, answer) { + // Custom logic here, or use: + return super.validateAnswer(questionId, answer); + } + + // Optional: Provide data for interactive components + getInteractiveData(stepId) { + if (stepId === 'your-interactive-step') { + return { + // Data your frontend component needs + }; + } + return null; + } + + // Optional: Custom methods for lesson-specific actions + yourCustomMethod(params) { + // Implementation + } +} + +module.exports = YourLessonModule; +``` + +### Question Types + +#### 1. Single Choice +```yaml +questionType: "single_choice" +options: + - id: "correct-option" + text: "The right answer" + isCorrect: true + points: 25 + - id: "wrong-option" + text: "An incorrect answer" + isCorrect: false + points: 0 +``` +- Only one correct answer +- All-or-nothing scoring +- Radio button UI + +#### 2. Multiple Choice +```yaml +questionType: "multiple_choice" +options: + - id: "correct-1" + text: "First correct answer" + isCorrect: true + points: 15 + - id: "correct-2" + text: "Second correct answer" + isCorrect: true + points: 15 + - id: "wrong-1" + text: "Incorrect answer" + isCorrect: false + points: 0 +``` +- Multiple correct answers +- Partial credit awarded per correct selection +- Checkbox UI + +#### 3. Free Text +```yaml +questionType: "free_text" +validationRules: + keywords: + required: ["must", "contain", "these"] + partialCredit: 10 # points if some keywords present + minLength: 50 # minimum character count +maxPoints: 25 +``` +- Open-ended response +- Keyword-based validation +- Minimum length requirement + +### Interactive Components + +#### Creating a New Interactive Component + +1. **Define in YAML:** +```yaml +- id: "interactive-demo" + type: "interactive" + title: "Interactive Demo" + interactiveComponent: "YourComponentName" + content: "Instructions for the interactive element" +``` + +2. **Provide Data in Module:** +```javascript +getInteractiveData(stepId) { + if (stepId === 'interactive-demo') { + return { + data: 'Your component data', + config: {} + }; + } + return null; +} +``` + +3. **Create React Component:** +```javascript +// frontend/src/components/lessons/InteractiveContent/YourComponent.jsx +import React from 'react'; + +const YourComponent = ({ lessonData, eventLessonId }) => { + const interactiveData = lessonData?.interactiveData || {}; + + return ( +
+ {/* Your interactive UI */} +
+ ); +}; + +export default YourComponent; +``` + +4. **Register in LessonView:** +```javascript +// frontend/src/pages/LessonView.jsx +import YourComponent from '../components/lessons/InteractiveContent/YourComponent'; + +// In render: +{currentStep.interactiveComponent === 'YourComponent' && ( + +)} +``` + +### Lesson-Specific Actions + +For interactive components that need backend processing: + +1. **Add Method to Module:** +```javascript +yourCustomAction(params) { + // Process params + return result; +} +``` + +2. **Handle in Controller:** +```javascript +// backend/src/controllers/lesson.controller.js +if (action === 'your-action' && lessonModule.yourCustomAction) { + result = lessonModule.yourCustomAction(actionData); +} +``` + +3. **Call from Frontend:** +```javascript +participantAPI.executeLessonAction( + eventLessonId, + 'your-action', + { data } +) +``` + +### Seeding New Lessons + +1. **Add to Catalog:** +```javascript +// backend/seed-lessons.js +const lessons = [ + { + lesson_key: 'your-lesson-key', + title: 'Your Lesson Title', + description: 'Description', + module_path: 'your-module-directory', + config_path: 'your-config.yaml', + difficulty_level: 'intermediate', + estimated_duration: 20 + } +]; +``` + +2. **Run Seed Script:** +```bash +docker exec lernplattform_backend node seed-lessons.js +``` + +### Best Practices + +**Content Design:** +- Start with clear learning objectives +- Use progressive difficulty (easy → hard) +- Provide immediate feedback +- Include real-world examples +- Make it hands-on when possible + +**Question Design:** +- Multiple choice: 2-4 options, avoid "all of the above" +- Free text: Clear validation criteria +- Feedback: Educational, not just correct/incorrect +- Points: Reflect question difficulty + +**Interactive Elements:** +- Make them essential, not decorative +- Provide clear instructions +- Give immediate visual feedback +- Include educational explanations +- Allow experimentation + +**Code Quality:** +- Follow existing patterns +- Handle errors gracefully +- Validate all inputs +- Comment complex logic +- Test thoroughly + +**Security:** +- Never execute actual SQL +- Sandbox all demonstrations +- Validate on both frontend and backend +- Don't leak sensitive information +- Use appropriate warnings + +### Testing New Lessons + +1. **Lesson Content:** + - All steps render correctly + - Images/media load properly + - Text formatting is correct + +2. **Questions:** + - Correct answers award proper points + - Wrong answers give appropriate feedback + - Partial credit calculates correctly + - Free text validation works + +3. **Interactive Components:** + - Components load without errors + - Actions execute successfully + - Feedback displays correctly + - Edge cases handled + +4. **Scoring:** + - Total points sum correctly + - Passing threshold works + - Score persists properly + - Leaderboard updates + +5. **Progress:** + - Lesson marked as started + - Navigation works (prev/next) + - Completion triggers properly + - Locked lessons stay locked + +### File Checklist + +- [ ] YAML config created +- [ ] Module directory created +- [ ] Module class implemented +- [ ] Interactive components (if any) created +- [ ] Seed script updated +- [ ] Lesson seeded to database +- [ ] Tested in browser +- [ ] Documentation updated + +--- + +## Appendix + +### Lesson Difficulty Guidelines + +**Beginner:** +- Foundational concepts +- No prior security knowledge required +- 10-15 minute duration +- Basic terminology introduction +- Real-world relevance emphasized + +**Intermediate:** +- Builds on basic security awareness +- Requires understanding of systems/networks +- 15-25 minute duration +- Hands-on demonstrations +- Technical explanations + +**Advanced:** +- Assumes security knowledge +- Complex attack scenarios +- 20-30 minute duration +- In-depth technical details +- Sophisticated defenses + +### Scoring Philosophy + +- **Partial Credit:** Encourage learning, reward partial knowledge +- **Passing Score:** 70-75% allows mistakes while ensuring competency +- **Question Weight:** Harder questions = more points +- **Immediate Feedback:** Don't wait until end of lesson + +### Content Style Guide + +**Tone:** +- Professional but approachable +- Educational, not preachy +- Objective about risks +- Encouraging about defenses + +**Formatting:** +- Use bullet points for lists +- Bold important terms on first use +- Code blocks for technical content +- Clear step-by-step instructions + +**Examples:** +- Prefer real-world incidents +- Include dates and sources +- Show impact (financial, reputational) +- Demonstrate both attacks and defenses + +--- + +## Support + +For questions about lesson development: +- Review existing lessons as templates +- Check base class methods in `LessonModule.js` +- Test in development environment first +- Document any new patterns + +**Last Updated:** 2026-01-12 +**Platform Version:** 1.0.0 +**Total Lessons:** 3 diff --git a/backend/lessons/README.md b/backend/lessons/README.md new file mode 100644 index 0000000..fdbca31 --- /dev/null +++ b/backend/lessons/README.md @@ -0,0 +1,310 @@ +# Lessons Directory + +This directory contains all lesson content for the security awareness training platform. + +## Structure + +``` +lessons/ +├── configs/ # YAML lesson configurations +│ ├── phishing-email-basics.yaml +│ ├── sql-injection-shop.yaml +│ └── browser-in-browser-attack.yaml +├── modules/ # JavaScript lesson modules +│ ├── base/ +│ │ └── LessonModule.js # Base class all lessons extend +│ ├── phishing-email-basics/ +│ │ └── index.js +│ ├── sql-injection-shop/ +│ │ └── index.js +│ └── browser-in-browser-attack/ +│ └── index.js +├── lesson-schema.json # JSON schema for validation (optional) +├── README.md # This file +└── LESSONS_DOCUMENTATION.md # Comprehensive lesson docs +``` + +## Available Lessons + +### 1. Phishing Email Detection Basics +- **Key:** `phishing-email-basics` +- **Difficulty:** Beginner +- **Duration:** 15 minutes +- **Topics:** Email security, social engineering, red flags +- **Interactive:** No + +### 2. SQL Injection Attack - Online Shop Demo +- **Key:** `sql-injection-shop` +- **Difficulty:** Intermediate +- **Duration:** 20 minutes +- **Topics:** Web security, OWASP Top 10, SQL injection +- **Interactive:** Yes - Fake shop with vulnerable search + +### 3. Browser-in-the-Browser (BitB) Attack +- **Key:** `browser-in-browser-attack` +- **Difficulty:** Advanced +- **Duration:** 25 minutes +- **Topics:** Advanced phishing, OAuth security, UI spoofing +- **Interactive:** Yes - Fake browser popup demos + +## Quick Start + +### Adding a New Lesson + +1. **Create YAML config:** + ```bash + cp configs/phishing-email-basics.yaml configs/your-lesson.yaml + # Edit the file with your lesson content + ``` + +2. **Create module:** + ```bash + mkdir modules/your-lesson + # Create index.js extending LessonModule + ``` + +3. **Seed to database:** + ```bash + # Add to seed script + docker exec lernplattform_backend node seed-lessons.js + ``` + +4. **Assign to event:** + - Use admin panel to assign lesson to events + - Configure points, weight, and order + +### Testing a Lesson + +1. **Seed the lesson** (see above) +2. **Assign to a test event** via admin panel +3. **Join event as participant** from hub page +4. **Complete the lesson** and verify: + - All steps render correctly + - Questions award proper points + - Interactive components work + - Score calculates correctly + +## Lesson Configuration Format + +### Minimal YAML Example + +```yaml +lessonKey: "my-lesson" +title: "My Lesson Title" +description: "Brief description" +difficultyLevel: "beginner" +estimatedDuration: 15 +module: "my-lesson" + +steps: + - id: "intro" + type: "content" + title: "Introduction" + content: "Lesson content here..." + + - id: "q1" + type: "question" + questionType: "single_choice" + question: "What is the answer?" + options: + - id: "correct" + text: "The right answer" + isCorrect: true + points: 100 + - id: "wrong" + text: "Wrong answer" + isCorrect: false + points: 0 + maxPoints: 100 + feedback: + correct: "Great job!" + incorrect: "Try again!" + +scoring: + passingScore: 70 + maxTotalPoints: 100 +``` + +### Minimal Module Example + +```javascript +const LessonModule = require('../base/LessonModule'); + +class MyLesson extends LessonModule { + constructor(config) { + super(config); + } + + // Use base class validation by default + // Override only if custom logic needed +} + +module.exports = MyLesson; +``` + +## Question Types + +### Single Choice +- One correct answer +- Radio buttons in UI +- All-or-nothing scoring + +### Multiple Choice +- Multiple correct answers +- Checkboxes in UI +- Partial credit per correct selection + +### Free Text +- Open-ended response +- Keyword-based validation +- Minimum length requirement + +## Interactive Components + +For lessons with interactive demos: + +1. **Define in YAML:** + ```yaml + - id: "demo" + type: "interactive" + title: "Interactive Demo" + interactiveComponent: "MyComponent" + ``` + +2. **Provide data in module:** + ```javascript + getInteractiveData(stepId) { + if (stepId === 'demo') { + return { /* component data */ }; + } + return null; + } + ``` + +3. **Create React component:** + ``` + frontend/src/components/lessons/InteractiveContent/MyComponent.jsx + ``` + +4. **Register in LessonView.jsx** + +## File Naming Conventions + +- **Lesson keys:** lowercase-with-hyphens +- **Config files:** `{lesson-key}.yaml` +- **Module directories:** `{lesson-key}/` +- **Module entry:** `index.js` + +## Best Practices + +### Content +- Start with clear learning objectives +- Use real-world examples +- Progress from easy to hard +- Provide immediate feedback +- Make it hands-on when possible + +### Questions +- 2-4 options for choice questions +- Clear, unambiguous wording +- Educational feedback (not just "correct"/"incorrect") +- Points reflect difficulty + +### Code +- Extend `LessonModule` base class +- Use base class methods when possible +- Comment complex logic +- Handle errors gracefully +- Validate all inputs + +### Security +- Never execute actual dangerous commands +- Sandbox all demonstrations +- Use appropriate warnings +- Don't leak sensitive data + +## Common Patterns + +### Multi-step Content Lesson +```yaml +steps: + - type: content (intro) + - type: content (main content) + - type: question + - type: content (summary) + - type: question +``` + +### Interactive Demo Lesson +```yaml +steps: + - type: content (intro) + - type: interactive (hands-on) + - type: question (about demo) + - type: content (explanation) + - type: question (best practices) +``` + +### Progressive Learning +```yaml +# Start easy +- Single choice with obvious answer +# Build complexity +- Multiple choice with several correct answers +# Test understanding +- Free text requiring explanation +``` + +## Troubleshooting + +### Lesson not appearing in admin panel +- Check if lesson was seeded to database +- Verify `lesson_key` matches between YAML and database +- Check database logs for errors + +### Questions not scoring correctly +- Verify `maxPoints` matches sum of correct option points +- For multiple choice, ensure points are on correct options +- Check `isCorrect` boolean values + +### Interactive component not loading +- Verify component name matches in YAML +- Check component is imported in LessonView.jsx +- Look for console errors in browser +- Verify `getInteractiveData()` returns data + +### Module not found errors +- Check `module_path` in database matches directory name +- Verify `index.js` exists in module directory +- Ensure `module.exports` is present +- Check for syntax errors in module + +## Documentation + +- **Comprehensive Guide:** [LESSONS_DOCUMENTATION.md](./LESSONS_DOCUMENTATION.md) +- **Base Class:** [modules/base/LessonModule.js](./modules/base/LessonModule.js) +- **Examples:** See existing lessons in `configs/` and `modules/` + +## Development Workflow + +1. **Design** lesson content and questions +2. **Create** YAML config and module +3. **Test** locally with seed script +4. **Assign** to test event +5. **Validate** all questions and interactions +6. **Review** scoring and feedback +7. **Deploy** to production event + +## Support + +For questions or issues: +1. Review existing lesson implementations +2. Check base class documentation +3. Test in development environment first +4. Review error logs for debugging + +--- + +**Last Updated:** 2026-01-12 +**Total Lessons:** 3 +**Platform Version:** 1.0.0 diff --git a/backend/lessons/configs/browser-in-browser-attack.yaml b/backend/lessons/configs/browser-in-browser-attack.yaml new file mode 100644 index 0000000..e3cec24 --- /dev/null +++ b/backend/lessons/configs/browser-in-browser-attack.yaml @@ -0,0 +1,174 @@ +lessonKey: "browser-in-browser-attack" +title: "Browser-in-the-Browser (BitB) Attack" +description: "Learn to identify sophisticated phishing attacks that mimic legitimate browser windows" +difficultyLevel: "advanced" +estimatedDuration: 25 +module: "browser-in-browser-attack" + +steps: + - id: "intro" + type: "content" + title: "What is Browser-in-the-Browser?" + content: | + Browser-in-the-Browser (BitB) is an advanced phishing technique that creates a fake browser window inside a webpage. It's designed to trick users into thinking they're interacting with a legitimate OAuth/SSO login popup. + + Why it's dangerous: + • Looks identical to real browser popup windows + • Shows a fake address bar with HTTPS lock icon + • Mimics trusted services (Google, Microsoft, Facebook) + • Can steal credentials even from security-aware users + • Bypasses traditional phishing detection + + This attack gained prominence in 2022 and has been used in targeted attacks against organizations. + + - id: "how-it-works" + type: "content" + title: "How the Attack Works" + content: | + Traditional OAuth Flow: + 1. User clicks "Sign in with Google" on a website + 2. Browser opens a REAL popup to google.com + 3. User enters credentials on Google's actual site + 4. Google redirects back with authentication token + + BitB Attack Flow: + 1. User clicks "Sign in with Google" on malicious site + 2. Site creates a FAKE popup using HTML/CSS/JavaScript + 3. Fake popup shows fake address bar displaying "accounts.google.com" + 4. User enters credentials on attacker's fake page + 5. Attacker captures credentials and simulates success + + The entire "browser window" is actually just HTML elements styled to look like a browser! + + - id: "bitb-demo" + type: "interactive" + title: "Interactive BitB Demo" + interactiveComponent: "BitBDemo" + content: | + Below you'll see two login scenarios. One uses a REAL browser popup (secure), and one uses a BitB attack (malicious). + + Can you identify the fake? Pay close attention to the details! + + - id: "question-1" + type: "question" + questionType: "multiple_choice" + question: "What are the key indicators that can help identify a Browser-in-the-Browser attack?" + options: + - id: "https-lock" + text: "The presence of HTTPS and a lock icon in the address bar" + isCorrect: false + points: 0 + - id: "window-behavior" + text: "The popup window cannot be dragged outside the main browser window" + isCorrect: true + points: 20 + - id: "inspect-element" + text: "Right-clicking allows you to 'Inspect Element' on the address bar" + isCorrect: true + points: 20 + - id: "domain-name" + text: "The domain name shown in the address bar" + isCorrect: false + points: 0 + maxPoints: 40 + feedback: + correct: "Excellent! Real browser windows can be moved anywhere and their UI cannot be inspected as HTML elements." + incorrect: "Think about what differentiates a real browser window from HTML/CSS elements on a webpage. The lock icon and domain can both be faked!" + + - id: "detection-techniques" + type: "content" + title: "Detecting BitB Attacks" + content: | + How to spot a Browser-in-the-Browser attack: + + 1. **Try to Drag the Window** + • Real popups can be dragged outside the browser + • Fake popups are trapped within the main window + + 2. **Check if Address Bar is Selectable** + • Real address bars: text is selectable + • Fake address bars: usually just an image or styled div + + 3. **Right-Click the Address Bar** + • Real browser: no "Inspect Element" option + • Fake browser: shows HTML inspection menu + + 4. **Look for Pixel-Perfect Details** + • Fake windows may have slight styling differences + • Shadow effects, fonts, or spacing might be off + + 5. **Check Your Browser's Task Bar** + • Real popups appear as separate windows in taskbar + • Fake popups don't create new window entries + + 6. **Use Browser Extensions** + • Some extensions can detect fake browser UI + + - id: "question-2" + type: "question" + questionType: "single_choice" + question: "A website asks you to 'Sign in with Microsoft' and a popup appears. What is the SAFEST approach?" + options: + - id: "trust-https" + text: "Check for HTTPS in the address bar and proceed if present" + isCorrect: false + points: 0 + - id: "test-window" + text: "Try to drag the popup outside the browser window to verify it's real" + isCorrect: true + points: 35 + - id: "check-domain" + text: "Carefully read the domain name to ensure it's Microsoft's real domain" + isCorrect: false + points: 0 + - id: "close-and-manual" + text: "Close the popup and manually navigate to Microsoft's site" + isCorrect: false + points: 10 + maxPoints: 35 + feedback: + correct: "Perfect! Testing if the window can be dragged outside the browser is the most reliable quick check. Though manually navigating is also very safe!" + incorrect: "While checking the domain helps, it can be faked in a BitB attack. The physical behavior of the window (can it be dragged out?) reveals the truth." + + - id: "prevention" + type: "content" + title: "Protecting Against BitB Attacks" + content: | + For Users: + • Always test if popup windows can be moved freely + • Use password managers (they check actual domains) + • Enable 2FA/MFA for additional security layer + • Be suspicious of unexpected login prompts + • Manually navigate to sites instead of clicking links + + For Developers: + • Educate users about OAuth popup behavior + • Use OAuth redirect flow instead of popups when possible + • Implement additional verification steps + • Consider passwordless authentication methods + • Show clear security indicators in your app + + For Organizations: + • Train employees to recognize advanced phishing + • Deploy anti-phishing browser extensions + • Use hardware security keys (FIDO2/WebAuthn) + • Monitor for suspicious authentication attempts + • Implement conditional access policies + + - id: "question-3" + type: "question" + questionType: "free_text" + question: "Why are password managers particularly effective at protecting against BitB attacks?" + validationRules: + keywords: + required: ["domain", "autofill", "real"] + partialCredit: 8 + minLength: 40 + maxPoints: 25 + feedback: + correct: "Excellent! Password managers check the actual domain of the page and won't autofill credentials on fake domains, even if they look legitimate." + incorrect: "Think about how password managers verify which site they're on before filling in credentials. They check the real URL, not what's displayed visually." + +scoring: + passingScore: 75 + maxTotalPoints: 100 diff --git a/backend/lessons/configs/phishing-email-basics.yaml b/backend/lessons/configs/phishing-email-basics.yaml new file mode 100644 index 0000000..a1181a4 --- /dev/null +++ b/backend/lessons/configs/phishing-email-basics.yaml @@ -0,0 +1,117 @@ +lessonKey: "phishing-email-basics" +title: "Phishing Email Detection Basics" +description: "Learn to identify common phishing tactics in emails and protect yourself from email-based attacks" +difficultyLevel: "beginner" +estimatedDuration: 15 +module: "phishing-email-basics" + +steps: + - id: "intro" + type: "content" + title: "What is Phishing?" + content: | + Phishing is a type of cyber attack where attackers impersonate legitimate organizations + to steal sensitive information like passwords, credit card numbers, or personal data. + + Phishing emails often: + - Create a sense of urgency + - Contain suspicious links or attachments + - Have spelling and grammar errors + - Use generic greetings like "Dear Customer" + - Request sensitive information + + - id: "example-1" + type: "content" + title: "Example Phishing Email" + content: | + **From:** security@paypa1-verify.com + **Subject:** Urgent: Verify Your Account Now! + + Dear Valued Customer, + + Your PayPal account has been temporarily suspended due to unusual activity. + To restore your account, please verify your information immediately by clicking + the link below: + + [Verify Account Now] + + Failure to verify within 24 hours will result in permanent account suspension. + + Thank you, + PayPal Security Team + + - id: "question-1" + type: "question" + questionType: "multiple_choice" + question: "What are the suspicious elements in this email? (Select all that apply)" + options: + - id: "misspelled-domain" + text: "The sender's domain is misspelled (paypa1 instead of paypal)" + isCorrect: true + points: 15 + - id: "urgent-language" + text: "Uses urgent/threatening language to create pressure" + isCorrect: true + points: 15 + - id: "generic-greeting" + text: "Uses generic greeting 'Dear Valued Customer'" + isCorrect: true + points: 10 + - id: "requests-action" + text: "Requests immediate action via a link" + isCorrect: true + points: 10 + - id: "legitimate" + text: "This appears to be a legitimate email" + isCorrect: false + points: 0 + maxPoints: 50 + feedback: + correct: "Excellent! You identified all the key phishing indicators." + partial: "Good job! You spotted some red flags, but review the email again carefully." + incorrect: "Not quite. Let's review the common signs of phishing emails." + + - id: "question-2" + type: "question" + questionType: "single_choice" + question: "What should you do if you receive a suspicious email like this?" + options: + - id: "click-link" + text: "Click the link to verify my account" + isCorrect: false + points: 0 + - id: "reply-email" + text: "Reply to the email asking if it's legitimate" + isCorrect: false + points: 0 + - id: "delete-report" + text: "Delete the email and report it as phishing" + isCorrect: true + points: 25 + - id: "forward-friends" + text: "Forward it to friends to warn them" + isCorrect: false + points: 0 + maxPoints: 25 + feedback: + correct: "Perfect! Deleting and reporting phishing emails is the right approach." + incorrect: "That's not safe. Never click links or reply to suspicious emails. Delete and report them." + + - id: "question-3" + type: "question" + questionType: "free_text" + question: "Describe at least three things you should check before clicking a link in an email." + validationRules: + - type: "contains_keywords" + keywords: ["sender", "domain", "url", "link", "https", "hover", "address", "spelling", "grammar"] + minMatches: 3 + - type: "min_length" + value: 50 + maxPoints: 25 + feedback: + correct: "Great answer! You understand the importance of verifying emails before taking action." + incorrect: "Consider checking the sender's email address, hovering over links to see the real URL, and looking for HTTPS." + +scoring: + passingScore: 70 + maxTotalPoints: 100 diff --git a/backend/lessons/configs/sql-injection-shop.yaml b/backend/lessons/configs/sql-injection-shop.yaml new file mode 100644 index 0000000..e9b4438 --- /dev/null +++ b/backend/lessons/configs/sql-injection-shop.yaml @@ -0,0 +1,143 @@ +lessonKey: "sql-injection-shop" +title: "SQL Injection Attack - Online Shop Demo" +description: "Learn how SQL injection vulnerabilities work through a realistic online shop scenario" +difficultyLevel: "intermediate" +estimatedDuration: 20 +module: "sql-injection-shop" + +steps: + - id: "intro" + type: "content" + title: "What is SQL Injection?" + content: | + SQL Injection is one of the most dangerous web application vulnerabilities. It occurs when an attacker can insert malicious SQL code into a query, allowing them to: + + • Access unauthorized data + • Modify or delete database records + • Bypass authentication + • Execute administrative operations + + In this lesson, you'll explore a vulnerable online shop to understand how SQL injection works and why proper input validation is critical. + + - id: "shop-demo" + type: "interactive" + title: "Vulnerable Online Shop" + interactiveComponent: "SQLShopDemo" + content: | + Below is a simplified online shop with a product search feature. The search functionality is vulnerable to SQL injection. + + Try searching for normal products first, then experiment with SQL injection techniques. + + - id: "question-1" + type: "question" + questionType: "multiple_choice" + question: "Which of the following search inputs could be used to exploit SQL injection?" + options: + - id: "normal-search" + text: "laptop" + isCorrect: false + points: 0 + - id: "single-quote" + text: "' OR '1'='1" + isCorrect: true + points: 15 + - id: "union-select" + text: "' UNION SELECT username, password FROM users--" + isCorrect: true + points: 15 + - id: "drop-table" + text: "'; DROP TABLE products--" + isCorrect: true + points: 10 + maxPoints: 40 + feedback: + correct: "Correct! These inputs manipulate the SQL query structure." + incorrect: "Review the demo. SQL injection exploits use special characters like quotes and SQL keywords." + + - id: "detection" + type: "content" + title: "How SQL Injection Works" + content: | + A vulnerable query might look like: + + SELECT * FROM products WHERE name LIKE '%[USER_INPUT]%' + + When a user searches for "laptop", the query becomes: + SELECT * FROM products WHERE name LIKE '%laptop%' + + But if they enter "' OR '1'='1", it becomes: + SELECT * FROM products WHERE name LIKE '%' OR '1'='1%' + + The OR '1'='1' condition is always true, so ALL products are returned! + + More dangerous attacks can extract data from other tables or even delete data. + + - id: "question-2" + type: "question" + questionType: "single_choice" + question: "What is the BEST way to prevent SQL injection vulnerabilities?" + options: + - id: "input-filtering" + text: "Filter out dangerous characters like quotes and semicolons" + isCorrect: false + points: 0 + - id: "parameterized-queries" + text: "Use parameterized queries (prepared statements)" + isCorrect: true + points: 30 + - id: "stored-procedures" + text: "Only use stored procedures for database access" + isCorrect: false + points: 0 + - id: "input-length" + text: "Limit the length of user inputs" + isCorrect: false + points: 0 + maxPoints: 30 + feedback: + correct: "Excellent! Parameterized queries separate SQL code from user data, making injection impossible." + incorrect: "While filtering helps, parameterized queries are the gold standard. They ensure user input is always treated as data, never as SQL code." + + - id: "mitigation" + type: "content" + title: "Preventing SQL Injection" + content: | + Best practices to prevent SQL injection: + + 1. **Parameterized Queries** (Most Important) + • Use prepared statements with bound parameters + • Never concatenate user input into SQL strings + + 2. **Input Validation** + • Validate data types (numbers, emails, etc.) + • Use allowlists for expected values + + 3. **Least Privilege** + • Database accounts should have minimal permissions + • Read-only accounts for read operations + + 4. **Web Application Firewalls** + • Can detect and block SQL injection attempts + • Should be used as an additional layer, not primary defense + + 5. **Regular Security Audits** + • Code reviews and penetration testing + • Automated vulnerability scanning + + - id: "question-3" + type: "question" + questionType: "free_text" + question: "In your own words, explain why parameterized queries prevent SQL injection." + validationRules: + keywords: + required: ["parameter", "data", "separate"] + partialCredit: 10 + minLength: 50 + maxPoints: 30 + feedback: + correct: "Great explanation! You understand that parameterized queries keep SQL structure separate from user data." + incorrect: "Think about how parameterized queries treat user input differently than string concatenation. Key concepts: separation of code and data." + +scoring: + passingScore: 70 + maxTotalPoints: 100 diff --git a/backend/lessons/modules/base/LessonModule.js b/backend/lessons/modules/base/LessonModule.js new file mode 100644 index 0000000..a7c7a6b --- /dev/null +++ b/backend/lessons/modules/base/LessonModule.js @@ -0,0 +1,230 @@ +/** + * Base class for all lesson modules + * All lesson modules should extend this class + */ +class LessonModule { + constructor(config) { + this.config = config; + this.lessonKey = config.lessonKey; + } + + /** + * Validate an answer for a specific question + * @param {string} questionId - The question identifier + * @param {any} answer - The participant's answer + * @returns {Object} { isCorrect, pointsAwarded, feedback } + */ + async validateAnswer(questionId, answer) { + const step = this.config.steps.find(s => s.id === questionId); + if (!step || step.type !== 'question') { + throw new Error(`Question ${questionId} not found`); + } + + return this._validateQuestionType(step, answer); + } + + /** + * Internal validation based on question type + */ + _validateQuestionType(step, answer) { + switch (step.questionType) { + case 'single_choice': + return this._validateSingleChoice(step, answer); + case 'multiple_choice': + return this._validateMultipleChoice(step, answer); + case 'free_text': + return this._validateFreeText(step, answer); + default: + throw new Error(`Unknown question type: ${step.questionType}`); + } + } + + _validateSingleChoice(step, answer) { + const selectedOption = step.options.find(opt => opt.id === answer); + if (!selectedOption) { + return { + isCorrect: false, + pointsAwarded: 0, + feedback: step.feedback?.incorrect || 'Incorrect answer' + }; + } + + return { + isCorrect: selectedOption.isCorrect, + pointsAwarded: selectedOption.isCorrect ? selectedOption.points : 0, + feedback: selectedOption.isCorrect + ? (step.feedback?.correct || 'Correct!') + : (step.feedback?.incorrect || 'Incorrect answer') + }; + } + + _validateMultipleChoice(step, answers) { + // answers should be an array of option IDs + if (!Array.isArray(answers)) { + return { + isCorrect: false, + pointsAwarded: 0, + feedback: step.feedback?.incorrect || 'Invalid answer format' + }; + } + + const correctOptions = step.options.filter(opt => opt.isCorrect).map(opt => opt.id); + const selectedCorrect = answers.filter(a => correctOptions.includes(a)); + const selectedIncorrect = answers.filter(a => !correctOptions.includes(a)); + + // Calculate points + const pointsAwarded = selectedCorrect.reduce((sum, id) => { + const option = step.options.find(opt => opt.id === id); + return sum + (option?.points || 0); + }, 0); + + const isFullyCorrect = selectedCorrect.length === correctOptions.length && + selectedIncorrect.length === 0; + const isPartiallyCorrect = selectedCorrect.length > 0 && !isFullyCorrect; + + let feedback = step.feedback?.incorrect || 'Incorrect answer'; + if (isFullyCorrect) { + feedback = step.feedback?.correct || 'Correct!'; + } else if (isPartiallyCorrect) { + feedback = step.feedback?.partial || step.feedback?.correct || 'Partially correct'; + } + + return { + isCorrect: isFullyCorrect, + isPartial: isPartiallyCorrect, + pointsAwarded, + feedback + }; + } + + _validateFreeText(step, answer) { + if (!answer || typeof answer !== 'string') { + return { + isCorrect: false, + pointsAwarded: 0, + feedback: step.feedback?.incorrect || 'Answer is required' + }; + } + + if (!step.validationRules || step.validationRules.length === 0) { + // No validation rules, accept any non-empty answer + const points = answer.trim().length > 0 ? step.maxPoints : 0; + return { + isCorrect: points > 0, + pointsAwarded: points, + feedback: points > 0 + ? (step.feedback?.correct || 'Answer received') + : (step.feedback?.incorrect || 'Answer is too short') + }; + } + + let passedRules = 0; + const totalRules = step.validationRules.length; + + for (const rule of step.validationRules) { + if (this._checkValidationRule(rule, answer)) { + passedRules++; + } + } + + const scorePercentage = passedRules / totalRules; + const pointsAwarded = Math.round(step.maxPoints * scorePercentage); + const isCorrect = scorePercentage >= 0.7; // 70% threshold + + return { + isCorrect, + pointsAwarded, + feedback: isCorrect + ? (step.feedback?.correct || 'Good answer!') + : (step.feedback?.incorrect || 'Please review your answer') + }; + } + + _checkValidationRule(rule, answer) { + const lowerAnswer = (answer || '').toLowerCase(); + + switch (rule.type) { + case 'contains_keywords': + const matches = rule.keywords.filter(keyword => + lowerAnswer.includes(keyword.toLowerCase()) + ).length; + return matches >= (rule.minMatches || 1); + + case 'min_length': + return answer.length >= rule.value; + + case 'max_length': + return answer.length <= rule.value; + + case 'regex': + return new RegExp(rule.pattern, rule.flags || 'i').test(answer); + + default: + return false; + } + } + + /** + * Get interactive component data for a step + * Can be overridden by subclasses for dynamic content + */ + async getInteractiveData(stepId) { + const step = this.config.steps.find(s => s.id === stepId); + if (!step || step.type !== 'interactive') { + throw new Error(`Interactive step ${stepId} not found`); + } + + return { + component: step.interactiveComponent, + props: step.componentProps || {} + }; + } + + /** + * Get lesson content for rendering (without answers) + */ + getContent() { + return { + lessonKey: this.lessonKey, + title: this.config.title, + description: this.config.description, + difficultyLevel: this.config.difficultyLevel, + estimatedDuration: this.config.estimatedDuration, + steps: this.config.steps.map(step => ({ + id: step.id, + type: step.type, + title: step.title, + content: step.content, + // For question steps, don't send correct answers + ...(step.type === 'question' && { + questionType: step.questionType, + question: step.question, + maxPoints: step.maxPoints, + options: step.options?.map(opt => ({ + id: opt.id, + text: opt.text + // isCorrect and points are intentionally omitted + })) + }), + // For interactive steps, send component info + ...(step.type === 'interactive' && { + interactiveComponent: step.interactiveComponent, + componentProps: step.componentProps + }) + })), + scoring: { + maxTotalPoints: this.config.scoring?.maxTotalPoints || 100, + passingScore: this.config.scoring?.passingScore || 70 + } + }; + } + + /** + * Get full configuration (for debugging/admin) + */ + getFullConfig() { + return this.config; + } +} + +module.exports = LessonModule; diff --git a/backend/lessons/modules/browser-in-browser-attack/index.js b/backend/lessons/modules/browser-in-browser-attack/index.js new file mode 100644 index 0000000..1d05a8e --- /dev/null +++ b/backend/lessons/modules/browser-in-browser-attack/index.js @@ -0,0 +1,83 @@ +const LessonModule = require('../base/LessonModule'); + +class BrowserInBrowserLesson extends LessonModule { + constructor(config) { + super(config); + } + + // Get interactive data for the BitB demo + getInteractiveData(stepId) { + if (stepId === 'bitb-demo') { + return { + scenarios: [ + { + id: 'legitimate', + title: 'Legitimate OAuth Popup', + provider: 'Google', + domain: 'accounts.google.com', + isReal: true, + description: 'This simulates how a REAL browser popup would behave', + indicators: [ + 'Can be dragged outside browser window', + 'Has native window controls', + 'Address bar text is not selectable (real browser UI)', + 'Right-click shows browser context menu, not page menu', + 'Appears as separate window in system taskbar' + ] + }, + { + id: 'bitb-attack', + title: 'Browser-in-the-Browser Attack', + provider: 'Microsoft', + domain: 'login.microsoftonline.com', + isReal: false, + description: 'This is a FAKE popup window created with HTML/CSS/JavaScript', + indicators: [ + 'Cannot be dragged outside the main browser window', + 'Entire window is trapped within the page boundaries', + 'Address bar is just HTML text/image (right-click shows Inspect)', + 'Window controls (minimize, maximize, close) are fake buttons', + 'Does not appear in system taskbar as separate window' + ] + } + ], + testInstructions: [ + 'Try to drag each popup window outside the main browser area', + 'Right-click on the address bar to see if you can inspect it as HTML', + 'Look for subtle differences in fonts, spacing, or shadows', + 'Check if the window controls behave like real browser buttons', + 'Notice if the popup can extend beyond the main window boundaries' + ], + realWorldExamples: [ + { + year: 2022, + target: 'Corporate employees', + provider: 'Microsoft OAuth', + description: 'Attackers used BitB to steal enterprise credentials' + }, + { + year: 2022, + target: 'Cryptocurrency users', + provider: 'Google Sign-in', + description: 'Fake crypto platforms used BitB for account takeovers' + }, + { + year: 2023, + target: 'GitHub developers', + provider: 'GitHub OAuth', + description: 'Malicious sites mimicked GitHub login to steal tokens' + } + ] + }; + } + return null; + } + + // Validate specific BitB detection knowledge + async validateAnswer(questionId, answer) { + // Use base class validation for standard question types + return super.validateAnswer(questionId, answer); + } +} + +module.exports = BrowserInBrowserLesson; diff --git a/backend/lessons/modules/phishing-email-basics/index.js b/backend/lessons/modules/phishing-email-basics/index.js new file mode 100644 index 0000000..04d3b94 --- /dev/null +++ b/backend/lessons/modules/phishing-email-basics/index.js @@ -0,0 +1,16 @@ +const LessonModule = require('../base/LessonModule'); + +/** + * Phishing Email Detection Basics Lesson + * Teaches participants to identify common phishing tactics + */ +class PhishingEmailBasicsLesson extends LessonModule { + constructor(config) { + super(config); + } + + // This lesson uses the default validation from the base class + // No custom validation needed for this beginner lesson +} + +module.exports = PhishingEmailBasicsLesson; diff --git a/backend/lessons/modules/sql-injection-shop/index.js b/backend/lessons/modules/sql-injection-shop/index.js new file mode 100644 index 0000000..48a69e5 --- /dev/null +++ b/backend/lessons/modules/sql-injection-shop/index.js @@ -0,0 +1,209 @@ +const LessonModule = require('../base/LessonModule'); + +class SQLInjectionShopLesson extends LessonModule { + constructor(config) { + super(config); + } + + // Mock database with products + getMockDatabase() { + return { + products: [ + { id: 1, name: 'Laptop Pro 15', price: 1299.99, category: 'Electronics', stock: 15 }, + { id: 2, name: 'Wireless Mouse', price: 29.99, category: 'Accessories', stock: 50 }, + { id: 3, name: 'USB-C Cable', price: 12.99, category: 'Accessories', stock: 100 }, + { id: 4, name: 'Gaming Keyboard', price: 89.99, category: 'Electronics', stock: 25 }, + { id: 5, name: 'Monitor 27"', price: 349.99, category: 'Electronics', stock: 20 }, + { id: 6, name: 'Webcam HD', price: 79.99, category: 'Electronics', stock: 30 }, + { id: 7, name: 'Desk Lamp', price: 34.99, category: 'Office', stock: 40 }, + { id: 8, name: 'Notebook Set', price: 15.99, category: 'Office', stock: 60 } + ], + users: [ + { id: 1, username: 'admin', password: 'hashed_admin_password', role: 'admin' }, + { id: 2, username: 'john_doe', password: 'hashed_user_password', role: 'customer' }, + { id: 3, username: 'jane_smith', password: 'hashed_user_password', role: 'customer' } + ], + orders: [ + { id: 1, user_id: 2, total: 1329.98, status: 'shipped' }, + { id: 2, user_id: 3, total: 89.99, status: 'processing' } + ] + }; + } + + // Simulate vulnerable SQL query + executeVulnerableQuery(searchTerm) { + const db = this.getMockDatabase(); + + // Build the "vulnerable" query string for educational display + const vulnerableQuery = `SELECT * FROM products WHERE name LIKE '%${searchTerm}%'`; + + // Detect SQL injection attempts + const injectionDetected = this.detectInjection(searchTerm); + + let results = []; + let injectionType = null; + let explanation = ''; + + if (injectionDetected) { + const injectionInfo = this.analyzeInjection(searchTerm); + injectionType = injectionInfo.type; + explanation = injectionInfo.explanation; + + // Simulate different injection results + if (injectionInfo.type === 'OR_ALWAYS_TRUE') { + // Return all products (simulating OR '1'='1') + results = db.products; + } else if (injectionInfo.type === 'UNION_SELECT') { + // Simulate UNION attack showing user data + results = [ + { id: 'INJECTED', name: 'admin', price: 'hashed_admin_password', category: 'LEAKED DATA', stock: 'admin' }, + { id: 'INJECTED', name: 'john_doe', price: 'hashed_user_password', category: 'LEAKED DATA', stock: 'customer' }, + { id: 'INJECTED', name: 'jane_smith', price: 'hashed_user_password', category: 'LEAKED DATA', stock: 'customer' } + ]; + } else if (injectionInfo.type === 'DROP_TABLE') { + // Simulate destructive command + results = []; + explanation += ' In a real scenario, this could delete the entire products table!'; + } else if (injectionInfo.type === 'COMMENT_INJECTION') { + // Bypass rest of query + results = db.products; + } + } else { + // Normal search - filter products by name + results = db.products.filter(p => + p.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return { + query: vulnerableQuery, + results, + injectionDetected, + injectionType, + explanation, + recordCount: results.length + }; + } + + // Detect if input contains SQL injection + detectInjection(input) { + const injectionPatterns = [ + /'/, // Single quote + /--/, // SQL comment + /;/, // Statement separator + /union/i, // UNION keyword + /select/i, // SELECT keyword + /drop/i, // DROP keyword + /insert/i, // INSERT keyword + /update/i, // UPDATE keyword + /delete/i, // DELETE keyword + /or\s+['"]?\d+['"]?\s*=\s*['"]?\d+['"]?/i // OR 1=1 pattern + ]; + + return injectionPatterns.some(pattern => pattern.test(input)); + } + + // Analyze the type of SQL injection + analyzeInjection(input) { + const lowerInput = input.toLowerCase(); + + if (lowerInput.includes('union') && lowerInput.includes('select')) { + return { + type: 'UNION_SELECT', + explanation: '⚠️ UNION SELECT injection detected! This technique combines results from multiple tables, potentially exposing sensitive data like usernames and passwords.' + }; + } + + if (lowerInput.includes('drop')) { + return { + type: 'DROP_TABLE', + explanation: '🚨 DROP TABLE injection detected! This is a destructive attack that could delete entire database tables. Critical data loss would occur!' + }; + } + + if (lowerInput.includes("'") && (lowerInput.includes('or') || lowerInput.includes('||'))) { + if (lowerInput.match(/or\s+['"]?\d+['"]?\s*=\s*['"]?\d+['"]?/)) { + return { + type: 'OR_ALWAYS_TRUE', + explanation: "⚠️ OR injection detected! The condition '1'='1' is always true, bypassing the intended filter and returning ALL records." + }; + } + } + + if (lowerInput.includes('--') || lowerInput.includes('#')) { + return { + type: 'COMMENT_INJECTION', + explanation: '⚠️ Comment injection detected! The -- sequence comments out the rest of the SQL query, potentially bypassing security checks.' + }; + } + + if (lowerInput.includes(';')) { + return { + type: 'MULTIPLE_STATEMENTS', + explanation: '⚠️ Multiple statement injection detected! The semicolon allows execution of additional SQL commands, enabling complex attacks.' + }; + } + + // Generic injection + return { + type: 'GENERIC', + explanation: '⚠️ SQL injection attempt detected! Special characters in the input could manipulate the query structure.' + }; + } + + // Demonstrate safe parameterized query + executeSafeQuery(searchTerm) { + const db = this.getMockDatabase(); + + // Show the safe query with placeholder + const safeQuery = `SELECT * FROM products WHERE name LIKE ?`; + const parameter = `%${searchTerm}%`; + + // Execute safe search (treats all input as literal data) + const results = db.products.filter(p => + p.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return { + query: safeQuery, + parameter, + results, + explanation: '✅ Parameterized query used! User input is treated as data only, never as SQL code. Injection is impossible.', + recordCount: results.length + }; + } + + // Get interactive data for the SQL shop demo + getInteractiveData(stepId) { + if (stepId === 'shop-demo') { + return { + database: this.getMockDatabase(), + examples: [ + { + label: 'Normal Search', + input: 'laptop', + description: 'Search for products containing "laptop"' + }, + { + label: 'View All Products (OR injection)', + input: "' OR '1'='1", + description: 'Exploit: Returns all products by making condition always true' + }, + { + label: 'Extract User Data (UNION)', + input: "' UNION SELECT id, username, password, role, 'LEAKED' FROM users--", + description: 'Exploit: Combines product results with user table data' + }, + { + label: 'Destructive Attack (DROP)', + input: "'; DROP TABLE products--", + description: 'Exploit: Attempts to delete the products table' + } + ] + }; + } + return null; + } +} + +module.exports = SQLInjectionShopLesson; diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..c76cd05 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2186 @@ +{ + "name": "lernplattform-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lernplattform-backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "pg": "^8.11.3", + "uuid": "^9.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/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/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "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/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/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/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/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-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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-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.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "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/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/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/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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/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/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-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/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-validator": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", + "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/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-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/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/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "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/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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/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/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "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/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/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-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/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/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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "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/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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "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/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/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/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.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "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-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "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/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/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/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "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/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/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" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..8b201b7 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,35 @@ +{ + "name": "lernplattform-backend", + "version": "1.0.0", + "description": "Backend API for Security Awareness Learning Platform", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "security", + "learning", + "platform", + "education" + ], + "author": "", + "license": "ISC", + "dependencies": { + "express": "^4.18.2", + "pg": "^8.11.3", + "bcrypt": "^5.1.1", + "jsonwebtoken": "^9.0.2", + "js-yaml": "^4.1.0", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/backend/seed-new-lessons.js b/backend/seed-new-lessons.js new file mode 100644 index 0000000..a69294f --- /dev/null +++ b/backend/seed-new-lessons.js @@ -0,0 +1,78 @@ +const { pool } = require('./src/config/database'); + +const lessons = [ + { + lesson_key: 'sql-injection-shop', + title: 'SQL Injection Attack - Online Shop Demo', + description: 'Learn how SQL injection vulnerabilities work through a realistic online shop scenario', + module_path: 'sql-injection-shop', + config_path: 'sql-injection-shop.yaml', + difficulty_level: 'intermediate', + estimated_duration: 20 + }, + { + lesson_key: 'browser-in-browser-attack', + title: 'Browser-in-the-Browser (BitB) Attack', + description: 'Learn to identify sophisticated phishing attacks that mimic legitimate browser windows', + module_path: 'browser-in-browser-attack', + config_path: 'browser-in-browser-attack.yaml', + difficulty_level: 'advanced', + estimated_duration: 25 + } +]; + +async function seedLessons() { + const client = await pool.connect(); + + try { + console.log('Starting to seed new lessons...'); + + for (const lesson of lessons) { + // Check if lesson already exists + const existingResult = await client.query( + 'SELECT id FROM lessons WHERE lesson_key = $1', + [lesson.lesson_key] + ); + + if (existingResult.rows.length > 0) { + console.log(`Lesson "${lesson.lesson_key}" already exists, skipping...`); + continue; + } + + // Insert new lesson + const result = await client.query( + `INSERT INTO lessons (lesson_key, title, description, module_path, config_path, difficulty_level, estimated_duration) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id`, + [ + lesson.lesson_key, + lesson.title, + lesson.description, + lesson.module_path, + lesson.config_path, + lesson.difficulty_level, + lesson.estimated_duration + ] + ); + + console.log(`✓ Created lesson: ${lesson.title} (ID: ${result.rows[0].id})`); + } + + console.log('\n✅ All new lessons seeded successfully!'); + console.log('\nYou can now assign these lessons to events via the admin panel.'); + + } catch (error) { + console.error('Error seeding lessons:', error); + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +seedLessons() + .then(() => process.exit(0)) + .catch((error) => { + console.error('Seed failed:', error); + process.exit(1); + }); diff --git a/backend/src/config/database.js b/backend/src/config/database.js new file mode 100644 index 0000000..d8b6909 --- /dev/null +++ b/backend/src/config/database.js @@ -0,0 +1,60 @@ +const { Pool } = require('pg'); +const config = require('./environment'); + +// Create PostgreSQL connection pool +const pool = new Pool({ + host: config.database.host, + port: config.database.port, + database: config.database.name, + user: config.database.user, + password: config.database.password, + max: 20, // Maximum number of clients in the pool + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}); + +// Test connection +pool.on('connect', () => { + console.log('Database connected successfully'); +}); + +pool.on('error', (err) => { + console.error('Unexpected database error:', err); + process.exit(-1); +}); + +// Query helper function +const query = async (text, params) => { + const start = Date.now(); + try { + const res = await pool.query(text, params); + const duration = Date.now() - start; + console.log('Executed query', { text, duration, rows: res.rowCount }); + return res; + } catch (error) { + console.error('Database query error:', error); + throw error; + } +}; + +// Transaction helper +const transaction = async (callback) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +module.exports = { + pool, + query, + transaction +}; diff --git a/backend/src/config/environment.js b/backend/src/config/environment.js new file mode 100644 index 0000000..c256dc8 --- /dev/null +++ b/backend/src/config/environment.js @@ -0,0 +1,33 @@ +require('dotenv').config(); + +module.exports = { + // Server configuration + nodeEnv: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT || '3000', 10), + + // Database configuration + database: { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + name: process.env.DB_NAME || 'lernplattform', + user: process.env.DB_USER || 'lernplattform_user', + password: process.env.DB_PASSWORD || 'changeme123' + }, + + // Security configuration + jwtSecret: process.env.JWT_SECRET || 'change_this_secret_key_in_production', + sessionSecret: process.env.SESSION_SECRET || 'change_this_session_secret', + sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '3600000', 10), // 1 hour default + + // Admin configuration + adminDefaultPassword: process.env.ADMIN_DEFAULT_PASSWORD || 'admin123', + + // Logging configuration + logLevel: process.env.LOG_LEVEL || 'info', + + // CORS configuration + corsOrigin: process.env.CORS_ORIGIN || '*', + + // Paths + lessonsPath: process.env.LESSONS_PATH || './lessons' +}; diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js new file mode 100644 index 0000000..55e8ff6 --- /dev/null +++ b/backend/src/controllers/admin.controller.js @@ -0,0 +1,96 @@ +const { ApiError } = require('../middleware/errorHandler'); +const { generateAdminToken, verifyPassword } = require('../middleware/auth'); +const db = require('../config/database'); + +/** + * Admin login + * POST /api/admin/login + */ +const login = async (req, res) => { + const { username, password } = req.body; + + // Validate input + if (!username || !password) { + throw new ApiError(400, 'Username and password are required'); + } + + // Get admin from database + const result = await db.query( + 'SELECT id, username, password_hash FROM admin_users WHERE username = $1', + [username] + ); + + if (result.rows.length === 0) { + throw new ApiError(401, 'Invalid credentials'); + } + + const admin = result.rows[0]; + + // Verify password + const isValidPassword = await verifyPassword(password, admin.password_hash); + + if (!isValidPassword) { + throw new ApiError(401, 'Invalid credentials'); + } + + // Update last login timestamp + await db.query( + 'UPDATE admin_users SET last_login = CURRENT_TIMESTAMP WHERE id = $1', + [admin.id] + ); + + // Generate token + const token = generateAdminToken(admin.id, admin.username); + + res.json({ + success: true, + message: 'Login successful', + data: { + token, + admin: { + id: admin.id, + username: admin.username + } + } + }); +}; + +/** + * Get current admin profile + * GET /api/admin/profile + */ +const getProfile = async (req, res) => { + const result = await db.query( + 'SELECT id, username, created_at, last_login FROM admin_users WHERE id = $1', + [req.admin.id] + ); + + if (result.rows.length === 0) { + throw new ApiError(404, 'Admin not found'); + } + + res.json({ + success: true, + data: result.rows[0] + }); +}; + +/** + * Verify token (for client-side token validation) + * GET /api/admin/verify + */ +const verifyToken = async (req, res) => { + res.json({ + success: true, + message: 'Token is valid', + data: { + admin: req.admin + } + }); +}; + +module.exports = { + login, + getProfile, + verifyToken +}; diff --git a/backend/src/controllers/adminLesson.controller.js b/backend/src/controllers/adminLesson.controller.js new file mode 100644 index 0000000..6d885e5 --- /dev/null +++ b/backend/src/controllers/adminLesson.controller.js @@ -0,0 +1,156 @@ +const { ApiError } = require('../middleware/errorHandler'); +const lessonQueries = require('../models/queries/lesson.queries'); +const eventQueries = require('../models/queries/event.queries'); + +/** + * Get all lessons + * GET /api/admin/lessons + */ +const getAllLessons = async (req, res) => { + const lessons = await lessonQueries.getAllLessons(); + + res.json({ + success: true, + data: lessons + }); +}; + +/** + * Assign lesson to event + * POST /api/admin/events/:eventId/lessons + */ +const assignLessonToEvent = async (req, res) => { + const { eventId } = req.params; + let { lessonId, orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId } = req.body; + + // Validate required fields + if (!lessonId) { + throw new ApiError(400, 'Lesson ID is required'); + } + + // Check if event exists + const eventExists = await eventQueries.eventExists(eventId); + if (!eventExists) { + throw new ApiError(404, 'Event not found'); + } + + // Check if lesson exists + const lessonExists = await lessonQueries.lessonExists(lessonId); + if (!lessonExists) { + throw new ApiError(404, 'Lesson not found'); + } + + // Check if lesson is already assigned to this event + const existingLessons = await lessonQueries.getEventLessons(eventId); + const alreadyAssigned = existingLessons.find(el => el.lesson_id === parseInt(lessonId)); + if (alreadyAssigned) { + throw new ApiError(400, 'This lesson is already assigned to this event'); + } + + // If no order index specified, calculate next available + if (orderIndex === undefined) { + const maxOrder = existingLessons.reduce((max, el) => Math.max(max, el.order_index), 0); + orderIndex = maxOrder + 1; + } else { + // Check if order index conflicts + const orderConflict = existingLessons.find(el => el.order_index === parseInt(orderIndex)); + if (orderConflict) { + // Auto-increment to next available + const maxOrder = existingLessons.reduce((max, el) => Math.max(max, el.order_index), 0); + orderIndex = maxOrder + 1; + } + } + + // Assign lesson to event + const eventLesson = await lessonQueries.assignLessonToEvent( + eventId, + lessonId, + orderIndex, + maxPoints || 100, + weight || 1.0, + isRequired !== undefined ? isRequired : true, + unlockAfterLessonId + ); + + res.status(201).json({ + success: true, + message: 'Lesson assigned to event', + data: eventLesson + }); +}; + +/** + * Get lessons assigned to an event + * GET /api/admin/events/:eventId/lessons + */ +const getEventLessons = async (req, res) => { + const { eventId } = req.params; + + // Check if event exists + const eventExists = await eventQueries.eventExists(eventId); + if (!eventExists) { + throw new ApiError(404, 'Event not found'); + } + + const lessons = await lessonQueries.getEventLessons(eventId); + + res.json({ + success: true, + data: lessons + }); +}; + +/** + * Update event lesson configuration + * PUT /api/admin/events/:eventId/lessons/:eventLessonId + */ +const updateEventLesson = async (req, res) => { + const { eventLessonId } = req.params; + const { orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId } = req.body; + + const updates = {}; + if (orderIndex !== undefined) updates.order_index = orderIndex; + if (maxPoints !== undefined) updates.max_points = maxPoints; + if (weight !== undefined) updates.weight = weight; + if (isRequired !== undefined) updates.is_required = isRequired; + if (unlockAfterLessonId !== undefined) updates.unlock_after_lesson_id = unlockAfterLessonId; + + const updated = await lessonQueries.updateEventLesson(eventLessonId, updates); + + if (!updated) { + throw new ApiError(404, 'Event lesson not found'); + } + + res.json({ + success: true, + message: 'Event lesson updated', + data: updated + }); +}; + +/** + * Remove lesson from event + * DELETE /api/admin/events/:eventId/lessons/:eventLessonId + */ +const removeEventLesson = async (req, res) => { + const { eventLessonId } = req.params; + + const removed = await lessonQueries.removeEventLesson(eventLessonId); + + if (!removed) { + throw new ApiError(404, 'Event lesson not found'); + } + + res.json({ + success: true, + message: 'Lesson removed from event' + }); +}; + +module.exports = { + getAllLessons, + assignLessonToEvent, + getEventLessons, + updateEventLesson, + removeEventLesson +}; diff --git a/backend/src/controllers/event.controller.js b/backend/src/controllers/event.controller.js new file mode 100644 index 0000000..e2c1ab4 --- /dev/null +++ b/backend/src/controllers/event.controller.js @@ -0,0 +1,190 @@ +const { ApiError } = require('../middleware/errorHandler'); +const eventQueries = require('../models/queries/event.queries'); +const participantQueries = require('../models/queries/participant.queries'); + +/** + * Create a new event + * POST /api/admin/events + */ +const createEvent = async (req, res) => { + const { name, description, startDate, endDate } = req.body; + + // Validate input + if (!name) { + throw new ApiError(400, 'Event name is required'); + } + + if (name.length < 3 || name.length > 255) { + throw new ApiError(400, 'Event name must be between 3 and 255 characters'); + } + + // Validate dates if provided + if (startDate && endDate) { + const start = new Date(startDate); + const end = new Date(endDate); + + if (end <= start) { + throw new ApiError(400, 'End date must be after start date'); + } + } + + const event = await eventQueries.createEvent(name, description, startDate, endDate); + + res.status(201).json({ + success: true, + message: 'Event created successfully', + data: event + }); +}; + +/** + * Get all events + * GET /api/admin/events + */ +const getAllEvents = async (req, res) => { + const events = await eventQueries.getAllEvents(); + + res.json({ + success: true, + data: events + }); +}; + +/** + * Get event by ID + * GET /api/admin/events/:eventId + */ +const getEventById = async (req, res) => { + const { eventId } = req.params; + + const event = await eventQueries.getEventById(eventId); + + if (!event) { + throw new ApiError(404, 'Event not found'); + } + + res.json({ + success: true, + data: event + }); +}; + +/** + * Update event + * PUT /api/admin/events/:eventId + */ +const updateEvent = async (req, res) => { + const { eventId } = req.params; + const { name, description, startDate, endDate, isActive } = req.body; + + // Check if event exists + const exists = await eventQueries.eventExists(eventId); + if (!exists) { + throw new ApiError(404, 'Event not found'); + } + + // Validate name if provided + if (name !== undefined) { + if (!name || name.length < 3 || name.length > 255) { + throw new ApiError(400, 'Event name must be between 3 and 255 characters'); + } + } + + // Validate dates if both provided + if (startDate && endDate) { + const start = new Date(startDate); + const end = new Date(endDate); + + if (end <= start) { + throw new ApiError(400, 'End date must be after start date'); + } + } + + const updates = {}; + if (name !== undefined) updates.name = name; + if (description !== undefined) updates.description = description; + if (startDate !== undefined) updates.start_date = startDate; + if (endDate !== undefined) updates.end_date = endDate; + if (isActive !== undefined) updates.is_active = isActive; + + const updatedEvent = await eventQueries.updateEvent(eventId, updates); + + res.json({ + success: true, + message: 'Event updated successfully', + data: updatedEvent + }); +}; + +/** + * Delete event + * DELETE /api/admin/events/:eventId + */ +const deleteEvent = async (req, res) => { + const { eventId } = req.params; + + // Check if event exists + const exists = await eventQueries.eventExists(eventId); + if (!exists) { + throw new ApiError(404, 'Event not found'); + } + + await eventQueries.deleteEvent(eventId); + + res.json({ + success: true, + message: 'Event deleted successfully' + }); +}; + +/** + * Get event participants + * GET /api/admin/events/:eventId/participants + */ +const getEventParticipants = async (req, res) => { + const { eventId } = req.params; + + // Check if event exists + const exists = await eventQueries.eventExists(eventId); + if (!exists) { + throw new ApiError(404, 'Event not found'); + } + + const participants = await participantQueries.getParticipantsByEvent(eventId); + + res.json({ + success: true, + data: participants + }); +}; + +/** + * Get event statistics + * GET /api/admin/events/:eventId/analytics + */ +const getEventAnalytics = async (req, res) => { + const { eventId } = req.params; + + // Check if event exists + const exists = await eventQueries.eventExists(eventId); + if (!exists) { + throw new ApiError(404, 'Event not found'); + } + + const statistics = await eventQueries.getEventStatistics(eventId); + + res.json({ + success: true, + data: statistics + }); +}; + +module.exports = { + createEvent, + getAllEvents, + getEventById, + updateEvent, + deleteEvent, + getEventParticipants, + getEventAnalytics +}; diff --git a/backend/src/controllers/lesson.controller.js b/backend/src/controllers/lesson.controller.js new file mode 100644 index 0000000..289c347 --- /dev/null +++ b/backend/src/controllers/lesson.controller.js @@ -0,0 +1,287 @@ +const { ApiError } = require('../middleware/errorHandler'); +const lessonQueries = require('../models/queries/lesson.queries'); +const progressQueries = require('../models/queries/progress.queries'); +const lessonLoader = require('../services/lessonLoader.service'); +const scoringService = require('../services/scoring.service'); + +/** + * Get lessons for an event (participant view) + * GET /api/participant/event/:eventId/lessons + */ +const getEventLessons = async (req, res) => { + const { eventId } = req.params; + const participantId = req.participant.id; + + const lessons = await lessonQueries.getEventLessonsWithProgress(eventId, participantId); + + res.json({ + success: true, + data: lessons.map(lesson => ({ + eventLessonId: lesson.event_lesson_id, + lessonId: lesson.id, + lessonKey: lesson.lesson_key, + title: lesson.title, + description: lesson.description, + difficultyLevel: lesson.difficulty_level, + estimatedDuration: lesson.estimated_duration, + orderIndex: lesson.order_index, + maxPoints: lesson.max_points, + weight: lesson.weight, + isRequired: lesson.is_required, + isUnlocked: lesson.is_unlocked, + progress: lesson.progress_id ? { + status: lesson.status, + score: lesson.score, + attempts: lesson.attempts, + startedAt: lesson.started_at, + completedAt: lesson.completed_at + } : null + })) + }); +}; + +/** + * Get lesson content + * GET /api/participant/lesson/:eventLessonId + */ +const getLessonContent = async (req, res) => { + const { eventLessonId } = req.params; + const participantId = req.participant.id; + + // Get event lesson details + const eventLesson = await lessonQueries.getEventLessonById(eventLessonId); + + if (!eventLesson) { + throw new ApiError(404, 'Lesson not found'); + } + + // Check if lesson is unlocked + const isUnlocked = await progressQueries.isLessonUnlocked(participantId, eventLessonId); + + if (!isUnlocked) { + throw new ApiError(403, 'This lesson is locked. Complete previous lessons first.'); + } + + // Load lesson content from module + const content = await lessonLoader.getLessonContent(eventLesson.lesson_key); + + // Get progress if exists + const progress = await progressQueries.getLessonProgress(participantId, eventLessonId); + + res.json({ + success: true, + data: { + eventLessonId, + ...content, + maxPoints: eventLesson.max_points, + weight: eventLesson.weight, + progress: progress ? { + id: progress.id, + status: progress.status, + score: progress.score, + currentStep: progress.current_step, + attempts: progress.attempts + } : null + } + }); +}; + +/** + * Start a lesson + * POST /api/participant/lesson/:eventLessonId/start + */ +const startLesson = async (req, res) => { + const { eventLessonId } = req.params; + const participantId = req.participant.id; + + // Check if lesson is unlocked + const isUnlocked = await progressQueries.isLessonUnlocked(participantId, eventLessonId); + + if (!isUnlocked) { + throw new ApiError(403, 'This lesson is locked'); + } + + // Start or resume progress + const progress = await progressQueries.startLesson(participantId, eventLessonId); + + res.json({ + success: true, + message: 'Lesson started', + data: { + progressId: progress.id, + status: progress.status, + startedAt: progress.started_at + } + }); +}; + +/** + * Submit an answer + * POST /api/participant/lesson/:eventLessonId/answer + */ +const submitAnswer = async (req, res) => { + const { eventLessonId } = req.params; + const { questionId, answer } = req.body; + const participantId = req.participant.id; + + if (!questionId || answer === undefined) { + throw new ApiError(400, 'Question ID and answer are required'); + } + + // Get event lesson + const eventLesson = await lessonQueries.getEventLessonById(eventLessonId); + + if (!eventLesson) { + throw new ApiError(404, 'Lesson not found'); + } + + // Get or create progress + let progress = await progressQueries.getLessonProgress(participantId, eventLessonId); + + if (!progress) { + // Auto-start lesson if not started + progress = await progressQueries.startLesson(participantId, eventLessonId); + } + + // Validate answer using lesson module + const validation = await lessonLoader.validateAnswer( + eventLesson.lesson_key, + questionId, + answer + ); + + // Save answer to database + await progressQueries.saveAnswer( + progress.id, + questionId, + answer, + validation.isCorrect, + validation.pointsAwarded, + validation.feedback + ); + + // Update score + const newScore = await progressQueries.updateScore(progress.id, validation.pointsAwarded); + + res.json({ + success: true, + data: { + isCorrect: validation.isCorrect, + isPartial: validation.isPartial || false, + pointsAwarded: validation.pointsAwarded, + feedback: validation.feedback, + totalScore: newScore + } + }); +}; + +/** + * Complete a lesson + * POST /api/participant/lesson/:eventLessonId/complete + */ +const completeLesson = async (req, res) => { + const { eventLessonId } = req.params; + const participantId = req.participant.id; + + // Get progress + const progress = await progressQueries.getLessonProgress(participantId, eventLessonId); + + if (!progress) { + throw new ApiError(404, 'Lesson progress not found. Start the lesson first.'); + } + + if (progress.status === 'completed') { + throw new ApiError(400, 'Lesson already completed'); + } + + // Mark as completed + const updated = await progressQueries.completeLesson(progress.id); + + // Calculate lesson score details + const scoreDetails = await scoringService.calculateLessonScore(progress.id); + + // Check if passed + const passed = await scoringService.checkLessonPassed(progress.id); + + res.json({ + success: true, + message: 'Lesson completed', + data: { + completedAt: updated.completed_at, + finalScore: updated.score, + maxPoints: scoreDetails.maxPoints, + percentage: scoreDetails.percentage, + passed + } + }); +}; + +/** + * Get interactive component data + * GET /api/lessons/:lessonKey/interactive/:stepId + */ +const getInteractiveData = async (req, res) => { + const { lessonKey, stepId } = req.params; + + const data = await lessonLoader.getInteractiveData(lessonKey, stepId); + + res.json({ + success: true, + data + }); +}; + +/** + * Execute lesson-specific action (e.g., SQL query) + * POST /api/lesson/:eventLessonId/action/:action + */ +const executeLessonAction = async (req, res) => { + const { eventLessonId, action } = req.params; + const participantId = req.participant.id; + const actionData = req.body; + + // Get progress to ensure lesson is started + const progress = await progressQueries.getLessonProgress(participantId, eventLessonId); + + if (!progress) { + throw new ApiError(404, 'Lesson not started. Start the lesson first.'); + } + + // Get event lesson details to find lesson key + const eventLesson = await lessonQueries.getEventLessonById(eventLessonId); + const lessonKey = eventLesson.lesson_key; + + // Load lesson module + const lessonModule = await lessonLoader.loadLesson(lessonKey); + + // Execute action based on type + let result; + + if (action === 'execute-query' && lessonModule.executeVulnerableQuery) { + // SQL Injection demo + const { searchTerm, mode } = actionData; + + if (mode === 'safe' && lessonModule.executeSafeQuery) { + result = lessonModule.executeSafeQuery(searchTerm); + } else { + result = lessonModule.executeVulnerableQuery(searchTerm); + } + } else { + throw new ApiError(400, `Unsupported action: ${action}`); + } + + res.json({ + success: true, + data: result + }); +}; + +module.exports = { + getEventLessons, + getLessonContent, + startLesson, + submitAnswer, + completeLesson, + getInteractiveData, + executeLessonAction +}; diff --git a/backend/src/controllers/participant.controller.js b/backend/src/controllers/participant.controller.js new file mode 100644 index 0000000..547e39f --- /dev/null +++ b/backend/src/controllers/participant.controller.js @@ -0,0 +1,129 @@ +const { ApiError } = require('../middleware/errorHandler'); +const { generateSessionToken } = require('../middleware/auth'); +const participantQueries = require('../models/queries/participant.queries'); +const eventQueries = require('../models/queries/event.queries'); + +/** + * Join an event with a pseudonym + * POST /api/participant/join + */ +const joinEvent = async (req, res) => { + const { pseudonym, eventId } = req.body; + + // Validate input + if (!pseudonym || !eventId) { + throw new ApiError(400, 'Pseudonym and eventId are required'); + } + + // Validate pseudonym format + if (pseudonym.length < 3 || pseudonym.length > 50) { + throw new ApiError(400, 'Pseudonym must be between 3 and 50 characters'); + } + + // Check if event exists and is active + const event = await eventQueries.getEventById(eventId); + if (!event) { + throw new ApiError(404, 'Event not found'); + } + + if (!event.is_active) { + throw new ApiError(403, 'This event is no longer accepting participants'); + } + + // Check if pseudonym is already taken in this event + const exists = await participantQueries.pseudonymExists(pseudonym, eventId); + if (exists) { + throw new ApiError(409, 'Pseudonym already taken in this event. Please choose another.'); + } + + // Generate session token + const sessionToken = generateSessionToken(); + + // Create participant + const participant = await participantQueries.createParticipant( + pseudonym, + eventId, + sessionToken + ); + + res.status(201).json({ + success: true, + message: 'Successfully joined event', + data: { + participant: { + id: participant.id, + pseudonym: participant.pseudonym, + eventId: participant.event_id + }, + sessionToken, + event: { + id: event.id, + name: event.name, + description: event.description + } + } + }); +}; + +/** + * Get list of active events + * GET /api/participant/events + */ +const getActiveEvents = async (req, res) => { + const events = await eventQueries.getActiveEvents(); + + res.json({ + success: true, + data: events + }); +}; + +/** + * Get participant's own progress + * GET /api/participant/progress + */ +const getProgress = async (req, res) => { + const participantId = req.participant.id; + + const progress = await participantQueries.getParticipantProgress(participantId); + + if (!progress) { + throw new ApiError(404, 'Participant not found'); + } + + res.json({ + success: true, + data: progress + }); +}; + +/** + * Get participant profile + * GET /api/participant/profile + */ +const getProfile = async (req, res) => { + const participant = await participantQueries.getParticipantById(req.participant.id); + + if (!participant) { + throw new ApiError(404, 'Participant not found'); + } + + res.json({ + success: true, + data: { + id: participant.id, + pseudonym: participant.pseudonym, + eventId: participant.event_id, + eventName: participant.event_name, + createdAt: participant.created_at, + lastActive: participant.last_active + } + }); +}; + +module.exports = { + joinEvent, + getActiveEvents, + getProgress, + getProfile +}; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..4d0ebc8 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,106 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const morgan = require('morgan'); +const config = require('./config/environment'); +const { pool } = require('./config/database'); +const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); + +// Import routes +const participantRoutes = require('./routes/participant.routes'); +const adminRoutes = require('./routes/admin.routes'); +const lessonRoutes = require('./routes/lesson.routes'); + +// Initialize Express app +const app = express(); + +// Security middleware +app.use(helmet()); + +// CORS configuration +app.use(cors({ + origin: config.corsOrigin, + credentials: true +})); + +// Body parsing middleware +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Logging middleware +if (config.nodeEnv === 'development') { + app.use(morgan('dev')); +} else { + app.use(morgan('combined')); +} + +// Health check endpoint +app.get('/health', async (req, res) => { + try { + // Check database connection + await pool.query('SELECT 1'); + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + environment: config.nodeEnv, + database: 'connected' + }); + } catch (error) { + res.status(503).json({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + environment: config.nodeEnv, + database: 'disconnected', + error: error.message + }); + } +}); + +// API routes +app.get('/api', (req, res) => { + res.json({ + message: 'Security Awareness Learning Platform API', + version: '1.0.0', + endpoints: { + participant: '/api/participant', + admin: '/api/admin', + lessons: '/api/lessons' + } + }); +}); + +// Mount routes +app.use('/api/participant', participantRoutes); +app.use('/api/admin', adminRoutes); +app.use('/api/lesson', lessonRoutes); + +// 404 handler +app.use(notFoundHandler); + +// Error handling middleware +app.use(errorHandler); + +// Start server +const PORT = config.port; +app.listen(PORT, () => { + console.log(` + ======================================== + Security Awareness Learning Platform + ======================================== + Environment: ${config.nodeEnv} + Server running on port: ${PORT} + Database: ${config.database.host}:${config.database.port}/${config.database.name} + ======================================== + `); +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('SIGTERM signal received: closing HTTP server'); + pool.end(() => { + console.log('Database pool closed'); + process.exit(0); + }); +}); + +module.exports = app; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..2ec9149 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,168 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcrypt'); +const config = require('../config/environment'); +const { ApiError } = require('./errorHandler'); +const db = require('../config/database'); + +/** + * Generate JWT token for admin + */ +const generateAdminToken = (adminId, username) => { + return jwt.sign( + { id: adminId, username, role: 'admin' }, + config.jwtSecret, + { expiresIn: '24h' } + ); +}; + +/** + * Generate session token for participant + */ +const generateSessionToken = () => { + const { v4: uuidv4 } = require('uuid'); + return uuidv4(); +}; + +/** + * Hash password with bcrypt + */ +const hashPassword = async (password) => { + return await bcrypt.hash(password, 10); +}; + +/** + * Verify password with bcrypt + */ +const verifyPassword = async (password, hashedPassword) => { + return await bcrypt.compare(password, hashedPassword); +}; + +/** + * Middleware to verify admin JWT token + */ +const verifyAdminToken = async (req, res, next) => { + try { + // Get token from Authorization header + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new ApiError(401, 'No token provided'); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + // Verify token + const decoded = jwt.verify(token, config.jwtSecret); + + if (decoded.role !== 'admin') { + throw new ApiError(403, 'Access denied. Admin privileges required.'); + } + + // Verify admin exists in database + const result = await db.query( + 'SELECT id, username FROM admin_users WHERE id = $1', + [decoded.id] + ); + + if (result.rows.length === 0) { + throw new ApiError(401, 'Invalid token'); + } + + // Attach admin info to request + req.admin = { + id: decoded.id, + username: decoded.username + }; + + next(); + } catch (error) { + if (error.name === 'JsonWebTokenError') { + next(new ApiError(401, 'Invalid token')); + } else if (error.name === 'TokenExpiredError') { + next(new ApiError(401, 'Token expired')); + } else { + next(error); + } + } +}; + +/** + * Middleware to verify participant session token + */ +const verifyParticipantToken = async (req, res, next) => { + try { + // Get token from Authorization header + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new ApiError(401, 'No session token provided'); + } + + const sessionToken = authHeader.substring(7); + + // Verify token exists in database and get participant info + const result = await db.query( + `SELECT p.id, p.pseudonym, p.event_id, e.name as event_name, e.is_active + FROM participants p + JOIN events e ON e.id = p.event_id + WHERE p.session_token = $1`, + [sessionToken] + ); + + if (result.rows.length === 0) { + throw new ApiError(401, 'Invalid session token'); + } + + const participant = result.rows[0]; + + // Check if event is still active + if (!participant.is_active) { + throw new ApiError(403, 'Event is no longer active'); + } + + // Update last active timestamp + await db.query( + 'UPDATE participants SET last_active = CURRENT_TIMESTAMP WHERE id = $1', + [participant.id] + ); + + // Attach participant info to request + req.participant = { + id: participant.id, + pseudonym: participant.pseudonym, + eventId: participant.event_id, + eventName: participant.event_name + }; + + next(); + } catch (error) { + next(error); + } +}; + +/** + * Optional participant authentication (doesn't fail if no token) + */ +const optionalParticipantAuth = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (authHeader && authHeader.startsWith('Bearer ')) { + await verifyParticipantToken(req, res, next); + } else { + next(); + } + } catch (error) { + next(); + } +}; + +module.exports = { + generateAdminToken, + generateSessionToken, + hashPassword, + verifyPassword, + verifyAdminToken, + verifyParticipantToken, + optionalParticipantAuth +}; diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..5153513 --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,73 @@ +const config = require('../config/environment'); + +/** + * Custom error class for API errors + */ +class ApiError extends Error { + constructor(statusCode, message, errors = null) { + super(message); + this.statusCode = statusCode; + this.errors = errors; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * Error handling middleware + */ +const errorHandler = (err, req, res, next) => { + let { statusCode = 500, message, errors } = err; + + // Log error + console.error('Error:', { + statusCode, + message, + path: req.path, + method: req.method, + ...(config.nodeEnv === 'development' && { stack: err.stack }) + }); + + // Don't leak error details in production + if (!err.isOperational && config.nodeEnv === 'production') { + message = 'Internal server error'; + } + + res.status(statusCode).json({ + success: false, + error: { + message, + ...(errors && { errors }), + ...(config.nodeEnv === 'development' && { stack: err.stack }) + } + }); +}; + +/** + * Async error wrapper to catch errors in async route handlers + */ +const asyncHandler = (fn) => { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +/** + * 404 handler + */ +const notFoundHandler = (req, res) => { + res.status(404).json({ + success: false, + error: { + message: 'Route not found', + path: req.path + } + }); +}; + +module.exports = { + ApiError, + errorHandler, + asyncHandler, + notFoundHandler +}; diff --git a/backend/src/models/queries/event.queries.js b/backend/src/models/queries/event.queries.js new file mode 100644 index 0000000..20bbd15 --- /dev/null +++ b/backend/src/models/queries/event.queries.js @@ -0,0 +1,167 @@ +const db = require('../../config/database'); + +/** + * Create a new event + */ +const createEvent = async (name, description, startDate, endDate) => { + const query = ` + INSERT INTO events (name, description, start_date, end_date, is_active) + VALUES ($1, $2, $3, $4, true) + RETURNING * + `; + + const result = await db.query(query, [name, description, startDate, endDate]); + return result.rows[0]; +}; + +/** + * Get event by ID + */ +const getEventById = async (eventId) => { + const query = ` + SELECT + e.*, + COUNT(DISTINCT p.id) as participant_count, + COUNT(DISTINCT el.id) as lesson_count + FROM events e + LEFT JOIN participants p ON p.event_id = e.id + LEFT JOIN event_lessons el ON el.event_id = e.id + WHERE e.id = $1 + GROUP BY e.id + `; + + const result = await db.query(query, [eventId]); + return result.rows[0] || null; +}; + +/** + * Get all events + */ +const getAllEvents = async () => { + const query = ` + SELECT + e.*, + COUNT(DISTINCT p.id) as participant_count, + COUNT(DISTINCT el.id) as lesson_count + FROM events e + LEFT JOIN participants p ON p.event_id = e.id + LEFT JOIN event_lessons el ON el.event_id = e.id + GROUP BY e.id + ORDER BY e.created_at DESC + `; + + const result = await db.query(query); + return result.rows; +}; + +/** + * Get active events (for participant view) + */ +const getActiveEvents = async () => { + const query = ` + SELECT + e.id, + e.name, + e.description, + e.start_date, + e.end_date, + COUNT(DISTINCT el.id) as lesson_count + FROM events e + LEFT JOIN event_lessons el ON el.event_id = e.id + WHERE e.is_active = true + GROUP BY e.id + ORDER BY e.start_date DESC NULLS LAST, e.created_at DESC + `; + + const result = await db.query(query); + return result.rows; +}; + +/** + * Update event + */ +const updateEvent = async (eventId, updates) => { + const allowedFields = ['name', 'description', 'start_date', 'end_date', 'is_active']; + const fields = []; + const values = []; + let paramIndex = 1; + + Object.keys(updates).forEach(key => { + if (allowedFields.includes(key) && updates[key] !== undefined) { + fields.push(`${key} = $${paramIndex}`); + values.push(updates[key]); + paramIndex++; + } + }); + + if (fields.length === 0) { + return null; + } + + values.push(eventId); + const query = ` + UPDATE events + SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP + WHERE id = $${paramIndex} + RETURNING * + `; + + const result = await db.query(query, values); + return result.rows[0] || null; +}; + +/** + * Delete event (cascades to participants and progress) + */ +const deleteEvent = async (eventId) => { + const query = 'DELETE FROM events WHERE id = $1 RETURNING id'; + const result = await db.query(query, [eventId]); + return result.rows.length > 0; +}; + +/** + * Check if event exists + */ +const eventExists = async (eventId) => { + const query = 'SELECT id FROM events WHERE id = $1'; + const result = await db.query(query, [eventId]); + return result.rows.length > 0; +}; + +/** + * Get event statistics + */ +const getEventStatistics = async (eventId) => { + const query = ` + SELECT + e.id, + e.name, + COUNT(DISTINCT p.id) as total_participants, + COUNT(DISTINCT el.id) as total_lessons, + COUNT(DISTINCT lp.id) as total_lesson_starts, + COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as total_completions, + ROUND(AVG(lp.score), 2) as average_score, + MAX(lp.score) as highest_score, + MIN(lp.score) as lowest_score + FROM events e + LEFT JOIN participants p ON p.event_id = e.id + LEFT JOIN event_lessons el ON el.event_id = e.id + LEFT JOIN lesson_progress lp ON lp.participant_id = p.id + WHERE e.id = $1 + GROUP BY e.id, e.name + `; + + const result = await db.query(query, [eventId]); + return result.rows[0] || null; +}; + +module.exports = { + createEvent, + getEventById, + getAllEvents, + getActiveEvents, + updateEvent, + deleteEvent, + eventExists, + getEventStatistics +}; diff --git a/backend/src/models/queries/lesson.queries.js b/backend/src/models/queries/lesson.queries.js new file mode 100644 index 0000000..098d891 --- /dev/null +++ b/backend/src/models/queries/lesson.queries.js @@ -0,0 +1,229 @@ +const db = require('../../config/database'); + +/** + * Create a new lesson in the catalog + */ +const createLesson = async (lessonKey, title, description, modulePath, configPath, difficultyLevel, estimatedDuration) => { + const query = ` + INSERT INTO lessons (lesson_key, title, description, module_path, config_path, difficulty_level, estimated_duration) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + + const result = await db.query(query, [ + lessonKey, + title, + description, + modulePath, + configPath, + difficultyLevel, + estimatedDuration + ]); + + return result.rows[0]; +}; + +/** + * Get lesson by ID + */ +const getLessonById = async (lessonId) => { + const query = 'SELECT * FROM lessons WHERE id = $1'; + const result = await db.query(query, [lessonId]); + return result.rows[0] || null; +}; + +/** + * Get lesson by key + */ +const getLessonByKey = async (lessonKey) => { + const query = 'SELECT * FROM lessons WHERE lesson_key = $1'; + const result = await db.query(query, [lessonKey]); + return result.rows[0] || null; +}; + +/** + * Get all lessons + */ +const getAllLessons = async () => { + const query = ` + SELECT * FROM lessons + ORDER BY title ASC + `; + const result = await db.query(query); + return result.rows; +}; + +/** + * Assign lesson to event + */ +const assignLessonToEvent = async (eventId, lessonId, orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId) => { + const query = ` + INSERT INTO event_lessons (event_id, lesson_id, order_index, max_points, weight, is_required, unlock_after_lesson_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + + const result = await db.query(query, [ + eventId, + lessonId, + orderIndex, + maxPoints || 100, + weight || 1.0, + isRequired !== undefined ? isRequired : true, + unlockAfterLessonId || null + ]); + + return result.rows[0]; +}; + +/** + * Get lessons for an event + */ +const getEventLessons = async (eventId) => { + const query = ` + SELECT + el.id as event_lesson_id, + el.order_index, + el.max_points, + el.weight, + el.is_required, + el.unlock_after_lesson_id, + l.* + FROM event_lessons el + JOIN lessons l ON l.id = el.lesson_id + WHERE el.event_id = $1 + ORDER BY el.order_index ASC + `; + + const result = await db.query(query, [eventId]); + return result.rows; +}; + +/** + * Get lessons for an event with participant progress + */ +const getEventLessonsWithProgress = async (eventId, participantId) => { + const query = ` + SELECT + el.id as event_lesson_id, + el.order_index, + el.max_points, + el.weight, + el.is_required, + el.unlock_after_lesson_id, + l.*, + lp.id as progress_id, + lp.status, + lp.score, + lp.attempts, + lp.started_at, + lp.completed_at, + CASE + WHEN el.unlock_after_lesson_id IS NULL THEN true + WHEN EXISTS ( + SELECT 1 FROM lesson_progress lp2 + JOIN event_lessons el2 ON el2.id = lp2.event_lesson_id + WHERE lp2.participant_id = $2 + AND el2.lesson_id = el.unlock_after_lesson_id + AND lp2.status = 'completed' + ) THEN true + ELSE false + END as is_unlocked + FROM event_lessons el + JOIN lessons l ON l.id = el.lesson_id + LEFT JOIN lesson_progress lp ON lp.event_lesson_id = el.id AND lp.participant_id = $2 + WHERE el.event_id = $1 + ORDER BY el.order_index ASC + `; + + const result = await db.query(query, [eventId, participantId]); + return result.rows; +}; + +/** + * Get event lesson by ID + */ +const getEventLessonById = async (eventLessonId) => { + const query = ` + SELECT + el.*, + l.lesson_key, + l.title, + l.description, + l.module_path, + l.config_path, + l.difficulty_level, + l.estimated_duration + FROM event_lessons el + JOIN lessons l ON l.id = el.lesson_id + WHERE el.id = $1 + `; + + const result = await db.query(query, [eventLessonId]); + return result.rows[0] || null; +}; + +/** + * Update event lesson configuration + */ +const updateEventLesson = async (eventLessonId, updates) => { + const allowedFields = ['order_index', 'max_points', 'weight', 'is_required', 'unlock_after_lesson_id']; + const fields = []; + const values = []; + let paramIndex = 1; + + Object.keys(updates).forEach(key => { + if (allowedFields.includes(key) && updates[key] !== undefined) { + fields.push(`${key} = $${paramIndex}`); + values.push(updates[key]); + paramIndex++; + } + }); + + if (fields.length === 0) { + return null; + } + + values.push(eventLessonId); + const query = ` + UPDATE event_lessons + SET ${fields.join(', ')} + WHERE id = $${paramIndex} + RETURNING * + `; + + const result = await db.query(query, values); + return result.rows[0] || null; +}; + +/** + * Remove lesson from event + */ +const removeEventLesson = async (eventLessonId) => { + const query = 'DELETE FROM event_lessons WHERE id = $1 RETURNING id'; + const result = await db.query(query, [eventLessonId]); + return result.rows.length > 0; +}; + +/** + * Check if lesson exists + */ +const lessonExists = async (lessonId) => { + const query = 'SELECT id FROM lessons WHERE id = $1'; + const result = await db.query(query, [lessonId]); + return result.rows.length > 0; +}; + +module.exports = { + createLesson, + getLessonById, + getLessonByKey, + getAllLessons, + assignLessonToEvent, + getEventLessons, + getEventLessonsWithProgress, + getEventLessonById, + updateEventLesson, + removeEventLesson, + lessonExists +}; diff --git a/backend/src/models/queries/participant.queries.js b/backend/src/models/queries/participant.queries.js new file mode 100644 index 0000000..454228a --- /dev/null +++ b/backend/src/models/queries/participant.queries.js @@ -0,0 +1,142 @@ +const db = require('../../config/database'); + +/** + * Create a new participant + */ +const createParticipant = async (pseudonym, eventId, sessionToken) => { + const query = ` + INSERT INTO participants (pseudonym, event_id, session_token) + VALUES ($1, $2, $3) + RETURNING id, pseudonym, event_id, session_token, created_at + `; + + const result = await db.query(query, [pseudonym, eventId, sessionToken]); + return result.rows[0]; +}; + +/** + * Get participant by ID + */ +const getParticipantById = async (participantId) => { + const query = ` + SELECT p.*, e.name as event_name, e.is_active as event_active + FROM participants p + JOIN events e ON e.id = p.event_id + WHERE p.id = $1 + `; + + const result = await db.query(query, [participantId]); + return result.rows[0] || null; +}; + +/** + * Get participant by session token + */ +const getParticipantByToken = async (sessionToken) => { + const query = ` + SELECT p.*, e.name as event_name, e.is_active as event_active + FROM participants p + JOIN events e ON e.id = p.event_id + WHERE p.session_token = $1 + `; + + const result = await db.query(query, [sessionToken]); + return result.rows[0] || null; +}; + +/** + * Check if pseudonym exists in event + */ +const pseudonymExists = async (pseudonym, eventId) => { + const query = ` + SELECT id FROM participants + WHERE pseudonym = $1 AND event_id = $2 + `; + + const result = await db.query(query, [pseudonym, eventId]); + return result.rows.length > 0; +}; + +/** + * Get all participants for an event + */ +const getParticipantsByEvent = async (eventId) => { + const query = ` + SELECT + p.id, + p.pseudonym, + p.created_at, + p.last_active, + COUNT(DISTINCT lp.id) as total_lessons_started, + COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as lessons_completed, + COALESCE(SUM(lp.score), 0) as total_score + FROM participants p + LEFT JOIN lesson_progress lp ON lp.participant_id = p.id + WHERE p.event_id = $1 + GROUP BY p.id, p.pseudonym, p.created_at, p.last_active + ORDER BY total_score DESC, p.pseudonym ASC + `; + + const result = await db.query(query, [eventId]); + return result.rows; +}; + +/** + * Get participant progress summary + */ +const getParticipantProgress = async (participantId) => { + const query = ` + SELECT + p.pseudonym, + p.event_id, + e.name as event_name, + COUNT(DISTINCT lp.id) as total_lessons_started, + COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as lessons_completed, + COALESCE(SUM(lp.score), 0) as total_score, + COUNT(DISTINCT el.id) as total_lessons_available + FROM participants p + JOIN events e ON e.id = p.event_id + LEFT JOIN event_lessons el ON el.event_id = p.event_id + LEFT JOIN lesson_progress lp ON lp.participant_id = p.id + WHERE p.id = $1 + GROUP BY p.id, p.pseudonym, p.event_id, e.name + `; + + const result = await db.query(query, [participantId]); + return result.rows[0] || null; +}; + +/** + * Delete participant + */ +const deleteParticipant = async (participantId) => { + const query = 'DELETE FROM participants WHERE id = $1 RETURNING id'; + const result = await db.query(query, [participantId]); + return result.rows.length > 0; +}; + +/** + * Update last active timestamp + */ +const updateLastActive = async (participantId) => { + const query = ` + UPDATE participants + SET last_active = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING last_active + `; + + const result = await db.query(query, [participantId]); + return result.rows[0]; +}; + +module.exports = { + createParticipant, + getParticipantById, + getParticipantByToken, + pseudonymExists, + getParticipantsByEvent, + getParticipantProgress, + deleteParticipant, + updateLastActive +}; diff --git a/backend/src/models/queries/progress.queries.js b/backend/src/models/queries/progress.queries.js new file mode 100644 index 0000000..5e93f7a --- /dev/null +++ b/backend/src/models/queries/progress.queries.js @@ -0,0 +1,206 @@ +const db = require('../../config/database'); + +/** + * Start a lesson (create or update progress record) + */ +const startLesson = async (participantId, eventLessonId) => { + const query = ` + INSERT INTO lesson_progress (participant_id, event_lesson_id, status, started_at, current_step) + VALUES ($1, $2, 'in_progress', CURRENT_TIMESTAMP, 0) + ON CONFLICT (participant_id, event_lesson_id) + DO UPDATE SET + status = 'in_progress', + started_at = COALESCE(lesson_progress.started_at, CURRENT_TIMESTAMP), + updated_at = CURRENT_TIMESTAMP + RETURNING * + `; + + const result = await db.query(query, [participantId, eventLessonId]); + return result.rows[0]; +}; + +/** + * Update current step in lesson + */ +const updateStep = async (progressId, stepIndex) => { + const query = ` + UPDATE lesson_progress + SET current_step = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + RETURNING * + `; + + const result = await db.query(query, [stepIndex, progressId]); + return result.rows[0]; +}; + +/** + * Complete a lesson + */ +const completeLesson = async (progressId) => { + const query = ` + UPDATE lesson_progress + SET status = 'completed', completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING * + `; + + const result = await db.query(query, [progressId]); + return result.rows[0]; +}; + +/** + * Get progress for a specific lesson + */ +const getLessonProgress = async (participantId, eventLessonId) => { + const query = ` + SELECT + lp.*, + el.max_points, + el.weight, + l.title as lesson_title, + l.lesson_key + FROM lesson_progress lp + JOIN event_lessons el ON el.id = lp.event_lesson_id + JOIN lessons l ON l.id = el.lesson_id + WHERE lp.participant_id = $1 AND lp.event_lesson_id = $2 + `; + + const result = await db.query(query, [participantId, eventLessonId]); + return result.rows[0] || null; +}; + +/** + * Get progress by ID + */ +const getProgressById = async (progressId) => { + const query = ` + SELECT + lp.*, + el.max_points, + el.weight, + l.lesson_key + FROM lesson_progress lp + JOIN event_lessons el ON el.id = lp.event_lesson_id + JOIN lessons l ON l.id = el.lesson_id + WHERE lp.id = $1 + `; + + const result = await db.query(query, [progressId]); + return result.rows[0] || null; +}; + +/** + * Get all progress for a participant + */ +const getParticipantProgress = async (participantId) => { + const query = ` + SELECT + lp.*, + el.max_points, + el.weight, + el.order_index, + l.lesson_key, + l.title, + l.description, + l.difficulty_level + FROM lesson_progress lp + JOIN event_lessons el ON el.id = lp.event_lesson_id + JOIN lessons l ON l.id = el.lesson_id + WHERE lp.participant_id = $1 + ORDER BY el.order_index + `; + + const result = await db.query(query, [participantId]); + return result.rows; +}; + +/** + * Save an answer + */ +const saveAnswer = async (progressId, questionKey, answerData, isCorrect, pointsAwarded, feedback) => { + const query = ` + INSERT INTO lesson_answers (lesson_progress_id, question_key, answer_data, is_correct, points_awarded, feedback) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + + const result = await db.query(query, [ + progressId, + questionKey, + JSON.stringify(answerData), + isCorrect, + pointsAwarded, + feedback + ]); + + return result.rows[0]; +}; + +/** + * Update score for lesson progress + */ +const updateScore = async (progressId, pointsToAdd) => { + const query = ` + UPDATE lesson_progress + SET score = score + $1, attempts = attempts + 1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + RETURNING score + `; + + const result = await db.query(query, [pointsToAdd, progressId]); + return result.rows[0]?.score || 0; +}; + +/** + * Get answers for a lesson progress + */ +const getAnswers = async (progressId) => { + const query = ` + SELECT * FROM lesson_answers + WHERE lesson_progress_id = $1 + ORDER BY submitted_at ASC + `; + + const result = await db.query(query, [progressId]); + return result.rows; +}; + +/** + * Check if lesson is unlocked for participant + */ +const isLessonUnlocked = async (participantId, eventLessonId) => { + const query = ` + SELECT + el.unlock_after_lesson_id, + CASE + WHEN el.unlock_after_lesson_id IS NULL THEN true + WHEN EXISTS ( + SELECT 1 FROM lesson_progress lp2 + JOIN event_lessons el2 ON el2.id = lp2.event_lesson_id + WHERE lp2.participant_id = $1 + AND el2.lesson_id = el.unlock_after_lesson_id + AND lp2.status = 'completed' + ) THEN true + ELSE false + END as is_unlocked + FROM event_lessons el + WHERE el.id = $2 + `; + + const result = await db.query(query, [participantId, eventLessonId]); + return result.rows[0]?.is_unlocked || false; +}; + +module.exports = { + startLesson, + updateStep, + completeLesson, + getLessonProgress, + getProgressById, + getParticipantProgress, + saveAnswer, + updateScore, + getAnswers, + isLessonUnlocked +}; diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js new file mode 100644 index 0000000..dd40505 --- /dev/null +++ b/backend/src/routes/admin.routes.js @@ -0,0 +1,32 @@ +const express = require('express'); +const router = express.Router(); +const { asyncHandler } = require('../middleware/errorHandler'); +const { verifyAdminToken } = require('../middleware/auth'); +const adminController = require('../controllers/admin.controller'); +const eventController = require('../controllers/event.controller'); +const adminLessonController = require('../controllers/adminLesson.controller'); + +// Admin authentication routes +router.post('/login', asyncHandler(adminController.login)); +router.get('/profile', verifyAdminToken, asyncHandler(adminController.getProfile)); +router.get('/verify', verifyAdminToken, asyncHandler(adminController.verifyToken)); + +// Event management routes (admin only) +router.get('/events', verifyAdminToken, asyncHandler(eventController.getAllEvents)); +router.post('/events', verifyAdminToken, asyncHandler(eventController.createEvent)); +router.get('/events/:eventId', verifyAdminToken, asyncHandler(eventController.getEventById)); +router.put('/events/:eventId', verifyAdminToken, asyncHandler(eventController.updateEvent)); +router.delete('/events/:eventId', verifyAdminToken, asyncHandler(eventController.deleteEvent)); + +// Event participant management +router.get('/events/:eventId/participants', verifyAdminToken, asyncHandler(eventController.getEventParticipants)); +router.get('/events/:eventId/analytics', verifyAdminToken, asyncHandler(eventController.getEventAnalytics)); + +// Lesson management routes (admin only) +router.get('/lessons', verifyAdminToken, asyncHandler(adminLessonController.getAllLessons)); +router.post('/events/:eventId/lessons', verifyAdminToken, asyncHandler(adminLessonController.assignLessonToEvent)); +router.get('/events/:eventId/lessons', verifyAdminToken, asyncHandler(adminLessonController.getEventLessons)); +router.put('/events/:eventId/lessons/:eventLessonId', verifyAdminToken, asyncHandler(adminLessonController.updateEventLesson)); +router.delete('/events/:eventId/lessons/:eventLessonId', verifyAdminToken, asyncHandler(adminLessonController.removeEventLesson)); + +module.exports = router; diff --git a/backend/src/routes/lesson.routes.js b/backend/src/routes/lesson.routes.js new file mode 100644 index 0000000..c6f4266 --- /dev/null +++ b/backend/src/routes/lesson.routes.js @@ -0,0 +1,31 @@ +const express = require('express'); +const router = express.Router(); +const { asyncHandler } = require('../middleware/errorHandler'); +const { verifyParticipantToken } = require('../middleware/auth'); +const lessonController = require('../controllers/lesson.controller'); + +// All lesson routes require participant authentication +router.use(verifyParticipantToken); + +// Get lessons for an event +router.get('/event/:eventId/lessons', asyncHandler(lessonController.getEventLessons)); + +// Get lesson content +router.get('/:eventLessonId', asyncHandler(lessonController.getLessonContent)); + +// Start a lesson +router.post('/:eventLessonId/start', asyncHandler(lessonController.startLesson)); + +// Submit an answer +router.post('/:eventLessonId/answer', asyncHandler(lessonController.submitAnswer)); + +// Complete a lesson +router.post('/:eventLessonId/complete', asyncHandler(lessonController.completeLesson)); + +// Execute lesson-specific action +router.post('/:eventLessonId/action/:action', asyncHandler(lessonController.executeLessonAction)); + +// Get interactive component data +router.get('/:lessonKey/interactive/:stepId', asyncHandler(lessonController.getInteractiveData)); + +module.exports = router; diff --git a/backend/src/routes/participant.routes.js b/backend/src/routes/participant.routes.js new file mode 100644 index 0000000..98278f1 --- /dev/null +++ b/backend/src/routes/participant.routes.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const { asyncHandler } = require('../middleware/errorHandler'); +const { verifyParticipantToken } = require('../middleware/auth'); +const participantController = require('../controllers/participant.controller'); + +// Public routes (no authentication required) +router.post('/join', asyncHandler(participantController.joinEvent)); +router.get('/events', asyncHandler(participantController.getActiveEvents)); + +// Protected routes (require participant session token) +router.get('/profile', verifyParticipantToken, asyncHandler(participantController.getProfile)); +router.get('/progress', verifyParticipantToken, asyncHandler(participantController.getProgress)); + +module.exports = router; diff --git a/backend/src/services/lessonLoader.service.js b/backend/src/services/lessonLoader.service.js new file mode 100644 index 0000000..83bee9d --- /dev/null +++ b/backend/src/services/lessonLoader.service.js @@ -0,0 +1,131 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const config = require('../config/environment'); + +// Cache for loaded lessons +const lessonCache = new Map(); + +/** + * Load a lesson module and its configuration + */ +const loadLesson = async (lessonKey) => { + // Check cache first + if (lessonCache.has(lessonKey)) { + return lessonCache.get(lessonKey); + } + + try { + // Load YAML configuration + const configPath = path.join( + process.cwd(), + config.lessonsPath, + 'configs', + `${lessonKey}.yaml` + ); + + if (!fs.existsSync(configPath)) { + throw new Error(`Lesson configuration not found: ${lessonKey}.yaml`); + } + + const configContent = fs.readFileSync(configPath, 'utf8'); + const lessonConfig = yaml.load(configContent); + + // Validate config has required fields + if (!lessonConfig.lessonKey || !lessonConfig.title || !lessonConfig.module) { + throw new Error(`Invalid lesson configuration for ${lessonKey}`); + } + + // Load JavaScript module + const modulePath = path.join( + process.cwd(), + config.lessonsPath, + 'modules', + lessonConfig.module, + 'index.js' + ); + + if (!fs.existsSync(modulePath)) { + throw new Error(`Lesson module not found: ${lessonConfig.module}/index.js`); + } + + // Require the module + const LessonClass = require(modulePath); + + // Instantiate the lesson module with config + const lessonInstance = new LessonClass(lessonConfig); + + // Cache the instance + lessonCache.set(lessonKey, lessonInstance); + + return lessonInstance; + } catch (error) { + console.error(`Error loading lesson ${lessonKey}:`, error); + throw new Error(`Failed to load lesson: ${error.message}`); + } +}; + +/** + * Get lesson content (for rendering to participant) + */ +const getLessonContent = async (lessonKey) => { + const lesson = await loadLesson(lessonKey); + return lesson.getContent(); +}; + +/** + * Validate an answer using the lesson module + */ +const validateAnswer = async (lessonKey, questionId, answer) => { + const lesson = await loadLesson(lessonKey); + return await lesson.validateAnswer(questionId, answer); +}; + +/** + * Get interactive component data + */ +const getInteractiveData = async (lessonKey, stepId) => { + const lesson = await loadLesson(lessonKey); + return await lesson.getInteractiveData(stepId); +}; + +/** + * Clear lesson cache (useful for development/testing) + */ +const clearCache = () => { + lessonCache.clear(); +}; + +/** + * Reload a specific lesson from disk + */ +const reloadLesson = async (lessonKey) => { + lessonCache.delete(lessonKey); + return await loadLesson(lessonKey); +}; + +/** + * List all available lesson configurations + */ +const listAvailableLessons = () => { + const configsPath = path.join(process.cwd(), config.lessonsPath, 'configs'); + + if (!fs.existsSync(configsPath)) { + return []; + } + + const files = fs.readdirSync(configsPath); + return files + .filter(file => file.endsWith('.yaml') || file.endsWith('.yml')) + .map(file => file.replace(/\.(yaml|yml)$/, '')); +}; + +module.exports = { + loadLesson, + getLessonContent, + validateAnswer, + getInteractiveData, + clearCache, + reloadLesson, + listAvailableLessons +}; diff --git a/backend/src/services/scoring.service.js b/backend/src/services/scoring.service.js new file mode 100644 index 0000000..a044a06 --- /dev/null +++ b/backend/src/services/scoring.service.js @@ -0,0 +1,155 @@ +const db = require('../config/database'); + +/** + * Calculate total score for a participant in an event + */ +const calculateTotalScore = async (participantId) => { + const query = ` + SELECT + SUM(lp.score) as total_score, + SUM(el.max_points * el.weight) as max_possible_score, + COUNT(DISTINCT el.id) as total_lessons, + COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN el.id END) as completed_lessons + FROM participants p + JOIN event_lessons el ON el.event_id = p.event_id + LEFT JOIN lesson_progress lp ON lp.participant_id = p.id AND lp.event_lesson_id = el.id + WHERE p.id = $1 + GROUP BY p.id + `; + + const result = await db.query(query, [participantId]); + + if (result.rows.length === 0) { + return { + totalScore: 0, + maxPossibleScore: 0, + percentage: 0, + completedLessons: 0, + totalLessons: 0 + }; + } + + const data = result.rows[0]; + return { + totalScore: parseInt(data.total_score) || 0, + maxPossibleScore: parseFloat(data.max_possible_score) || 0, + percentage: data.max_possible_score > 0 + ? ((data.total_score / data.max_possible_score) * 100).toFixed(2) + : 0, + completedLessons: parseInt(data.completed_lessons) || 0, + totalLessons: parseInt(data.total_lessons) || 0 + }; +}; + +/** + * Calculate weighted score for a specific lesson + */ +const calculateLessonScore = async (progressId) => { + const query = ` + SELECT + lp.score, + el.max_points, + el.weight + FROM lesson_progress lp + JOIN event_lessons el ON el.id = lp.event_lesson_id + WHERE lp.id = $1 + `; + + const result = await db.query(query, [progressId]); + + if (result.rows.length === 0) { + return { score: 0, weightedScore: 0, percentage: 0 }; + } + + const data = result.rows[0]; + const percentage = (data.score / data.max_points) * 100; + const weightedScore = (data.score / data.max_points) * data.max_points * data.weight; + + return { + score: data.score, + maxPoints: data.max_points, + weight: data.weight, + percentage: percentage.toFixed(2), + weightedScore: weightedScore.toFixed(2) + }; +}; + +/** + * Get leaderboard for an event + */ +const getEventLeaderboard = async (eventId, limit = 10) => { + const query = ` + SELECT + p.pseudonym, + SUM(lp.score) as total_score, + COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as completed_lessons, + MAX(lp.updated_at) as last_activity + FROM participants p + LEFT JOIN lesson_progress lp ON lp.participant_id = p.id + WHERE p.event_id = $1 + GROUP BY p.id, p.pseudonym + ORDER BY total_score DESC, completed_lessons DESC, last_activity DESC + LIMIT $2 + `; + + const result = await db.query(query, [eventId, limit]); + + return result.rows.map((row, index) => ({ + rank: index + 1, + pseudonym: row.pseudonym, + totalScore: parseInt(row.total_score) || 0, + completedLessons: parseInt(row.completed_lessons) || 0, + lastActivity: row.last_activity + })); +}; + +/** + * Award points for a correct answer + */ +const awardPoints = async (progressId, points) => { + const query = ` + UPDATE lesson_progress + SET score = score + $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + RETURNING score + `; + + const result = await db.query(query, [points, progressId]); + return result.rows[0]?.score || 0; +}; + +/** + * Check if participant passed the lesson + */ +const checkLessonPassed = async (progressId) => { + const query = ` + SELECT + lp.score, + el.max_points, + l.lesson_key + FROM lesson_progress lp + JOIN event_lessons el ON el.id = lp.event_lesson_id + JOIN lessons l ON l.id = el.lesson_id + WHERE lp.id = $1 + `; + + const result = await db.query(query, [progressId]); + + if (result.rows.length === 0) { + return false; + } + + const data = result.rows[0]; + const percentage = (data.score / data.max_points) * 100; + + // Default passing threshold is 70% + return percentage >= 70; +}; + +module.exports = { + calculateTotalScore, + calculateLessonScore, + getEventLeaderboard, + awardPoints, + checkLessonPassed +}; diff --git a/backend/src/utils/seedLessons.js b/backend/src/utils/seedLessons.js new file mode 100644 index 0000000..d41e927 --- /dev/null +++ b/backend/src/utils/seedLessons.js @@ -0,0 +1,61 @@ +const db = require('../config/database'); +const lessonQueries = require('../models/queries/lesson.queries'); + +/** + * Seed lessons into the database + */ +const seedLessons = async () => { + const lessons = [ + { + lessonKey: 'phishing-email-basics', + title: 'Phishing Email Detection Basics', + description: 'Learn to identify common phishing tactics in emails and protect yourself from email-based attacks', + modulePath: 'phishing-email-basics', + configPath: 'phishing-email-basics.yaml', + difficultyLevel: 'beginner', + estimatedDuration: 15 + } + ]; + + for (const lesson of lessons) { + try { + // Check if lesson already exists + const existing = await lessonQueries.getLessonByKey(lesson.lessonKey); + + if (existing) { + console.log(`Lesson "${lesson.lessonKey}" already exists, skipping...`); + continue; + } + + // Create lesson + await lessonQueries.createLesson( + lesson.lessonKey, + lesson.title, + lesson.description, + lesson.modulePath, + lesson.configPath, + lesson.difficultyLevel, + lesson.estimatedDuration + ); + + console.log(`✓ Created lesson: ${lesson.title}`); + } catch (error) { + console.error(`✗ Error creating lesson "${lesson.lessonKey}":`, error.message); + } + } +}; + +// Run if called directly +if (require.main === module) { + seedLessons() + .then(() => { + console.log('\n✓ Lesson seeding complete'); + process.exit(0); + }) + .catch(error => { + console.error('\n✗ Lesson seeding failed:', error); + process.exit(1); + }); +} + +module.exports = { seedLessons }; diff --git a/database/init/01-schema.sql b/database/init/01-schema.sql new file mode 100644 index 0000000..561d9cb --- /dev/null +++ b/database/init/01-schema.sql @@ -0,0 +1,138 @@ +-- Security Awareness Learning Platform Database Schema +-- PostgreSQL 15+ + +-- Events table: Represents training events/sessions +CREATE TABLE events ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + start_date TIMESTAMP, + end_date TIMESTAMP, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Participants table: Anonymous participants with pseudonyms +CREATE TABLE participants ( + id SERIAL PRIMARY KEY, + pseudonym VARCHAR(100) NOT NULL, + event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE, + session_token VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(pseudonym, event_id) +); + +-- Lessons table: Catalog of available lessons +CREATE TABLE lessons ( + id SERIAL PRIMARY KEY, + lesson_key VARCHAR(100) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + module_path VARCHAR(255) NOT NULL, + config_path VARCHAR(255) NOT NULL, + difficulty_level VARCHAR(50), + estimated_duration INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Event_Lessons table: Lessons assigned to events with configuration +CREATE TABLE event_lessons ( + id SERIAL PRIMARY KEY, + event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE, + lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + order_index INTEGER NOT NULL, + max_points INTEGER NOT NULL DEFAULT 100, + weight DECIMAL(5,2) DEFAULT 1.0, + is_required BOOLEAN DEFAULT true, + unlock_after_lesson_id INTEGER REFERENCES lessons(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(event_id, lesson_id), + UNIQUE(event_id, order_index) +); + +-- Lesson_Progress table: Tracks participant progress through lessons +CREATE TABLE lesson_progress ( + id SERIAL PRIMARY KEY, + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + event_lesson_id INTEGER NOT NULL REFERENCES event_lessons(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'not_started', + started_at TIMESTAMP, + completed_at TIMESTAMP, + score INTEGER DEFAULT 0, + attempts INTEGER DEFAULT 0, + current_step INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(participant_id, event_lesson_id), + CHECK (status IN ('not_started', 'in_progress', 'completed')) +); + +-- Lesson_Answers table: Stores participant answers +CREATE TABLE lesson_answers ( + id SERIAL PRIMARY KEY, + lesson_progress_id INTEGER NOT NULL REFERENCES lesson_progress(id) ON DELETE CASCADE, + question_key VARCHAR(100) NOT NULL, + answer_data JSONB NOT NULL, + is_correct BOOLEAN, + points_awarded INTEGER DEFAULT 0, + submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + feedback TEXT +); + +-- Admin_Users table: Admin authentication +CREATE TABLE admin_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX idx_participants_event ON participants(event_id); +CREATE INDEX idx_participants_session ON participants(session_token); +CREATE INDEX idx_participants_pseudonym ON participants(pseudonym); +CREATE INDEX idx_event_lessons_event ON event_lessons(event_id); +CREATE INDEX idx_event_lessons_lesson ON event_lessons(lesson_id); +CREATE INDEX idx_event_lessons_order ON event_lessons(event_id, order_index); +CREATE INDEX idx_lesson_progress_participant ON lesson_progress(participant_id); +CREATE INDEX idx_lesson_progress_event_lesson ON lesson_progress(event_lesson_id); +CREATE INDEX idx_lesson_progress_status ON lesson_progress(status); +CREATE INDEX idx_lesson_answers_progress ON lesson_answers(lesson_progress_id); +CREATE INDEX idx_lesson_answers_question ON lesson_answers(question_key); + +-- Trigger function to update updated_at timestamps +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Apply triggers to tables +CREATE TRIGGER update_events_updated_at BEFORE UPDATE ON events + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_lessons_updated_at BEFORE UPDATE ON lessons + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_lesson_progress_updated_at BEFORE UPDATE ON lesson_progress + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Insert default admin user (password: admin123 - CHANGE IN PRODUCTION!) +-- bcrypt hash for 'admin123' with 10 rounds +INSERT INTO admin_users (username, password_hash) VALUES +('admin', '$2b$10$mP8BvCik6In9lvWqxV57VuKglR3IqW4GfMoF.5fsT8HrTxRqscElW'); + +-- Comments for documentation +COMMENT ON TABLE events IS 'Training events or sessions that participants can join'; +COMMENT ON TABLE participants IS 'Anonymous participants identified by pseudonym within events'; +COMMENT ON TABLE lessons IS 'Catalog of available lesson modules'; +COMMENT ON TABLE event_lessons IS 'Lessons assigned to specific events with custom configuration'; +COMMENT ON TABLE lesson_progress IS 'Tracks individual participant progress through lessons'; +COMMENT ON TABLE lesson_answers IS 'Stores submitted answers with scoring information'; +COMMENT ON TABLE admin_users IS 'Administrative users with full system access'; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..31c64d7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +services: + # PostgreSQL Database + database: + image: postgres:15-alpine + container_name: lernplattform_db + environment: + POSTGRES_DB: ${DB_NAME:-lernplattform} + POSTGRES_USER: ${DB_USER:-lernplattform_user} + POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme123} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d:ro + ports: + - "${DB_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-lernplattform_user}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - lernplattform_network + + # Backend API + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: lernplattform_backend + environment: + NODE_ENV: ${NODE_ENV:-production} + PORT: ${BACKEND_PORT:-3000} + DB_HOST: database + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-lernplattform} + DB_USER: ${DB_USER:-lernplattform_user} + DB_PASSWORD: ${DB_PASSWORD:-changeme123} + JWT_SECRET: ${JWT_SECRET:-change_this_secret_key_in_production} + SESSION_SECRET: ${SESSION_SECRET:-change_this_session_secret} + ADMIN_DEFAULT_PASSWORD: ${ADMIN_DEFAULT_PASSWORD:-admin123} + volumes: + - ./backend/lessons:/app/lessons:ro + ports: + - "${BACKEND_PORT:-3000}:3000" + depends_on: + database: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - lernplattform_network + restart: unless-stopped + + # Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api} + container_name: lernplattform_frontend + ports: + - "${FRONTEND_PORT:-80}:80" + depends_on: + - backend + networks: + - lernplattform_network + restart: unless-stopped + +volumes: + postgres_data: + driver: local + +networks: + lernplattform_network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..91ef771 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,41 @@ +# Multi-stage build for frontend + +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build argument for API URL +ARG VITE_API_URL +ENV VITE_API_URL=$VITE_API_URL + +# Build application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget --quiet --tries=1 --spider http://localhost:80 || exit 1 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c70fc99 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Security Learning Platform + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..df1a744 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + + # Handle React Router - all routes go to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # Prevent access to hidden files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + # Prevent access to backup files + location ~ ~$ { + deny all; + access_log off; + log_not_found off; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a885b12 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2033 @@ +{ + "name": "lernplattform-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lernplattform-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.1" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "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-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/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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/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-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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d20a5cd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "lernplattform-frontend", + "version": "1.0.0", + "description": "Frontend for Security Awareness Learning Platform", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.1", + "axios": "^1.6.2" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.8" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..4bbc7d6 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { ParticipantProvider } from './contexts/ParticipantContext'; +import { AdminProvider } from './contexts/AdminContext'; +import AppRoutes from './routes/AppRoutes'; + +function App() { + return ( + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx b/frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx new file mode 100644 index 0000000..efc0375 --- /dev/null +++ b/frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx @@ -0,0 +1,367 @@ +import React, { useState } from 'react'; + +const BitBDemo = ({ lessonData }) => { + const [activeScenario, setActiveScenario] = useState(null); + const [dragAttempted, setDragAttempted] = useState(false); + const [inspectAttempted, setInspectAttempted] = useState(false); + + const interactiveData = lessonData?.interactiveData || {}; + const scenarios = interactiveData.scenarios || []; + const testInstructions = interactiveData.testInstructions || []; + + const closeScenario = () => { + setActiveScenario(null); + setDragAttempted(false); + setInspectAttempted(false); + }; + + const handleDragStart = (e, isReal) => { + setDragAttempted(true); + if (!isReal) { + e.preventDefault(); // Fake window can't be dragged + } + }; + + const handleAddressBarRightClick = (e, isReal) => { + if (!isReal) { + setInspectAttempted(true); + // Allow right-click menu to show "Inspect Element" on fake popup + } + }; + + const renderFakeBrowser = (scenario) => { + const providerStyles = { + Google: { primary: '#4285f4', secondary: '#ea4335' }, + Microsoft: { primary: '#00a4ef', secondary: '#7fba00' } + }; + + const colors = providerStyles[scenario.provider] || providerStyles.Google; + + return ( +
handleDragStart(e, scenario.isReal)} + > + {/* Fake Browser Chrome */} +
{ + if (!scenario.isReal) { + handleAddressBarRightClick(e, scenario.isReal); + } + }} + > + {/* Window Controls */} +
+
+
+
+
+ + {/* Fake Address Bar */} +
+ 🔒 + {scenario.domain} +
+
+ + {/* Login Form Content */} +
+
+ {scenario.provider === 'Google' ? ( + Google + ) : ( + Microsoft + )} +
+
+ Sign in to continue +
+ + + + + + +
+ + {/* Warning Badge */} +
+ {scenario.isReal ? '✅ REAL' : '⚠️ FAKE'} +
+ + {/* Close Button */} + + + {/* Feedback Messages */} + {dragAttempted && !scenario.isReal && ( +
+ ⚠️ Notice: This fake window cannot be dragged! +
+ )} + + {inspectAttempted && !scenario.isReal && ( +
+ ⚠️ Right-click works! This reveals it's just HTML, not a real browser. +
+ )} +
+ ); + }; + + return ( +
+

🎭 Browser-in-the-Browser Attack Demo

+ + {/* Test Instructions */} +
+
+ 🔍 How to Test: +
+
    + {testInstructions.map((instruction, idx) => ( +
  • {instruction}
  • + ))} +
+
+ + {/* Scenario Buttons */} +
+ {scenarios.map((scenario) => ( +
+
+ + {scenario.isReal ? '✅' : '⚠️'} + +
{scenario.title}
+
+

+ {scenario.description} +

+ + + {/* Indicators */} +
+
Key Indicators:
+
    + {scenario.indicators.slice(0, 3).map((indicator, idx) => ( +
  • {indicator}
  • + ))} +
+
+
+ ))} +
+ + {/* Educational Note */} +
+
+ ⚠️ Important +
+
+ In a real Browser-in-the-Browser attack, the fake popup would look identical to the real one. + The ONLY reliable way to detect it is by testing the physical behavior: Can you drag it outside the browser window? + Can you right-click the address bar and inspect it as HTML? +
+
+ + {/* Real World Examples */} + {interactiveData.realWorldExamples && ( +
+
+ 📰 Real-World BitB Attacks: +
+
+ {interactiveData.realWorldExamples.map((example, idx) => ( +
+ {example.year} + {' - '} + {example.target} + {' via '} + {example.provider} +
+ ))} +
+
+ )} + + {/* Active Scenario Overlay */} + {activeScenario && ( + <> + {/* Dark Overlay */} +
+ + {/* Fake Browser Window */} + {renderFakeBrowser(activeScenario)} + + )} +
+ ); +}; + +export default BitBDemo; diff --git a/frontend/src/components/lessons/InteractiveContent/SQLShopDemo.jsx b/frontend/src/components/lessons/InteractiveContent/SQLShopDemo.jsx new file mode 100644 index 0000000..0725c22 --- /dev/null +++ b/frontend/src/components/lessons/InteractiveContent/SQLShopDemo.jsx @@ -0,0 +1,272 @@ +import React, { useState } from 'react'; +import { participantAPI } from '../../../services/api.service'; + +const SQLShopDemo = ({ lessonData, eventLessonId }) => { + const [searchTerm, setSearchTerm] = useState(''); + const [queryResult, setQueryResult] = useState(null); + const [loading, setLoading] = useState(false); + const [showSafeComparison, setShowSafeComparison] = useState(false); + + const interactiveData = lessonData?.interactiveData || {}; + const examples = interactiveData.examples || []; + + const executeSearch = async (term = searchTerm) => { + if (!term.trim()) return; + + setLoading(true); + try { + const response = await participantAPI.executeLessonAction( + eventLessonId, + 'execute-query', + { searchTerm: term, mode: 'vulnerable' } + ); + setQueryResult(response.data.data); + } catch (error) { + console.error('Failed to execute query:', error); + setQueryResult({ + query: 'Error executing query', + results: [], + explanation: 'Failed to execute search' + }); + } finally { + setLoading(false); + } + }; + + const executeSafeSearch = async () => { + if (!searchTerm.trim()) return; + + setLoading(true); + try { + const response = await participantAPI.executeLessonAction( + eventLessonId, + 'execute-query', + { searchTerm, mode: 'safe' } + ); + setQueryResult(response.data.data); + setShowSafeComparison(true); + } catch (error) { + console.error('Failed to execute safe query:', error); + } finally { + setLoading(false); + } + }; + + const loadExample = (example) => { + setSearchTerm(example.input); + setShowSafeComparison(false); + }; + + return ( +
+
+

🛒 TechShop - Product Search

+

+ This is a vulnerable online shop. Try searching for products, then experiment with SQL injection. +

+ + {/* Example Queries */} +
+
+ Try these examples: +
+
+ {examples.map((example, idx) => ( + + ))} +
+
+ + {/* Search Input */} +
+ setSearchTerm(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && executeSearch()} + placeholder="Search products... (try SQL injection!)" + style={{ + flex: 1, + padding: '0.75rem', + border: '2px solid #e5e7eb', + borderRadius: '0.375rem', + fontSize: '0.875rem', + fontFamily: 'monospace' + }} + /> + + +
+
+ + {/* Query Results */} + {queryResult && ( +
+ {/* SQL Query Display */} +
+
Executed SQL Query:
+
{queryResult.query}
+ {queryResult.parameter && ( +
+ Parameter: + '{queryResult.parameter}' +
+ )} +
+ + {/* Injection Detection */} + {queryResult.injectionDetected && ( +
+
+ ⚠️ SQL Injection Detected: {queryResult.injectionType?.replace(/_/g, ' ')} +
+
+ {queryResult.explanation} +
+
+ )} + + {/* Safe Query Explanation */} + {showSafeComparison && queryResult.explanation && queryResult.explanation.includes('✅') && ( +
+
+ ✅ Secure Query +
+
+ {queryResult.explanation} +
+
+ )} + + {/* Results Table */} +
+
+ Results: {queryResult.recordCount} {queryResult.recordCount === 1 ? 'record' : 'records'} +
+ + {queryResult.results.length > 0 ? ( +
+ + + + + + + + + + + + {queryResult.results.map((product, idx) => ( + + + + + + + + ))} + +
IDNamePriceCategoryStock
{product.id} + {product.name} + + {typeof product.price === 'number' ? `$${product.price.toFixed(2)}` : product.price} + {product.category}{product.stock}
+
+ ) : ( +
+ {queryResult.injectionType === 'DROP_TABLE' + ? '💥 Table would be deleted! (Simulated - no actual data was harmed)' + : 'No products found' + } +
+ )} +
+
+ )} + + {/* Educational Note */} + {!queryResult && ( +
+
+ 💡 Learning Tip +
+
+ Start with a normal search like "laptop" to see how the query works. Then try the SQL injection examples to understand how attackers manipulate queries. +
+
+ )} +
+ ); +}; + +export default SQLShopDemo; diff --git a/frontend/src/contexts/AdminContext.jsx b/frontend/src/contexts/AdminContext.jsx new file mode 100644 index 0000000..eb11e67 --- /dev/null +++ b/frontend/src/contexts/AdminContext.jsx @@ -0,0 +1,80 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { adminSessionService } from '../services/session.service'; +import { adminAPI } from '../services/api.service'; + +const AdminContext = createContext(); + +export const useAdmin = () => { + const context = useContext(AdminContext); + if (!context) { + throw new Error('useAdmin must be used within AdminProvider'); + } + return context; +}; + +export const AdminProvider = ({ children }) => { + const [admin, setAdmin] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Check for existing session + const token = adminSessionService.getToken(); + if (token) { + const savedAdmin = adminSessionService.getAdmin(); + if (savedAdmin) { + setAdmin(savedAdmin); + setIsAuthenticated(true); + // Set token in axios + localStorage.setItem('token', token); + } + } + setLoading(false); + }, []); + + const login = async (username, password) => { + try { + const response = await adminAPI.login(username, password); + const { token, admin: newAdmin } = response.data.data; + + // Save to localStorage + adminSessionService.saveSession(token, newAdmin); + // Also set for axios interceptor + localStorage.setItem('token', token); + + // Update state + setAdmin(newAdmin); + setIsAuthenticated(true); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error.response?.data?.error?.message || 'Login failed' + }; + } + }; + + const logout = () => { + adminSessionService.clearSession(); + localStorage.removeItem('token'); + setAdmin(null); + setIsAuthenticated(false); + }; + + const value = { + admin, + isAuthenticated, + loading, + login, + logout + }; + + return ( + + {children} + + ); +}; + +export default AdminContext; diff --git a/frontend/src/contexts/ParticipantContext.jsx b/frontend/src/contexts/ParticipantContext.jsx new file mode 100644 index 0000000..bb32d3b --- /dev/null +++ b/frontend/src/contexts/ParticipantContext.jsx @@ -0,0 +1,81 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { sessionService } from '../services/session.service'; +import { participantAPI } from '../services/api.service'; + +const ParticipantContext = createContext(); + +export const useParticipant = () => { + const context = useContext(ParticipantContext); + if (!context) { + throw new Error('useParticipant must be used within ParticipantProvider'); + } + return context; +}; + +export const ParticipantProvider = ({ children }) => { + const [participant, setParticipant] = useState(null); + const [event, setEvent] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Check for existing session + const token = sessionService.getToken(); + if (token) { + const savedParticipant = sessionService.getParticipant(); + const savedEvent = sessionService.getEvent(); + if (savedParticipant && savedEvent) { + setParticipant(savedParticipant); + setEvent(savedEvent); + setIsAuthenticated(true); + } + } + setLoading(false); + }, []); + + const joinEvent = async (pseudonym, eventId) => { + try { + const response = await participantAPI.joinEvent(pseudonym, eventId); + const { sessionToken, participant: newParticipant, event: newEvent } = response.data.data; + + // Save to localStorage + sessionService.saveSession(sessionToken, newParticipant, newEvent); + + // Update state + setParticipant(newParticipant); + setEvent(newEvent); + setIsAuthenticated(true); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error.response?.data?.error?.message || 'Failed to join event' + }; + } + }; + + const logout = () => { + sessionService.clearSession(); + setParticipant(null); + setEvent(null); + setIsAuthenticated(false); + }; + + const value = { + participant, + event, + isAuthenticated, + loading, + joinEvent, + logout + }; + + return ( + + {children} + + ); +}; + +export default ParticipantContext; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..32fcc3f --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles/index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/pages/EventLanding.jsx b/frontend/src/pages/EventLanding.jsx new file mode 100644 index 0000000..f878730 --- /dev/null +++ b/frontend/src/pages/EventLanding.jsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useParticipant } from '../contexts/ParticipantContext'; +import { participantAPI } from '../services/api.service'; + +const EventLanding = () => { + const [lessons, setLessons] = useState([]); + const [progress, setProgress] = useState(null); + const [loading, setLoading] = useState(true); + const { participant, event, logout } = useParticipant(); + const navigate = useNavigate(); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const [lessonsRes, progressRes] = await Promise.all([ + participantAPI.getEventLessons(event.id), + participantAPI.getProgress() + ]); + setLessons(lessonsRes.data.data); + setProgress(progressRes.data.data); + } catch (error) { + console.error('Failed to load data:', error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+ + +
+
+

Your Progress

+
+
+
{progress.total_score}
+
Total Score
+
+
+
+ {progress.lessons_completed}/{progress.total_lessons_available} +
+
Lessons Completed
+
+
+
+ +

Lessons

+
+ {lessons.map(lesson => ( +
lesson.isUnlocked && navigate(`/lesson/${lesson.eventLessonId}`)} + > +
+
+
+

{lesson.title}

+ {!lesson.isUnlocked && ( + 🔒 + )} +
+

{lesson.description}

+
+ ⏱️ {lesson.estimatedDuration} min + ⭐ {lesson.maxPoints} points + 📊 {lesson.difficultyLevel} +
+
+ {lesson.progress && ( +
+ {lesson.progress.status === 'completed' ? ( +
+
+
+ {lesson.progress.score}/{lesson.maxPoints} +
+
+ ) : lesson.progress.status === 'in_progress' ? ( + + In Progress + + ) : null} +
+ )} +
+
+ ))} +
+
+
+ ); +}; + +export default EventLanding; diff --git a/frontend/src/pages/Hub.jsx b/frontend/src/pages/Hub.jsx new file mode 100644 index 0000000..ca4ba83 --- /dev/null +++ b/frontend/src/pages/Hub.jsx @@ -0,0 +1,116 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useParticipant } from '../contexts/ParticipantContext'; +import { participantAPI } from '../services/api.service'; + +const Hub = () => { + const [events, setEvents] = useState([]); + const [pseudonym, setPseudonym] = useState(''); + const [selectedEvent, setSelectedEvent] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { isAuthenticated, joinEvent } = useParticipant(); + const navigate = useNavigate(); + + useEffect(() => { + if (isAuthenticated) { + navigate('/event'); + } + loadEvents(); + }, [isAuthenticated, navigate]); + + const loadEvents = async () => { + try { + const response = await participantAPI.getEvents(); + setEvents(response.data.data); + } catch (err) { + console.error('Failed to load events:', err); + } + }; + + const handleJoin = async (e) => { + e.preventDefault(); + setError(''); + + if (!pseudonym || !selectedEvent) { + setError('Please enter a pseudonym and select an event'); + return; + } + + setLoading(true); + const result = await joinEvent(pseudonym, selectedEvent); + + if (result.success) { + navigate('/event'); + } else { + setError(result.error); + setLoading(false); + } + }; + + return ( +
+
+

Security Learning Platform

+

Join an event to start learning

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setPseudonym(e.target.value)} + placeholder="Enter a pseudonym" + disabled={loading} + style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }} + /> +
+ +
+ + +
+ + +
+ + +
+
+ ); +}; + +export default Hub; diff --git a/frontend/src/pages/LessonView.jsx b/frontend/src/pages/LessonView.jsx new file mode 100644 index 0000000..6620f9a --- /dev/null +++ b/frontend/src/pages/LessonView.jsx @@ -0,0 +1,258 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { participantAPI } from '../services/api.service'; +import SQLShopDemo from '../components/lessons/InteractiveContent/SQLShopDemo'; +import BitBDemo from '../components/lessons/InteractiveContent/BitBDemo'; + +const LessonView = () => { + const { eventLessonId } = useParams(); + const navigate = useNavigate(); + const [lesson, setLesson] = useState(null); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [answers, setAnswers] = useState({}); + const [feedback, setFeedback] = useState({}); + const [totalScore, setTotalScore] = useState(0); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadLesson(); + }, [eventLessonId]); + + const loadLesson = async () => { + try { + const response = await participantAPI.getLessonContent(eventLessonId); + setLesson(response.data.data); + await participantAPI.startLesson(eventLessonId); + } catch (error) { + console.error('Failed to load lesson:', error); + } finally { + setLoading(false); + } + }; + + const handleAnswer = async (questionId, answer) => { + try { + const response = await participantAPI.submitAnswer(eventLessonId, questionId, answer); + const result = response.data.data; + + setFeedback(prev => ({ ...prev, [questionId]: result })); + setTotalScore(result.totalScore); + } catch (error) { + console.error('Failed to submit answer:', error); + } + }; + + const handleComplete = async () => { + try { + await participantAPI.completeLesson(eventLessonId); + navigate('/event'); + } catch (error) { + console.error('Failed to complete lesson:', error); + } + }; + + if (loading || !lesson) { + return
Loading lesson...
; + } + + const currentStep = lesson.steps[currentStepIndex]; + const isLastStep = currentStepIndex === lesson.steps.length - 1; + + return ( +
+ + +
+
+

{currentStep.title}

+ + {currentStep.type === 'content' && ( +
+ {currentStep.content} +
+ )} + + {currentStep.type === 'interactive' && ( +
+ {currentStep.content && ( +
+ {currentStep.content} +
+ )} + {currentStep.interactiveComponent === 'SQLShopDemo' && ( + + )} + {currentStep.interactiveComponent === 'BitBDemo' && ( + + )} +
+ )} + + {currentStep.type === 'question' && ( +
+

{currentStep.question}

+ + {currentStep.questionType === 'single_choice' && ( +
+ {currentStep.options.map(option => ( + + ))} +
+ )} + + {currentStep.questionType === 'multiple_choice' && ( +
+ {currentStep.options.map(option => ( + + ))} +
+ )} + + {currentStep.questionType === 'free_text' && ( +