initial commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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 (
|
||||
<div>
|
||||
{/* Your interactive UI */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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' && (
|
||||
<YourComponent lessonData={lesson} eventLessonId={eventLessonId} />
|
||||
)}
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+2186
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user