From ac1e56422312b66788b2503400f6fe0b87405e7b Mon Sep 17 00:00:00 2001 From: fdciabdul Date: Wed, 25 Mar 2026 11:17:39 +0700 Subject: [PATCH 1/9] feat: major platform upgrade with 16 new features - Multi-user registration with role-based access (admin/instructor/trainee) - Gamification system: XP, levels, 12 badges, daily streaks - Leaderboard with global and team rankings - Analytics dashboard with Recharts (radar, line, bar, pie charts) - Organization/team management with invite codes - Campaign mode for multi-stage attack simulations - Post-simulation debrief with Cialdini analysis and AI deep analysis - Custom scenario builder UI with visual node editor - Email simulation renderer (Gmail-style) - Certificate generation (Platinum/Gold/Silver/Bronze) - Adaptive difficulty AI based on user performance - Notification system with real-time bell component - Webhook/Slack integration for event callbacks - Dark/light theme toggle with CSS variable system - Voice simulation (vishing) via Web Speech API - PWA support with manifest.json Also includes: - Backend refactored from monolithic server.py to modular architecture - Rate limiting on auth endpoints - Docker healthchecks for all services - GitHub Actions CI/CD pipeline - Ruff linter configuration - .env.example for contributor onboarding - Bug fixes: missing sanitize_llm_output, unreachable dead code Co-Authored-By: cp --- .env.example | 19 + .github/workflows/ci.yml | 67 ++ Makefile | 16 +- backend/middleware/__init__.py | 0 backend/middleware/rate_limit.py | 34 + backend/models/__init__.py | 45 ++ backend/models/schemas.py | 274 +++++++ backend/pyproject.toml | 29 + backend/requirements.txt | 4 + backend/routes/__init__.py | 39 + backend/routes/adaptive.py | 24 + backend/routes/analytics.py | 166 +++++ backend/routes/auth.py | 155 ++++ backend/routes/campaigns.py | 147 ++++ backend/routes/certificates.py | 98 +++ backend/routes/challenges.py | 30 + backend/routes/debrief.py | 159 ++++ backend/routes/imports.py | 40 + backend/routes/leaderboard.py | 82 +++ backend/routes/llm.py | 144 ++++ backend/routes/notifications.py | 49 ++ backend/routes/organizations.py | 128 ++++ backend/routes/quizzes.py | 22 + backend/routes/reports.py | 26 + backend/routes/scenario_builder.py | 128 ++++ backend/routes/settings.py | 27 + backend/routes/simulations.py | 58 ++ backend/routes/webhooks.py | 82 +++ backend/server.py | 771 +++----------------- backend/services/__init__.py | 22 + backend/services/adaptive.py | 106 +++ backend/services/auth.py | 50 ++ backend/services/database.py | 9 + backend/services/gamification.py | 189 +++++ backend/services/llm.py | 145 ++++ backend/services/scoring.py | 24 + docker-compose.yml | 42 +- frontend/public/index.html | 17 +- frontend/public/manifest.json | 22 + frontend/src/App.js | 141 ++-- frontend/src/components/EmailRenderer.js | 69 ++ frontend/src/components/ErrorBoundary.js | 48 ++ frontend/src/components/Layout.js | 58 +- frontend/src/components/NotificationBell.js | 108 +++ frontend/src/components/VoiceSimulation.js | 175 +++++ frontend/src/index.css | 53 ++ frontend/src/pages/AnalyticsPage.js | 190 +++++ frontend/src/pages/CampaignsPage.js | 179 +++++ frontend/src/pages/CertificatePage.js | 115 +++ frontend/src/pages/DebriefPage.js | 177 +++++ frontend/src/pages/LeaderboardPage.js | 163 +++++ frontend/src/pages/LoginPage.js | 13 +- frontend/src/pages/RegisterPage.js | 144 ++++ frontend/src/pages/ScenarioBuilderPage.js | 298 ++++++++ 54 files changed, 4662 insertions(+), 758 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 backend/middleware/__init__.py create mode 100644 backend/middleware/rate_limit.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/schemas.py create mode 100644 backend/pyproject.toml create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/adaptive.py create mode 100644 backend/routes/analytics.py create mode 100644 backend/routes/auth.py create mode 100644 backend/routes/campaigns.py create mode 100644 backend/routes/certificates.py create mode 100644 backend/routes/challenges.py create mode 100644 backend/routes/debrief.py create mode 100644 backend/routes/imports.py create mode 100644 backend/routes/leaderboard.py create mode 100644 backend/routes/llm.py create mode 100644 backend/routes/notifications.py create mode 100644 backend/routes/organizations.py create mode 100644 backend/routes/quizzes.py create mode 100644 backend/routes/reports.py create mode 100644 backend/routes/scenario_builder.py create mode 100644 backend/routes/settings.py create mode 100644 backend/routes/simulations.py create mode 100644 backend/routes/webhooks.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/adaptive.py create mode 100644 backend/services/auth.py create mode 100644 backend/services/database.py create mode 100644 backend/services/gamification.py create mode 100644 backend/services/llm.py create mode 100644 backend/services/scoring.py create mode 100644 frontend/public/manifest.json create mode 100644 frontend/src/components/EmailRenderer.js create mode 100644 frontend/src/components/ErrorBoundary.js create mode 100644 frontend/src/components/NotificationBell.js create mode 100644 frontend/src/components/VoiceSimulation.js create mode 100644 frontend/src/pages/AnalyticsPage.js create mode 100644 frontend/src/pages/CampaignsPage.js create mode 100644 frontend/src/pages/CertificatePage.js create mode 100644 frontend/src/pages/DebriefPage.js create mode 100644 frontend/src/pages/LeaderboardPage.js create mode 100644 frontend/src/pages/RegisterPage.js create mode 100644 frontend/src/pages/ScenarioBuilderPage.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3512462 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# =========================================== +# Pretexta - Environment Configuration +# =========================================== +# Copy this file to .env and fill in your values +# cp .env.example .env + +# MongoDB Configuration +MONGO_USERNAME=soceng_admin +MONGO_PASSWORD=soceng_secure_password_2025 +DB_NAME=Pretexta + +# JWT Secret - CHANGE THIS IN PRODUCTION +JWT_SECRET=change-this-secret-key-in-production + +# CORS Origins (comma-separated) +CORS_ORIGINS=http://localhost:3000,http://localhost:80 + +# Frontend Backend URL +REACT_APP_BACKEND_URL=http://localhost:8001 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..612211c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend-lint: + name: Backend Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install ruff + run: pip install ruff + + - name: Lint + run: ruff check . + + - name: Format check + run: ruff format --check . + + frontend-lint: + name: Frontend Lint & Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "yarn" + cache-dependency-path: frontend/yarn.lock + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Lint + run: yarn lint 2>/dev/null || true + + - name: Build + run: yarn build + + docker-build: + name: Docker Build + runs-on: ubuntu-latest + needs: [backend-lint, frontend-lint] + steps: + - uses: actions/checkout@v4 + + - name: Build backend image + run: docker build -f Dockerfile.backend -t pretexta-backend:test . + + - name: Build frontend image + run: docker build -f Dockerfile.frontend -t pretexta-frontend:test . diff --git a/Makefile b/Makefile index e9a386f..9b6b285 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # SocengLab Makefile -.PHONY: help install build up down restart logs clean test seed +.PHONY: help install build up down restart logs clean test seed lint lint-fix # Default target help: @@ -23,6 +23,8 @@ help: @echo " make logs-backend - Show backend logs only" @echo " make logs-frontend - Show frontend logs only" @echo " make test - Run tests" + @echo " make lint - Run backend linter" + @echo " make lint-fix - Auto-fix lint issues" @echo "" @echo "Maintenance:" @echo " make clean - Remove containers and volumes" @@ -90,6 +92,18 @@ test: @cd frontend && yarn test --watchAll=false @echo "✅ Tests completed" +lint: + @echo "Linting backend..." + @cd backend && ruff check . + @cd backend && ruff format --check . + @echo "✅ Backend lint passed" + +lint-fix: + @echo "Fixing backend lint issues..." + @cd backend && ruff check --fix . + @cd backend && ruff format . + @echo "✅ Backend lint fixed" + clean: @echo "Cleaning up containers and volumes..." @docker-compose down -v diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/middleware/rate_limit.py b/backend/middleware/rate_limit.py new file mode 100644 index 0000000..445cc0b --- /dev/null +++ b/backend/middleware/rate_limit.py @@ -0,0 +1,34 @@ +import time +from collections import defaultdict +from fastapi import HTTPException, Request +from starlette.middleware.base import BaseHTTPMiddleware + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Simple in-memory rate limiter for auth endpoints.""" + + def __init__(self, app, max_attempts: int = 10, window_seconds: int = 300): + super().__init__(app) + self.max_attempts = max_attempts + self.window_seconds = window_seconds + self.attempts: dict[str, list[float]] = defaultdict(list) + + async def dispatch(self, request: Request, call_next): + if request.url.path == "/api/auth/login" and request.method == "POST": + client_ip = request.client.host if request.client else "unknown" + now = time.time() + + # Clean old entries + self.attempts[client_ip] = [ + t for t in self.attempts[client_ip] if now - t < self.window_seconds + ] + + if len(self.attempts[client_ip]) >= self.max_attempts: + raise HTTPException( + status_code=429, + detail=f"Too many login attempts. Try again in {self.window_seconds // 60} minutes.", + ) + + self.attempts[client_ip].append(now) + + return await call_next(request) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..2f8468a --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,45 @@ +from models.schemas import ( + User, + RegisterRequest, + LoginRequest, + LoginResponse, + PasswordChangeRequest, + ProfileUpdateRequest, + Challenge, + Quiz, + Simulation, + Campaign, + CampaignStage, + CampaignProgress, + Organization, + Badge, + LeaderboardEntry, + Notification, + WebhookConfig, + ScenarioTemplate, + LLMConfig, + Settings, +) + +__all__ = [ + "User", + "RegisterRequest", + "LoginRequest", + "LoginResponse", + "PasswordChangeRequest", + "ProfileUpdateRequest", + "Challenge", + "Quiz", + "Simulation", + "Campaign", + "CampaignStage", + "CampaignProgress", + "Organization", + "Badge", + "LeaderboardEntry", + "Notification", + "WebhookConfig", + "ScenarioTemplate", + "LLMConfig", + "Settings", +] diff --git a/backend/models/schemas.py b/backend/models/schemas.py new file mode 100644 index 0000000..81518ce --- /dev/null +++ b/backend/models/schemas.py @@ -0,0 +1,274 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional, Dict, Any +from datetime import datetime, timezone +import uuid + + +# ==================== AUTH & USERS ==================== + +class User(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + username: str + password_hash: str + email: Optional[str] = None + display_name: Optional[str] = None + role: str = "trainee" # admin, instructor, trainee + organization_id: Optional[str] = None + avatar_url: Optional[str] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + is_active: bool = True + # Gamification + xp: int = 0 + level: int = 1 + streak_days: int = 0 + last_active: Optional[datetime] = None + badges: List[str] = Field(default_factory=list) + # Preferences + theme: str = "dark" + notifications_enabled: bool = True + + +class RegisterRequest(BaseModel): + username: str + password: str + email: Optional[str] = None + display_name: Optional[str] = None + invite_code: Optional[str] = None + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + token: str + user: Dict[str, Any] + + +class PasswordChangeRequest(BaseModel): + current_password: str + new_password: str + + +class ProfileUpdateRequest(BaseModel): + display_name: Optional[str] = None + email: Optional[str] = None + avatar_url: Optional[str] = None + theme: Optional[str] = None + notifications_enabled: Optional[bool] = None + + +# ==================== CONTENT ==================== + +class Challenge(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + description: str + difficulty: str # easy, medium, hard + cialdini_categories: List[str] + estimated_time: int # minutes + nodes: List[Dict[str, Any]] + metadata: Dict[str, Any] = Field(default_factory=dict) + content_en: Optional[Dict[str, Any]] = None + content_id: Optional[Dict[str, Any]] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class Quiz(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + description: str + difficulty: str + cialdini_categories: List[str] + questions: List[Dict[str, Any]] + content_en: Optional[Dict[str, Any]] = None + content_id: Optional[Dict[str, Any]] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +# ==================== SIMULATIONS ==================== + +class Simulation(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + user_id: Optional[str] = None + challenge_id: Optional[str] = None + quiz_id: Optional[str] = None + simulation_type: str # challenge, quiz, ai_challenge, campaign + status: str # running, completed, paused + events: List[Dict[str, Any]] = Field(default_factory=list) + score: Optional[float] = None + started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + completed_at: Optional[datetime] = None + participant_name: Optional[str] = None + title: Optional[str] = None + + # AI Challenge specific fields + type: Optional[str] = None + challenge_type: Optional[str] = None + category: Optional[str] = None + difficulty: Optional[str] = None + total_questions: Optional[int] = None + correct_answers: Optional[int] = None + answers: Optional[Dict[str, Any]] = None + challenge_data: Optional[Dict[str, Any]] = None + + # Campaign tracking + campaign_id: Optional[str] = None + stage_index: Optional[int] = None + + # Debrief data + debrief: Optional[Dict[str, Any]] = None + + +# ==================== CAMPAIGNS ==================== + +class CampaignStage(BaseModel): + stage_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + description: str + channel: str # email, phone, chat, social_media + persona_id: Optional[str] = None + challenge_id: Optional[str] = None + order: int = 0 + unlock_condition: str = "complete_previous" # complete_previous, score_above, always + + +class Campaign(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + description: str + difficulty: str + stages: List[CampaignStage] = Field(default_factory=list) + cialdini_categories: List[str] = Field(default_factory=list) + estimated_time: int = 30 + created_by: Optional[str] = None + is_published: bool = False + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class CampaignProgress(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + campaign_id: str + user_id: str + current_stage: int = 0 + stage_results: List[Dict[str, Any]] = Field(default_factory=list) + status: str = "in_progress" # in_progress, completed, abandoned + overall_score: Optional[float] = None + started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + completed_at: Optional[datetime] = None + + +# ==================== ORGANIZATIONS ==================== + +class Organization(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + description: Optional[str] = None + invite_code: str = Field(default_factory=lambda: str(uuid.uuid4())[:8]) + owner_id: str + member_ids: List[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + settings: Dict[str, Any] = Field(default_factory=dict) + + +# ==================== GAMIFICATION ==================== + +class Badge(BaseModel): + id: str + name: str + description: str + icon: str + condition: str # e.g. "complete_5_scenarios", "streak_7" + xp_reward: int = 50 + + +class LeaderboardEntry(BaseModel): + user_id: str + username: str + display_name: Optional[str] = None + xp: int = 0 + level: int = 1 + badges_count: int = 0 + simulations_completed: int = 0 + avg_score: float = 0.0 + streak_days: int = 0 + + +# ==================== NOTIFICATIONS ==================== + +class Notification(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + user_id: str + title: str + message: str + type: str = "info" # info, achievement, reminder, alert + read: bool = False + link: Optional[str] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +# ==================== WEBHOOKS ==================== + +class WebhookConfig(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + url: str + events: List[str] = Field(default_factory=list) # simulation_complete, badge_earned, etc + secret: Optional[str] = None + enabled: bool = True + organization_id: Optional[str] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +# ==================== SCENARIO BUILDER ==================== + +class ScenarioTemplate(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + title: str + description: str + difficulty: str = "medium" + cialdini_categories: List[str] = Field(default_factory=list) + channel: str = "email_inbox" # email_inbox, chat, phone, sms, social_media + nodes: List[Dict[str, Any]] = Field(default_factory=list) + metadata: Dict[str, Any] = Field(default_factory=dict) + content_en: Optional[Dict[str, Any]] = None + content_id: Optional[Dict[str, Any]] = None + created_by: Optional[str] = None + is_draft: bool = True + is_published: bool = False + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +# ==================== CONFIG ==================== + +class LLMConfig(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + provider: str + api_key: str + model_name: Optional[str] = None + enabled: bool = False + rate_limit: int = 100 + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class Settings(BaseModel): + model_config = ConfigDict(extra="ignore") + id: str = "settings" + language: str = "en" + theme: str = "dark" + first_run_completed: bool = False + llm_enabled: bool = False + reduce_motion: bool = False diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..25bc385 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,29 @@ +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "S", # flake8-bandit (security) +] +ignore = [ + "S105", # hardcoded-password-string (we handle this via env vars) + "S106", # hardcoded-password-func-arg +] + +[tool.ruff.lint.per-file-ignores] +"scripts/*" = ["S"] # Allow security warnings in utility scripts + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/backend/requirements.txt b/backend/requirements.txt index 709a536..cc8a36e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,3 +19,7 @@ langchain-groq # Utilities PyYAML +httpx + +# Development +ruff diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..7c95dfe --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1,39 @@ +from routes.auth import router as auth_router +from routes.challenges import router as challenges_router +from routes.quizzes import router as quizzes_router +from routes.simulations import router as simulations_router +from routes.llm import router as llm_router +from routes.settings import router as settings_router +from routes.imports import router as imports_router +from routes.reports import router as reports_router +from routes.leaderboard import router as leaderboard_router +from routes.analytics import router as analytics_router +from routes.organizations import router as organizations_router +from routes.campaigns import router as campaigns_router +from routes.notifications import router as notifications_router +from routes.webhooks import router as webhooks_router +from routes.scenario_builder import router as scenario_builder_router +from routes.debrief import router as debrief_router +from routes.certificates import router as certificates_router +from routes.adaptive import router as adaptive_router + +__all__ = [ + "auth_router", + "challenges_router", + "quizzes_router", + "simulations_router", + "llm_router", + "settings_router", + "imports_router", + "reports_router", + "leaderboard_router", + "analytics_router", + "organizations_router", + "campaigns_router", + "notifications_router", + "webhooks_router", + "scenario_builder_router", + "debrief_router", + "certificates_router", + "adaptive_router", +] diff --git a/backend/routes/adaptive.py b/backend/routes/adaptive.py new file mode 100644 index 0000000..cb10d96 --- /dev/null +++ b/backend/routes/adaptive.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends + +from models.schemas import User +from services.auth import get_current_user +from services.adaptive import get_recommended_difficulty, get_recommended_categories, get_adaptive_persona_params + +router = APIRouter(prefix="/adaptive", tags=["adaptive"]) + + +@router.get("/difficulty") +async def get_difficulty_recommendation(current_user: User = Depends(get_current_user)): + """Get recommended difficulty for current user.""" + difficulty = await get_recommended_difficulty(current_user.id) + categories = await get_recommended_categories(current_user.id) + return { + "recommended_difficulty": difficulty, + "weak_categories": categories, + } + + +@router.get("/persona-params") +async def get_persona_params(current_user: User = Depends(get_current_user)): + """Get adaptive AI persona parameters based on user skill level.""" + return await get_adaptive_persona_params(current_user.id) diff --git a/backend/routes/analytics.py b/backend/routes/analytics.py new file mode 100644 index 0000000..6c3ec4f --- /dev/null +++ b/backend/routes/analytics.py @@ -0,0 +1,166 @@ +from datetime import datetime, timezone, timedelta +from fastapi import APIRouter, Depends, Query + +from models.schemas import User +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/analytics", tags=["analytics"]) + + +@router.get("/personal") +async def get_personal_analytics(current_user: User = Depends(get_current_user)): + """Get personal analytics for the current user.""" + sims = await db.simulations.find( + {"user_id": current_user.id, "status": "completed"}, {"_id": 0} + ).to_list(1000) + + if not sims: + return { + "total_simulations": 0, + "avg_score": 0, + "category_breakdown": {}, + "difficulty_breakdown": {}, + "score_over_time": [], + "cialdini_radar": {}, + "type_distribution": {}, + "improvement_rate": 0, + } + + # Average score + scores = [s.get("score", 0) for s in sims if s.get("score") is not None] + avg_score = sum(scores) / len(scores) if scores else 0 + + # Category breakdown (Cialdini) + cialdini_scores = {} + cialdini_counts = {} + for sim in sims: + categories = sim.get("challenge_data", {}).get("cialdini_categories", []) + score = sim.get("score", 0) or 0 + for cat in categories: + cialdini_scores.setdefault(cat, []).append(score) + cialdini_counts[cat] = cialdini_counts.get(cat, 0) + 1 + + cialdini_radar = {} + for cat, cat_scores in cialdini_scores.items(): + cialdini_radar[cat] = round(sum(cat_scores) / len(cat_scores), 1) if cat_scores else 0 + + # Difficulty breakdown + difficulty_counts = {} + difficulty_scores = {} + for sim in sims: + diff = sim.get("difficulty", "medium") + difficulty_counts[diff] = difficulty_counts.get(diff, 0) + 1 + score = sim.get("score", 0) or 0 + difficulty_scores.setdefault(diff, []).append(score) + + difficulty_breakdown = {} + for diff, d_scores in difficulty_scores.items(): + difficulty_breakdown[diff] = { + "count": difficulty_counts.get(diff, 0), + "avg_score": round(sum(d_scores) / len(d_scores), 1) if d_scores else 0, + } + + # Score over time + score_over_time = [] + for sim in sorted(sims, key=lambda s: s.get("started_at", "")): + score_over_time.append({ + "date": sim.get("completed_at", sim.get("started_at", "")), + "score": sim.get("score", 0) or 0, + "type": sim.get("simulation_type", "unknown"), + "title": sim.get("title", "Untitled"), + }) + + # Type distribution + type_dist = {} + for sim in sims: + sim_type = sim.get("simulation_type", "unknown") + type_dist[sim_type] = type_dist.get(sim_type, 0) + 1 + + # Improvement rate (compare first half vs second half) + improvement_rate = 0 + if len(scores) >= 4: + mid = len(scores) // 2 + first_half_avg = sum(scores[:mid]) / mid + second_half_avg = sum(scores[mid:]) / (len(scores) - mid) + improvement_rate = round(second_half_avg - first_half_avg, 1) + + return { + "total_simulations": len(sims), + "avg_score": round(avg_score, 1), + "category_breakdown": cialdini_counts, + "difficulty_breakdown": difficulty_breakdown, + "score_over_time": score_over_time, + "cialdini_radar": cialdini_radar, + "type_distribution": type_dist, + "improvement_rate": improvement_rate, + } + + +@router.get("/team") +async def get_team_analytics(current_user: User = Depends(get_current_user)): + """Get team/organization analytics.""" + if not current_user.organization_id: + return {"error": "Not part of an organization"} + + org = await db.organizations.find_one( + {"id": current_user.organization_id}, {"_id": 0} + ) + if not org: + return {"error": "Organization not found"} + + member_ids = org.get("member_ids", []) + members = await db.users.find( + {"id": {"$in": member_ids}}, + {"_id": 0, "password_hash": 0}, + ).to_list(100) + + # Aggregate team stats + team_stats = [] + total_sims = 0 + total_score = 0 + total_scored_sims = 0 + weakest_categories = {} + + for member in members: + user_sims = await db.simulations.find( + {"user_id": member["id"], "status": "completed"}, {"_id": 0} + ).to_list(1000) + + user_scores = [s.get("score", 0) for s in user_sims if s.get("score") is not None] + user_avg = sum(user_scores) / len(user_scores) if user_scores else 0 + total_sims += len(user_sims) + total_score += sum(user_scores) + total_scored_sims += len(user_scores) + + # Track per-category scores + for sim in user_sims: + categories = sim.get("challenge_data", {}).get("cialdini_categories", []) + score = sim.get("score", 0) or 0 + for cat in categories: + weakest_categories.setdefault(cat, []).append(score) + + team_stats.append({ + "user_id": member["id"], + "username": member.get("username", ""), + "display_name": member.get("display_name", ""), + "simulations_completed": len(user_sims), + "avg_score": round(user_avg, 1), + "level": member.get("level", 1), + }) + + # Find weakest areas + category_averages = {} + for cat, cat_scores in weakest_categories.items(): + category_averages[cat] = round(sum(cat_scores) / len(cat_scores), 1) + + team_avg = round(total_score / total_scored_sims, 1) if total_scored_sims > 0 else 0 + + return { + "organization": {"id": org["id"], "name": org["name"]}, + "total_members": len(members), + "total_simulations": total_sims, + "team_avg_score": team_avg, + "category_averages": category_averages, + "member_stats": sorted(team_stats, key=lambda x: x["avg_score"], reverse=True), + } diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..32328c0 --- /dev/null +++ b/backend/routes/auth.py @@ -0,0 +1,155 @@ +import re +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User, LoginRequest, LoginResponse, RegisterRequest, PasswordChangeRequest, ProfileUpdateRequest +from services.auth import hash_password, verify_password, create_token, get_current_user +from services.database import db +from services.gamification import award_xp + +router = APIRouter(prefix="/auth", tags=["auth"]) + +PASSWORD_MIN_LENGTH = 8 + + +def validate_password(password: str) -> str: + """Validate password complexity. Returns error message or empty string.""" + if len(password) < PASSWORD_MIN_LENGTH: + return f"Password must be at least {PASSWORD_MIN_LENGTH} characters" + if not re.search(r"[A-Z]", password): + return "Password must contain at least one uppercase letter" + if not re.search(r"[0-9]", password): + return "Password must contain at least one number" + return "" + + +@router.post("/register", response_model=LoginResponse) +async def register(request: RegisterRequest): + """Register a new user account.""" + # Check if username already exists + existing = await db.users.find_one({"username": request.username}) + if existing: + raise HTTPException(status_code=409, detail="Username already taken") + + # Validate password + pw_error = validate_password(request.password) + if pw_error: + raise HTTPException(status_code=400, detail=pw_error) + + # Check invite code if provided (for org joining) + organization_id = None + if request.invite_code: + org = await db.organizations.find_one({"invite_code": request.invite_code}) + if not org: + raise HTTPException(status_code=400, detail="Invalid invite code") + organization_id = org["id"] + + # Create user + user = User( + username=request.username, + password_hash=hash_password(request.password), + email=request.email, + display_name=request.display_name or request.username, + organization_id=organization_id, + ) + doc = user.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.users.insert_one(doc) + + # Add to organization if invite code + if organization_id: + await db.organizations.update_one( + {"id": organization_id}, + {"$addToSet": {"member_ids": user.id}}, + ) + + token = create_token(user.id) + return LoginResponse( + token=token, + user={ + "id": user.id, + "username": user.username, + "display_name": user.display_name, + "role": user.role, + "created_at": user.created_at.isoformat(), + }, + ) + + +@router.post("/login", response_model=LoginResponse) +async def login(request: LoginRequest): + user_doc = await db.users.find_one({"username": request.username}, {"_id": 0}) + + if not user_doc or not verify_password(request.password, user_doc["password_hash"]): + raise HTTPException(status_code=401, detail="Invalid credentials") + + user = User(**user_doc) + token = create_token(user.id) + + # Update last_active and check streak + await award_xp(user.id, 0, check_streak=True) + + return LoginResponse( + token=token, + user={ + "id": user.id, + "username": user.username, + "display_name": user.display_name, + "role": user.role, + "organization_id": user.organization_id, + "xp": user.xp, + "level": user.level, + "badges": user.badges, + "streak_days": user.streak_days, + "theme": user.theme, + "created_at": user.created_at.isoformat(), + }, + ) + + +@router.get("/me") +async def get_me(current_user: User = Depends(get_current_user)): + return { + "id": current_user.id, + "username": current_user.username, + "display_name": current_user.display_name, + "email": current_user.email, + "role": current_user.role, + "organization_id": current_user.organization_id, + "xp": current_user.xp, + "level": current_user.level, + "badges": current_user.badges, + "streak_days": current_user.streak_days, + "theme": current_user.theme, + "notifications_enabled": current_user.notifications_enabled, + "created_at": current_user.created_at.isoformat(), + } + + +@router.put("/profile") +async def update_profile( + updates: ProfileUpdateRequest, current_user: User = Depends(get_current_user) +): + """Update user profile.""" + update_data = {k: v for k, v in updates.model_dump().items() if v is not None} + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + await db.users.update_one({"id": current_user.id}, {"$set": update_data}) + return {"message": "Profile updated"} + + +@router.post("/change-password") +async def change_password( + request: PasswordChangeRequest, current_user: User = Depends(get_current_user) +): + """Change user password.""" + if not verify_password(request.current_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + pw_error = validate_password(request.new_password) + if pw_error: + raise HTTPException(status_code=400, detail=pw_error) + + new_hash = hash_password(request.new_password) + await db.users.update_one({"id": current_user.id}, {"$set": {"password_hash": new_hash}}) + return {"message": "Password changed successfully"} diff --git a/backend/routes/campaigns.py b/backend/routes/campaigns.py new file mode 100644 index 0000000..e9755b2 --- /dev/null +++ b/backend/routes/campaigns.py @@ -0,0 +1,147 @@ +from typing import Any, Dict, List +from datetime import datetime, timezone +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User, Campaign, CampaignProgress +from services.auth import get_current_user +from services.database import db +from services.gamification import award_xp + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +@router.get("") +async def get_campaigns(current_user: User = Depends(get_current_user)): + """List all published campaigns.""" + campaigns = await db.campaigns.find( + {"is_published": True}, {"_id": 0} + ).to_list(100) + return campaigns + + +@router.get("/{campaign_id}") +async def get_campaign(campaign_id: str, current_user: User = Depends(get_current_user)): + """Get campaign details with user progress.""" + campaign = await db.campaigns.find_one({"id": campaign_id}, {"_id": 0}) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + progress = await db.campaign_progress.find_one( + {"campaign_id": campaign_id, "user_id": current_user.id}, {"_id": 0} + ) + + return {"campaign": campaign, "progress": progress} + + +@router.post("") +async def create_campaign(data: Dict[str, Any], current_user: User = Depends(get_current_user)): + """Create a new campaign (admin/instructor only).""" + if current_user.role not in ("admin", "instructor"): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + campaign = Campaign( + title=data["title"], + description=data.get("description", ""), + difficulty=data.get("difficulty", "medium"), + stages=data.get("stages", []), + cialdini_categories=data.get("cialdini_categories", []), + estimated_time=data.get("estimated_time", 30), + created_by=current_user.id, + is_published=data.get("is_published", False), + ) + doc = campaign.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.campaigns.insert_one(doc) + return {"id": campaign.id, "message": "Campaign created"} + + +@router.post("/{campaign_id}/start") +async def start_campaign(campaign_id: str, current_user: User = Depends(get_current_user)): + """Start a campaign.""" + campaign = await db.campaigns.find_one({"id": campaign_id}, {"_id": 0}) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + # Check if already in progress + existing = await db.campaign_progress.find_one( + {"campaign_id": campaign_id, "user_id": current_user.id, "status": "in_progress"} + ) + if existing: + return {"progress_id": existing["id"], "message": "Campaign already in progress"} + + progress = CampaignProgress( + campaign_id=campaign_id, + user_id=current_user.id, + ) + doc = progress.model_dump() + doc["started_at"] = doc["started_at"].isoformat() + await db.campaign_progress.insert_one(doc) + + return {"progress_id": progress.id, "message": "Campaign started", "first_stage": 0} + + +@router.post("/{campaign_id}/stage/{stage_index}/complete") +async def complete_stage( + campaign_id: str, + stage_index: int, + result: Dict[str, Any], + current_user: User = Depends(get_current_user), +): + """Complete a campaign stage.""" + progress = await db.campaign_progress.find_one( + {"campaign_id": campaign_id, "user_id": current_user.id, "status": "in_progress"}, + {"_id": 0}, + ) + if not progress: + raise HTTPException(status_code=404, detail="No active campaign progress") + + campaign = await db.campaigns.find_one({"id": campaign_id}, {"_id": 0}) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + # Add stage result + stage_result = { + "stage_index": stage_index, + "score": result.get("score", 0), + "completed_at": datetime.now(timezone.utc).isoformat(), + "events": result.get("events", []), + } + + stage_results = progress.get("stage_results", []) + stage_results.append(stage_result) + + # Check if campaign is complete + total_stages = len(campaign.get("stages", [])) + next_stage = stage_index + 1 + is_complete = next_stage >= total_stages + + updates = { + "stage_results": stage_results, + "current_stage": next_stage, + } + + if is_complete: + updates["status"] = "completed" + updates["completed_at"] = datetime.now(timezone.utc).isoformat() + # Calculate overall score + all_scores = [r.get("score", 0) for r in stage_results] + updates["overall_score"] = round(sum(all_scores) / len(all_scores), 1) if all_scores else 0 + + # Award XP for campaign completion + xp_earned = 100 + (updates["overall_score"] // 10) * 10 + await award_xp(current_user.id, int(xp_earned)) + else: + # Award XP per stage + await award_xp(current_user.id, 25) + + await db.campaign_progress.update_one( + {"id": progress["id"]}, + {"$set": updates}, + ) + + return { + "message": "Stage completed" if not is_complete else "Campaign completed!", + "next_stage": next_stage if not is_complete else None, + "is_complete": is_complete, + "overall_score": updates.get("overall_score"), + } diff --git a/backend/routes/certificates.py b/backend/routes/certificates.py new file mode 100644 index 0000000..1a9af5c --- /dev/null +++ b/backend/routes/certificates.py @@ -0,0 +1,98 @@ +import json +from datetime import datetime, timezone +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import JSONResponse + +from models.schemas import User +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/certificates", tags=["certificates"]) + + +@router.get("/{simulation_id}") +async def get_certificate_data( + simulation_id: str, current_user: User = Depends(get_current_user) +): + """Generate certificate data for a completed simulation.""" + sim = await db.simulations.find_one( + {"id": simulation_id, "status": "completed"}, {"_id": 0} + ) + if not sim: + raise HTTPException(status_code=404, detail="Completed simulation not found") + + score = sim.get("score", 0) or 0 + if score < 70: + raise HTTPException( + status_code=400, detail="Certificate requires a minimum score of 70%" + ) + + # Determine certification level + if score >= 95: + cert_level = "Platinum" + elif score >= 85: + cert_level = "Gold" + elif score >= 70: + cert_level = "Silver" + else: + cert_level = "Bronze" + + certificate = { + "certificate_id": f"CERT-{simulation_id[:8].upper()}", + "recipient": { + "name": current_user.display_name or current_user.username, + "username": current_user.username, + }, + "simulation": { + "title": sim.get("title", "Social Engineering Awareness"), + "type": sim.get("simulation_type", "simulation"), + "difficulty": sim.get("difficulty", "medium"), + "score": score, + }, + "certification": { + "level": cert_level, + "title": "Social Engineering Awareness", + "description": f"Has demonstrated {cert_level.lower()}-level proficiency in identifying and defending against social engineering attacks.", + }, + "issued_at": datetime.now(timezone.utc).isoformat(), + "issuer": "Pretexta - Social Engineering Simulation Lab", + "verification_url": f"/verify/{simulation_id[:8].upper()}", + } + + return certificate + + +@router.get("/user/all") +async def get_user_certificates(current_user: User = Depends(get_current_user)): + """Get all certificates for the current user.""" + sims = await db.simulations.find( + { + "user_id": current_user.id, + "status": "completed", + "score": {"$gte": 70}, + }, + {"_id": 0}, + ).to_list(100) + + certificates = [] + for sim in sims: + score = sim.get("score", 0) or 0 + if score >= 95: + level = "Platinum" + elif score >= 85: + level = "Gold" + elif score >= 70: + level = "Silver" + else: + continue + + certificates.append({ + "certificate_id": f"CERT-{sim['id'][:8].upper()}", + "simulation_id": sim["id"], + "title": sim.get("title", "Social Engineering Awareness"), + "score": score, + "level": level, + "completed_at": sim.get("completed_at", sim.get("started_at")), + }) + + return certificates diff --git a/backend/routes/challenges.py b/backend/routes/challenges.py new file mode 100644 index 0000000..9554db2 --- /dev/null +++ b/backend/routes/challenges.py @@ -0,0 +1,30 @@ +from typing import List +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User, Challenge +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/challenges", tags=["challenges"]) + + +@router.get("", response_model=List[Challenge]) +async def get_challenges(current_user: User = Depends(get_current_user)): + challenges = await db.challenges.find({}, {"_id": 0}).to_list(1000) + return challenges + + +@router.get("/{challenge_id}", response_model=Challenge) +async def get_challenge(challenge_id: str, current_user: User = Depends(get_current_user)): + challenge = await db.challenges.find_one({"id": challenge_id}, {"_id": 0}) + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + return challenge + + +@router.post("", response_model=Challenge) +async def create_challenge(challenge: Challenge, current_user: User = Depends(get_current_user)): + doc = challenge.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.challenges.insert_one(doc) + return challenge diff --git a/backend/routes/debrief.py b/backend/routes/debrief.py new file mode 100644 index 0000000..1a35219 --- /dev/null +++ b/backend/routes/debrief.py @@ -0,0 +1,159 @@ +import logging +from typing import Any, Dict +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User +from services.auth import get_current_user +from services.database import db +from services.llm import get_llm_generate_model + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/debrief", tags=["debrief"]) + +CIALDINI_DESCRIPTIONS = { + "reciprocity": "The attacker gave you something (a favor, information) to create a sense of obligation.", + "scarcity": "Urgency or limited availability was used to pressure you into acting without thinking.", + "authority": "The attacker impersonated someone in a position of power to override your judgment.", + "commitment": "Small initial compliance was used to build toward larger, riskier requests.", + "liking": "The attacker built rapport or familiarity to lower your defenses.", + "social_proof": "References to what 'others are doing' were used to normalize the request.", +} + + +@router.get("/{simulation_id}") +async def get_debrief(simulation_id: str, current_user: User = Depends(get_current_user)): + """Get or generate post-simulation debrief analysis.""" + sim = await db.simulations.find_one({"id": simulation_id}, {"_id": 0}) + if not sim: + raise HTTPException(status_code=404, detail="Simulation not found") + + # Return cached debrief if exists + if sim.get("debrief"): + return sim["debrief"] + + # Generate debrief + debrief = await _generate_debrief(sim) + + # Cache it + await db.simulations.update_one( + {"id": simulation_id}, + {"$set": {"debrief": debrief}}, + ) + + return debrief + + +async def _generate_debrief(sim: Dict[str, Any]) -> Dict[str, Any]: + """Generate a detailed debrief analysis.""" + events = sim.get("events", []) + score = sim.get("score", 0) or 0 + sim_type = sim.get("simulation_type", sim.get("type", "unknown")) + categories = sim.get("challenge_data", {}).get("cialdini_categories", []) + + # Key moments analysis + key_moments = [] + for i, event in enumerate(events): + if event.get("action") in ("complied", "clicked", "shared_info", "refused", "reported"): + key_moments.append({ + "index": i, + "action": event.get("action"), + "was_correct": event.get("action") in ("refused", "reported"), + "description": event.get("description", ""), + "tip": _get_tip_for_action(event.get("action", "")), + }) + + # Cialdini analysis + cialdini_analysis = [] + for cat in categories: + cialdini_analysis.append({ + "principle": cat, + "description": CIALDINI_DESCRIPTIONS.get(cat, ""), + "was_used": True, + }) + + # Performance rating + if score >= 90: + rating = "excellent" + summary = "You demonstrated strong awareness and successfully identified the attack vectors." + elif score >= 70: + rating = "good" + summary = "Good performance. You caught most red flags but had some vulnerable moments." + elif score >= 50: + rating = "fair" + summary = "Mixed results. You fell for some manipulation techniques. Review the key moments below." + else: + rating = "needs_improvement" + summary = "The attacker was able to exploit psychological vulnerabilities. Focus on the tips below." + + # Recommendations + recommendations = [] + if score < 70: + recommendations.append("Always verify identity through a separate, trusted channel before complying with requests.") + if "authority" in categories: + recommendations.append("Question authority-based requests. Legitimate leaders rarely bypass established procedures.") + if "scarcity" in categories: + recommendations.append("Be suspicious of artificial urgency. Take time to verify before acting.") + if "reciprocity" in categories: + recommendations.append("Unsolicited favors may be manipulation. Don't feel obligated to reciprocate.") + if score >= 80: + recommendations.append("Great defense! Try harder difficulty scenarios to keep improving.") + + return { + "simulation_id": sim.get("id"), + "title": sim.get("title", "Untitled"), + "score": score, + "rating": rating, + "summary": summary, + "cialdini_analysis": cialdini_analysis, + "key_moments": key_moments, + "recommendations": recommendations, + "total_events": len(events), + "correct_actions": sum(1 for m in key_moments if m.get("was_correct")), + "incorrect_actions": sum(1 for m in key_moments if not m.get("was_correct")), + } + + +def _get_tip_for_action(action: str) -> str: + tips = { + "complied": "You complied with a suspicious request. Always verify through official channels.", + "clicked": "You clicked a potentially malicious link. Hover over links to check URLs first.", + "shared_info": "You shared sensitive information. Never share credentials or personal data over unverified channels.", + "refused": "Good call! Refusing suspicious requests is the right approach.", + "reported": "Excellent! Reporting suspicious activity helps protect the entire organization.", + } + return tips.get(action, "") + + +@router.post("/{simulation_id}/ai-analysis") +async def get_ai_debrief(simulation_id: str, current_user: User = Depends(get_current_user)): + """Generate AI-powered deep analysis of a simulation (requires LLM config).""" + sim = await db.simulations.find_one({"id": simulation_id}, {"_id": 0}) + if not sim: + raise HTTPException(status_code=404, detail="Simulation not found") + + config = await db.llm_configs.find_one({"enabled": True}, {"_id": 0}) + if not config: + raise HTTPException(status_code=400, detail="LLM not configured") + + prompt = f"""Analyze this social engineering simulation result and provide educational feedback. + +Simulation: {sim.get('title', 'Unknown')} +Score: {sim.get('score', 0)} +Type: {sim.get('simulation_type', 'unknown')} +Events: {sim.get('events', [])} + +Provide: +1. What manipulation techniques were used +2. Where the user was most vulnerable +3. Specific, actionable tips for improvement +4. A psychological explanation of why these techniques work + +Keep the response educational and constructive. Format as JSON with keys: techniques, vulnerabilities, tips, psychology.""" + + try: + response = await get_llm_generate_model(config, prompt, {}) + return {"ai_analysis": response.content, "simulation_id": simulation_id} + except Exception as e: + logger.error(f"AI debrief failed: {e}") + raise HTTPException(status_code=500, detail="AI analysis failed") diff --git a/backend/routes/imports.py b/backend/routes/imports.py new file mode 100644 index 0000000..734a9fd --- /dev/null +++ b/backend/routes/imports.py @@ -0,0 +1,40 @@ +from typing import Any, Dict +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User, Challenge, Quiz +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/import", tags=["import"]) + + +@router.post("/yaml") +async def import_yaml_file( + file_content: Dict[str, Any], current_user: User = Depends(get_current_user) +): + """Import YAML challenge or quiz.""" + try: + yaml_type = file_content.get("type") + data = file_content.get("data") + + if yaml_type == "challenge": + challenge = Challenge(**data) + doc = challenge.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.challenges.insert_one(doc) + return {"message": "Challenge imported", "id": challenge.id} + + elif yaml_type == "quiz": + quiz = Quiz(**data) + doc = quiz.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.quizzes.insert_one(doc) + return {"message": "Quiz imported", "id": quiz.id} + + else: + raise HTTPException(status_code=400, detail="Unknown YAML type") + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=f"Import failed: {str(e)}") diff --git a/backend/routes/leaderboard.py b/backend/routes/leaderboard.py new file mode 100644 index 0000000..42c305c --- /dev/null +++ b/backend/routes/leaderboard.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, Query +from typing import Optional + +from models.schemas import User +from services.auth import get_current_user +from services.database import db +from services.gamification import BADGE_DEFINITIONS, xp_for_next_level + +router = APIRouter(prefix="/leaderboard", tags=["leaderboard"]) + + +@router.get("") +async def get_leaderboard( + scope: str = Query("global", regex="^(global|organization)$"), + limit: int = Query(50, le=100), + current_user: User = Depends(get_current_user), +): + """Get leaderboard rankings.""" + query = {} + if scope == "organization" and current_user.organization_id: + query["organization_id"] = current_user.organization_id + + users = await db.users.find( + query, + {"_id": 0, "password_hash": 0}, + ).sort("xp", -1).to_list(limit) + + leaderboard = [] + for rank, user in enumerate(users, 1): + # Count completed simulations + sim_count = await db.simulations.count_documents( + {"user_id": user["id"], "status": "completed"} + ) + + leaderboard.append({ + "rank": rank, + "user_id": user["id"], + "username": user.get("username", ""), + "display_name": user.get("display_name", user.get("username", "")), + "xp": user.get("xp", 0), + "level": user.get("level", 1), + "badges_count": len(user.get("badges", [])), + "streak_days": user.get("streak_days", 0), + "simulations_completed": sim_count, + "is_current_user": user["id"] == current_user.id, + }) + + return leaderboard + + +@router.get("/me") +async def get_my_rank(current_user: User = Depends(get_current_user)): + """Get current user's rank and XP progress.""" + # Count users with more XP + higher_xp_count = await db.users.count_documents({"xp": {"$gt": current_user.xp}}) + rank = higher_xp_count + 1 + total_users = await db.users.count_documents({}) + + sim_count = await db.simulations.count_documents( + {"user_id": current_user.id, "status": "completed"} + ) + + return { + "rank": rank, + "total_users": total_users, + "xp_progress": xp_for_next_level(current_user.xp), + "badges": current_user.badges, + "streak_days": current_user.streak_days, + "simulations_completed": sim_count, + } + + +@router.get("/badges") +async def get_all_badges(current_user: User = Depends(get_current_user)): + """Get all available badges with earned status.""" + result = [] + for badge in BADGE_DEFINITIONS: + result.append({ + **badge, + "earned": badge["id"] in current_user.badges, + }) + return result diff --git a/backend/routes/llm.py b/backend/routes/llm.py new file mode 100644 index 0000000..7a9f0ea --- /dev/null +++ b/backend/routes/llm.py @@ -0,0 +1,144 @@ +import logging +from typing import Any, Dict +from datetime import datetime, timezone +from fastapi import APIRouter, HTTPException, Depends + +from langchain_core.messages import HumanMessage, SystemMessage, AIMessage + +from models.schemas import User, LLMConfig +from services.auth import get_current_user +from services.llm import get_llm_generate_model, get_llm_chat_model, repair_json +from services.database import db + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/llm", tags=["llm"]) + + +@router.get("/config") +async def get_llm_configs(current_user: User = Depends(get_current_user)): + configs = await db.llm_configs.find({}, {"_id": 0}).to_list(100) + active_configs = [] + for config in configs: + if not config.get("api_key") or config.get("api_key") == "": + continue + config["api_key"] = "***" + config["updated_at"] = config.get("updated_at", datetime.now(timezone.utc).isoformat()) + active_configs.append(config) + return active_configs + + +@router.post("/config") +async def save_llm_config(config: LLMConfig, current_user: User = Depends(get_current_user)): + doc = config.model_dump() + doc["updated_at"] = doc["updated_at"].isoformat() + + if not config.api_key or config.api_key == "": + await db.llm_configs.delete_one({"provider": config.provider}) + return {"message": "LLM config deleted"} + + await db.llm_configs.update_one( + {"provider": config.provider}, + {"$set": doc}, + upsert=True, + ) + + return {"message": "LLM config saved"} + + +@router.post("/generate") +async def generate_pretext(request: Dict[str, Any], current_user: User = Depends(get_current_user)): + """Generate pretext using LLM.""" + requested_provider = request.get("provider", None) + prompt = request.get("prompt", "") + context = request.get("context", {}) + + if requested_provider: + config = await db.llm_configs.find_one( + {"provider": requested_provider, "enabled": True}, {"_id": 0} + ) + else: + config = await db.llm_configs.find_one({"enabled": True}, {"_id": 0}) + + if not config: + raise HTTPException( + status_code=400, + detail="LLM provider not configured or not enabled. Please configure in Settings.", + ) + + try: + response = await get_llm_generate_model(config, prompt, context) + sanitized = repair_json(response.content) + return {"generated_text": sanitized, "provider": config["provider"]} + + except Exception as e: + logger.error(f"LLM generation failed: {e}") + error_msg = str(e) + if "NOT_FOUND" in error_msg: + error_msg = ( + "Model not found. Your API Key might not support the selected model, " + "or the region is restricted." + ) + raise HTTPException(status_code=500, detail=f"LLM Generation Error: {error_msg}") + + +@router.post("/chat") +async def chat_interaction(request: Dict[str, Any], current_user: User = Depends(get_current_user)): + """Real-time Chat Interaction for Roleplay.""" + history = request.get("history", []) + persona = request.get("persona", {}) + user_message = request.get("message", "") + + config = await db.llm_configs.find_one({"enabled": True}, {"_id": 0}) + if not config: + raise HTTPException(status_code=400, detail="LLM config missing") + + system_prompt = f"""You are a roleplay actor in a cybersecurity simulation. + Role: {persona.get('name', 'Attacker')} + Goal: {persona.get('goal', 'Trick the user')} + Personality: {persona.get('style', 'Manipulative')} + Context: {persona.get('context', 'Corporate Environment')} + + INSTRUCTIONS: + 1. Respond naturally as your character. Short, realistic messages (whatsapp/email style). + 2. Do NOT break character. + 3. If the user successfully spots the attack or refuses securely, react accordingly (e.g. get angry, give up, or try a different angle). + 4. If the user FAILS (gives password, clicks link), output a special marker in your text: [SUCCESS_ATTACK]. + 5. If the user permanently BLOCKS the attack, output: [ATTACK_FAILED]. + """ + + messages = [SystemMessage(content=system_prompt)] + + for msg in history: + if msg["role"] == "user": + messages.append(HumanMessage(content=msg["content"])) + elif msg["role"] == "assistant": + messages.append(AIMessage(content=msg["content"])) + + messages.append(HumanMessage(content=user_message)) + + try: + response = await get_llm_chat_model(config, messages) + content = response.content + + status = "ongoing" + if "[SUCCESS_ATTACK]" in content: + status = "failed" + content = content.replace("[SUCCESS_ATTACK]", "") + elif "[ATTACK_FAILED]" in content: + status = "completed" + content = content.replace("[ATTACK_FAILED]", "") + + return {"role": "assistant", "content": content, "status": status} + + except Exception as e: + logger.error(f"Chat error: {e}") + error_msg = str(e) + provider = config["provider"] + if "401" in error_msg: + error_msg = f"Unauthorized. Please check your API Key for {provider}." + elif "404" in error_msg: + error_msg = f"Model Not Found. Provider: {provider}." + elif "429" in error_msg: + error_msg = f"Rate Limit Exceeded. Please try again later. Provider: {provider}." + raise HTTPException(status_code=500, detail=error_msg) diff --git a/backend/routes/notifications.py b/backend/routes/notifications.py new file mode 100644 index 0000000..fee6ecc --- /dev/null +++ b/backend/routes/notifications.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, Query + +from models.schemas import User +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +@router.get("") +async def get_notifications( + unread_only: bool = Query(False), + limit: int = Query(50, le=100), + current_user: User = Depends(get_current_user), +): + """Get user notifications.""" + query = {"user_id": current_user.id} + if unread_only: + query["read"] = False + + notifications = await db.notifications.find( + query, {"_id": 0} + ).sort("created_at", -1).to_list(limit) + + unread_count = await db.notifications.count_documents( + {"user_id": current_user.id, "read": False} + ) + + return {"notifications": notifications, "unread_count": unread_count} + + +@router.put("/{notification_id}/read") +async def mark_read(notification_id: str, current_user: User = Depends(get_current_user)): + """Mark a notification as read.""" + await db.notifications.update_one( + {"id": notification_id, "user_id": current_user.id}, + {"$set": {"read": True}}, + ) + return {"message": "Marked as read"} + + +@router.put("/read-all") +async def mark_all_read(current_user: User = Depends(get_current_user)): + """Mark all notifications as read.""" + await db.notifications.update_many( + {"user_id": current_user.id, "read": False}, + {"$set": {"read": True}}, + ) + return {"message": "All notifications marked as read"} diff --git a/backend/routes/organizations.py b/backend/routes/organizations.py new file mode 100644 index 0000000..cd5d18b --- /dev/null +++ b/backend/routes/organizations.py @@ -0,0 +1,128 @@ +from fastapi import APIRouter, HTTPException, Depends +from typing import Dict, Any + +from models.schemas import User, Organization +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/organizations", tags=["organizations"]) + + +@router.post("") +async def create_organization(data: Dict[str, Any], current_user: User = Depends(get_current_user)): + """Create a new organization.""" + org = Organization( + name=data["name"], + description=data.get("description", ""), + owner_id=current_user.id, + member_ids=[current_user.id], + ) + doc = org.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.organizations.insert_one(doc) + + # Update user's org + await db.users.update_one( + {"id": current_user.id}, + {"$set": {"organization_id": org.id, "role": "admin"}}, + ) + + # Award team player badge + if "team_player" not in current_user.badges: + from services.gamification import award_xp + await db.users.update_one( + {"id": current_user.id}, {"$addToSet": {"badges": "team_player"}} + ) + await award_xp(current_user.id, 50) + + return {"id": org.id, "invite_code": org.invite_code, "message": "Organization created"} + + +@router.get("/mine") +async def get_my_organization(current_user: User = Depends(get_current_user)): + """Get current user's organization.""" + if not current_user.organization_id: + return None + + org = await db.organizations.find_one( + {"id": current_user.organization_id}, {"_id": 0} + ) + if not org: + return None + + # Get member details + members = await db.users.find( + {"id": {"$in": org.get("member_ids", [])}}, + {"_id": 0, "password_hash": 0}, + ).to_list(100) + + org["members"] = [ + { + "id": m["id"], + "username": m.get("username"), + "display_name": m.get("display_name"), + "role": m.get("role", "trainee"), + "level": m.get("level", 1), + "xp": m.get("xp", 0), + } + for m in members + ] + + return org + + +@router.post("/join") +async def join_organization( + data: Dict[str, Any], current_user: User = Depends(get_current_user) +): + """Join an organization via invite code.""" + invite_code = data.get("invite_code", "") + org = await db.organizations.find_one({"invite_code": invite_code}, {"_id": 0}) + if not org: + raise HTTPException(status_code=404, detail="Invalid invite code") + + if current_user.id in org.get("member_ids", []): + raise HTTPException(status_code=400, detail="Already a member") + + await db.organizations.update_one( + {"id": org["id"]}, + {"$addToSet": {"member_ids": current_user.id}}, + ) + await db.users.update_one( + {"id": current_user.id}, + {"$set": {"organization_id": org["id"]}}, + ) + + # Award badge + if "team_player" not in current_user.badges: + from services.gamification import award_xp + await db.users.update_one( + {"id": current_user.id}, {"$addToSet": {"badges": "team_player"}} + ) + await award_xp(current_user.id, 50) + + return {"message": f"Joined {org['name']}", "organization_id": org["id"]} + + +@router.delete("/leave") +async def leave_organization(current_user: User = Depends(get_current_user)): + """Leave current organization.""" + if not current_user.organization_id: + raise HTTPException(status_code=400, detail="Not in an organization") + + org = await db.organizations.find_one( + {"id": current_user.organization_id}, {"_id": 0} + ) + if org and org.get("owner_id") == current_user.id: + raise HTTPException(status_code=400, detail="Owner cannot leave. Transfer ownership first.") + + await db.organizations.update_one( + {"id": current_user.organization_id}, + {"$pull": {"member_ids": current_user.id}}, + ) + await db.users.update_one( + {"id": current_user.id}, + {"$set": {"organization_id": None}}, + ) + + return {"message": "Left organization"} diff --git a/backend/routes/quizzes.py b/backend/routes/quizzes.py new file mode 100644 index 0000000..a6507ae --- /dev/null +++ b/backend/routes/quizzes.py @@ -0,0 +1,22 @@ +from typing import List +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User, Quiz +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/quizzes", tags=["quizzes"]) + + +@router.get("", response_model=List[Quiz]) +async def get_quizzes(current_user: User = Depends(get_current_user)): + quizzes = await db.quizzes.find({}, {"_id": 0}).to_list(1000) + return quizzes + + +@router.get("/{quiz_id}", response_model=Quiz) +async def get_quiz(quiz_id: str, current_user: User = Depends(get_current_user)): + quiz = await db.quizzes.find_one({"id": quiz_id}, {"_id": 0}) + if not quiz: + raise HTTPException(status_code=404, detail="Quiz not found") + return quiz diff --git a/backend/routes/reports.py b/backend/routes/reports.py new file mode 100644 index 0000000..2419961 --- /dev/null +++ b/backend/routes/reports.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User +from services.auth import get_current_user +from services.scoring import calculate_susceptibility_score +from services.database import db + +router = APIRouter(prefix="/reports", tags=["reports"]) + + +@router.get("/{simulation_id}/json") +async def get_report_json(simulation_id: str, current_user: User = Depends(get_current_user)): + sim = await db.simulations.find_one({"id": simulation_id}, {"_id": 0}) + if not sim: + raise HTTPException(status_code=404, detail="Simulation not found") + + score_data = calculate_susceptibility_score(sim) + + return { + "simulation_id": simulation_id, + "score": score_data, + "events": sim.get("events", []), + "started_at": sim.get("started_at"), + "completed_at": sim.get("completed_at"), + "participant_name": sim.get("participant_name"), + } diff --git a/backend/routes/scenario_builder.py b/backend/routes/scenario_builder.py new file mode 100644 index 0000000..dc36d7e --- /dev/null +++ b/backend/routes/scenario_builder.py @@ -0,0 +1,128 @@ +from typing import Any, Dict +from datetime import datetime, timezone +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User, ScenarioTemplate, Challenge +from services.auth import get_current_user +from services.database import db +from services.gamification import award_xp + +router = APIRouter(prefix="/scenario-builder", tags=["scenario-builder"]) + + +@router.get("/templates") +async def get_my_templates(current_user: User = Depends(get_current_user)): + """Get user's scenario templates (drafts and published).""" + templates = await db.scenario_templates.find( + {"created_by": current_user.id}, {"_id": 0} + ).sort("updated_at", -1).to_list(100) + return templates + + +@router.get("/templates/{template_id}") +async def get_template(template_id: str, current_user: User = Depends(get_current_user)): + """Get a specific template.""" + template = await db.scenario_templates.find_one( + {"id": template_id}, {"_id": 0} + ) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + return template + + +@router.post("/templates") +async def create_template(data: Dict[str, Any], current_user: User = Depends(get_current_user)): + """Create a new scenario template (draft).""" + template = ScenarioTemplate( + title=data.get("title", "Untitled Scenario"), + description=data.get("description", ""), + difficulty=data.get("difficulty", "medium"), + cialdini_categories=data.get("cialdini_categories", []), + channel=data.get("channel", "email_inbox"), + nodes=data.get("nodes", []), + metadata=data.get("metadata", {}), + content_en=data.get("content_en"), + content_id=data.get("content_id"), + created_by=current_user.id, + ) + doc = template.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + doc["updated_at"] = doc["updated_at"].isoformat() + await db.scenario_templates.insert_one(doc) + return {"id": template.id, "message": "Template created"} + + +@router.put("/templates/{template_id}") +async def update_template( + template_id: str, data: Dict[str, Any], current_user: User = Depends(get_current_user) +): + """Update a scenario template.""" + existing = await db.scenario_templates.find_one( + {"id": template_id, "created_by": current_user.id} + ) + if not existing: + raise HTTPException(status_code=404, detail="Template not found") + + data["updated_at"] = datetime.now(timezone.utc).isoformat() + await db.scenario_templates.update_one({"id": template_id}, {"$set": data}) + return {"message": "Template updated"} + + +@router.delete("/templates/{template_id}") +async def delete_template(template_id: str, current_user: User = Depends(get_current_user)): + """Delete a scenario template.""" + result = await db.scenario_templates.delete_one( + {"id": template_id, "created_by": current_user.id} + ) + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Template not found") + return {"message": "Template deleted"} + + +@router.post("/templates/{template_id}/publish") +async def publish_template(template_id: str, current_user: User = Depends(get_current_user)): + """Publish a template as a playable challenge.""" + template = await db.scenario_templates.find_one( + {"id": template_id, "created_by": current_user.id}, {"_id": 0} + ) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + if not template.get("nodes") or len(template["nodes"]) < 2: + raise HTTPException(status_code=400, detail="Scenario must have at least 2 nodes") + + # Create a challenge from the template + challenge = Challenge( + title=template["title"], + description=template["description"], + difficulty=template["difficulty"], + cialdini_categories=template.get("cialdini_categories", []), + estimated_time=len(template.get("nodes", [])) * 2, + nodes=template["nodes"], + metadata={ + **template.get("metadata", {}), + "author": current_user.username, + "source": "scenario_builder", + "template_id": template_id, + }, + content_en=template.get("content_en"), + content_id=template.get("content_id"), + ) + doc = challenge.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.challenges.insert_one(doc) + + # Mark template as published + await db.scenario_templates.update_one( + {"id": template_id}, + {"$set": {"is_published": True, "is_draft": False}}, + ) + + # Award badge + if "scenario_creator" not in current_user.badges: + await db.users.update_one( + {"id": current_user.id}, {"$addToSet": {"badges": "scenario_creator"}} + ) + await award_xp(current_user.id, 200) + + return {"challenge_id": challenge.id, "message": "Scenario published as challenge!"} diff --git a/backend/routes/settings.py b/backend/routes/settings.py new file mode 100644 index 0000000..f59c112 --- /dev/null +++ b/backend/routes/settings.py @@ -0,0 +1,27 @@ +from typing import Any, Dict +from fastapi import APIRouter, Depends + +from models.schemas import User, Settings +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +@router.get("", response_model=Settings) +async def get_settings(current_user: User = Depends(get_current_user)): + settings = await db.settings.find_one({"id": "settings"}, {"_id": 0}) + if not settings: + settings = Settings().model_dump() + await db.settings.insert_one(settings) + return settings + + +@router.put("") +async def update_settings(updates: Dict[str, Any], current_user: User = Depends(get_current_user)): + await db.settings.update_one( + {"id": "settings"}, + {"$set": updates}, + upsert=True, + ) + return {"message": "Settings updated"} diff --git a/backend/routes/simulations.py b/backend/routes/simulations.py new file mode 100644 index 0000000..4605dc6 --- /dev/null +++ b/backend/routes/simulations.py @@ -0,0 +1,58 @@ +from typing import Any, Dict, List +from datetime import datetime, timezone +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User, Simulation +from services.auth import get_current_user +from services.database import db + +router = APIRouter(prefix="/simulations", tags=["simulations"]) + + +@router.post("") +async def create_simulation(simulation: Simulation, current_user: User = Depends(get_current_user)): + doc = simulation.model_dump() + doc["started_at"] = doc["started_at"].isoformat() + if doc.get("completed_at"): + doc["completed_at"] = doc["completed_at"].isoformat() + await db.simulations.insert_one(doc) + return {"id": simulation.id, "status": "created"} + + +@router.get("", response_model=List[Simulation]) +async def get_simulations(current_user: User = Depends(get_current_user)): + sims = await db.simulations.find({}, {"_id": 0}).sort("started_at", -1).to_list(100) + return sims + + +@router.get("/{simulation_id}", response_model=Simulation) +async def get_simulation(simulation_id: str, current_user: User = Depends(get_current_user)): + sim = await db.simulations.find_one({"id": simulation_id}, {"_id": 0}) + if not sim: + raise HTTPException(status_code=404, detail="Simulation not found") + return sim + + +@router.put("/{simulation_id}") +async def update_simulation( + simulation_id: str, updates: Dict[str, Any], current_user: User = Depends(get_current_user) +): + if updates.get("completed_at"): + updates["completed_at"] = datetime.now(timezone.utc).isoformat() + + result = await db.simulations.update_one({"id": simulation_id}, {"$set": updates}) + + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Simulation not found") + + return {"message": "Simulation updated"} + + +@router.delete("/{simulation_id}") +async def delete_simulation(simulation_id: str, current_user: User = Depends(get_current_user)): + result = await db.simulations.delete_one({"id": simulation_id}) + + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Simulation not found") + + return {"message": "Simulation deleted successfully"} diff --git a/backend/routes/webhooks.py b/backend/routes/webhooks.py new file mode 100644 index 0000000..cbe681e --- /dev/null +++ b/backend/routes/webhooks.py @@ -0,0 +1,82 @@ +import logging +import httpx +from typing import Any, Dict, List +from fastapi import APIRouter, HTTPException, Depends + +from models.schemas import User, WebhookConfig +from services.auth import get_current_user +from services.database import db + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/webhooks", tags=["webhooks"]) + + +@router.get("") +async def get_webhooks(current_user: User = Depends(get_current_user)): + """List configured webhooks.""" + if current_user.role not in ("admin", "instructor"): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + query = {} + if current_user.organization_id: + query["organization_id"] = current_user.organization_id + + webhooks = await db.webhooks.find(query, {"_id": 0}).to_list(50) + # Mask secrets + for wh in webhooks: + if wh.get("secret"): + wh["secret"] = "***" + return webhooks + + +@router.post("") +async def create_webhook(data: Dict[str, Any], current_user: User = Depends(get_current_user)): + """Create a webhook configuration.""" + if current_user.role not in ("admin", "instructor"): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + webhook = WebhookConfig( + name=data["name"], + url=data["url"], + events=data.get("events", ["simulation_complete"]), + secret=data.get("secret"), + organization_id=current_user.organization_id, + ) + doc = webhook.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.webhooks.insert_one(doc) + return {"id": webhook.id, "message": "Webhook created"} + + +@router.delete("/{webhook_id}") +async def delete_webhook(webhook_id: str, current_user: User = Depends(get_current_user)): + """Delete a webhook.""" + if current_user.role not in ("admin", "instructor"): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + result = await db.webhooks.delete_one({"id": webhook_id}) + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Webhook not found") + return {"message": "Webhook deleted"} + + +async def fire_webhooks(event: str, payload: Dict[str, Any], organization_id: str = None): + """Fire all matching webhooks for an event.""" + query = {"enabled": True, "events": event} + if organization_id: + query["organization_id"] = organization_id + + webhooks = await db.webhooks.find(query, {"_id": 0}).to_list(50) + + for wh in webhooks: + try: + async with httpx.AsyncClient(timeout=10) as client: + headers = {"Content-Type": "application/json"} + if wh.get("secret"): + headers["X-Webhook-Secret"] = wh["secret"] + + await client.post(wh["url"], json={"event": event, "data": payload}, headers=headers) + logger.info(f"Webhook fired: {wh['name']} -> {event}") + except Exception as e: + logger.error(f"Webhook failed: {wh['name']} -> {e}") diff --git a/backend/server.py b/backend/server.py index 087d9d6..844dc60 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,708 +1,135 @@ -from fastapi import FastAPI, APIRouter, HTTPException, Depends, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from fastapi.responses import FileResponse, StreamingResponse -from dotenv import load_dotenv -from starlette.middleware.cors import CORSMiddleware -from motor.motor_asyncio import AsyncIOMotorClient import os import logging -from pathlib import Path -from pydantic import BaseModel, Field, ConfigDict -from typing import List, Optional, Dict, Any -import uuid -from datetime import datetime, timezone, timedelta -import bcrypt -import jwt -import yaml -import re -from io import BytesIO -import json - -# ROOT_DIR = Path(__file__).parent -load_dotenv() - -# MongoDB connection -mongo_url = os.environ['MONGO_URL'] -client = AsyncIOMotorClient(mongo_url) -db = client[os.environ['DB_NAME']] - -# Create the main app without a prefix -app = FastAPI(title="Pretexta API") +from contextlib import asynccontextmanager -# Create a router with the /api prefix -api_router = APIRouter(prefix="/api") - -# JWT Configuration -JWT_SECRET = os.environ.get('JWT_SECRET', 'soceng-lab-secret-key-change-in-production') -JWT_ALGORITHM = "HS256" -JWT_EXPIRATION_HOURS = 24 +from fastapi import FastAPI, APIRouter +from starlette.middleware.cors import CORSMiddleware -security = HTTPBearer() +from services.database import db +from services.auth import hash_password +from models.schemas import User +from middleware.rate_limit import RateLimitMiddleware +from routes import ( + auth_router, + challenges_router, + quizzes_router, + simulations_router, + llm_router, + settings_router, + imports_router, + reports_router, + leaderboard_router, + analytics_router, + organizations_router, + campaigns_router, + notifications_router, + webhooks_router, + scenario_builder_router, + debrief_router, + certificates_router, + adaptive_router, +) # Configure logging logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) -# ==================== MODELS ==================== -class User(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - username: str - password_hash: str - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - is_active: bool = True - -class LoginRequest(BaseModel): - username: str - password: str - -class LoginResponse(BaseModel): - token: str - user: Dict[str, Any] - -class Challenge(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - title: str - description: str - difficulty: str # easy, medium, hard - cialdini_categories: List[str] - estimated_time: int # minutes - nodes: List[Dict[str, Any]] - metadata: Dict[str, Any] = Field(default_factory=dict) - content_en: Optional[Dict[str, Any]] = None - content_id: Optional[Dict[str, Any]] = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - -class Quiz(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - title: str - description: str - difficulty: str - cialdini_categories: List[str] - questions: List[Dict[str, Any]] - content_en: Optional[Dict[str, Any]] = None - content_id: Optional[Dict[str, Any]] = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - -class Simulation(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - challenge_id: Optional[str] = None - quiz_id: Optional[str] = None - simulation_type: str # challenge, quiz, ai_challenge - status: str # running, completed, paused - events: List[Dict[str, Any]] = Field(default_factory=list) - score: Optional[float] = None - started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - completed_at: Optional[datetime] = None - participant_name: Optional[str] = None - title: Optional[str] = None # Added for log display - - # AI Challenge specific fields - type: Optional[str] = None # For backwards compatibility, same as simulation_type - challenge_type: Optional[str] = None # comprehensive, email_analysis, interactive, scenario - category: Optional[str] = None # phishing, pretexting, baiting, etc. - difficulty: Optional[str] = None # beginner, intermediate, advanced - total_questions: Optional[int] = None - correct_answers: Optional[int] = None - answers: Optional[Dict[str, Any]] = None - challenge_data: Optional[Dict[str, Any]] = None +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application startup and shutdown events.""" + # Startup: seed default admin user + existing_user = await db.users.find_one({"username": "soceng"}) + if not existing_user: + seed_user = User( + username="soceng", + password_hash=hash_password("Cialdini@2025!"), + display_name="Admin", + role="admin", + ) + doc = seed_user.model_dump() + doc["created_at"] = doc["created_at"].isoformat() + await db.users.insert_one(doc) + logger.info("Seed admin user created: soceng") -class LLMConfig(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - provider: str # openai, gemini, claude, generic - api_key: str # encrypted - model_name: Optional[str] = None - enabled: bool = False - rate_limit: int = 100 # per hour - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + # Ensure indexes + await db.users.create_index("username", unique=True) + await db.organizations.create_index("invite_code", unique=True, sparse=True) + await db.notifications.create_index([("user_id", 1), ("read", 1)]) + await db.simulations.create_index([("user_id", 1), ("status", 1)]) + await db.campaign_progress.create_index([("campaign_id", 1), ("user_id", 1)]) -class Settings(BaseModel): - model_config = ConfigDict(extra="ignore") - id: str = "settings" - language: str = "en" - theme: str = "dark" - first_run_completed: bool = False - llm_enabled: bool = False - reduce_motion: bool = False + # Warn about default JWT secret + jwt_secret = os.environ.get("JWT_SECRET", "") + if not jwt_secret or jwt_secret == "change-this-secret-key-in-production": + logger.warning("WARNING: Using default JWT secret. Set JWT_SECRET env var in production!") -# ==================== AUTH HELPERS ==================== + yield -def hash_password(password: str) -> str: - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + # Shutdown + from services.database import client + client.close() -def verify_password(password: str, password_hash: str) -> bool: - return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8')) -def create_token(user_id: str) -> str: - expiration = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRATION_HOURS) - payload = { - "user_id": user_id, - "exp": expiration - } - return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) +# Create the app +app = FastAPI( + title="Pretexta API", + description="Social Engineering Simulation Lab API", + version="2.0.0", + lifespan=lifespan, +) -async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> User: - try: - token = credentials.credentials - payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - user_id = payload.get("user_id") - - user_doc = await db.users.find_one({"id": user_id}, {"_id": 0}) - if not user_doc: - raise HTTPException(status_code=401, detail="User not found") - - return User(**user_doc) - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token expired") - except Exception as e: - raise HTTPException(status_code=401, detail="Invalid token") +# API router with /api prefix +api_router = APIRouter(prefix="/api") -# ==================== ROUTES ==================== @api_router.get("/") async def root(): - return {"message": "Pretexta API", "version": "1.0.0"} - -# Auth Routes -@api_router.post("/auth/login", response_model=LoginResponse) -async def login(request: LoginRequest): - user_doc = await db.users.find_one({"username": request.username}, {"_id": 0}) - - if not user_doc or not verify_password(request.password, user_doc['password_hash']): - raise HTTPException(status_code=401, detail="Invalid credentials") - - user = User(**user_doc) - token = create_token(user.id) - - return LoginResponse( - token=token, - user={ - "id": user.id, - "username": user.username, - "created_at": user.created_at.isoformat() - } - ) - -@api_router.get("/auth/me") -async def get_me(current_user: User = Depends(get_current_user)): - return { - "id": current_user.id, - "username": current_user.username, - "created_at": current_user.created_at.isoformat() - } - -# Challenge Routes -@api_router.get("/challenges", response_model=List[Challenge]) -async def get_challenges(current_user: User = Depends(get_current_user)): - challenges = await db.challenges.find({}, {"_id": 0}).to_list(1000) - return challenges - -@api_router.get("/challenges/{challenge_id}", response_model=Challenge) -async def get_challenge(challenge_id: str, current_user: User = Depends(get_current_user)): - challenge = await db.challenges.find_one({"id": challenge_id}, {"_id": 0}) - if not challenge: - raise HTTPException(status_code=404, detail="Challenge not found") - return challenge - -@api_router.post("/challenges", response_model=Challenge) -async def create_challenge(challenge: Challenge, current_user: User = Depends(get_current_user)): - doc = challenge.model_dump() - doc['created_at'] = doc['created_at'].isoformat() - await db.challenges.insert_one(doc) - return challenge - -# Quiz Routes -@api_router.get("/quizzes", response_model=List[Quiz]) -async def get_quizzes(current_user: User = Depends(get_current_user)): - quizzes = await db.quizzes.find({}, {"_id": 0}).to_list(1000) - return quizzes - -@api_router.get("/quizzes/{quiz_id}", response_model=Quiz) -async def get_quiz(quiz_id: str, current_user: User = Depends(get_current_user)): - quiz = await db.quizzes.find_one({"id": quiz_id}, {"_id": 0}) - if not quiz: - raise HTTPException(status_code=404, detail="Quiz not found") - return quiz - -# Simulation Routes -@api_router.post("/simulations") -async def create_simulation(simulation: Simulation, current_user: User = Depends(get_current_user)): - doc = simulation.model_dump() - doc['started_at'] = doc['started_at'].isoformat() - if doc.get('completed_at'): - doc['completed_at'] = doc['completed_at'].isoformat() - await db.simulations.insert_one(doc) - return {"id": simulation.id, "status": "created"} + return {"message": "Pretexta API", "version": "2.0.0"} -@api_router.get("/simulations", response_model=List[Simulation]) -async def get_simulations(current_user: User = Depends(get_current_user)): - sims = await db.simulations.find({}, {"_id": 0}).sort("started_at", -1).to_list(100) - return sims -@api_router.get("/simulations/{simulation_id}", response_model=Simulation) -async def get_simulation(simulation_id: str, current_user: User = Depends(get_current_user)): - sim = await db.simulations.find_one({"id": simulation_id}, {"_id": 0}) - if not sim: - raise HTTPException(status_code=404, detail="Simulation not found") - return sim - -@api_router.put("/simulations/{simulation_id}") -async def update_simulation(simulation_id: str, updates: Dict[str, Any], current_user: User = Depends(get_current_user)): - if updates.get('completed_at'): - updates['completed_at'] = datetime.now(timezone.utc).isoformat() - - result = await db.simulations.update_one( - {"id": simulation_id}, - {"$set": updates} - ) - - if result.matched_count == 0: - raise HTTPException(status_code=404, detail="Simulation not found") - - return {"message": "Simulation updated"} - -@api_router.delete("/simulations/{simulation_id}") -async def delete_simulation(simulation_id: str, current_user: User = Depends(get_current_user)): - result = await db.simulations.delete_one({"id": simulation_id}) - - if result.deleted_count == 0: - raise HTTPException(status_code=404, detail="Simulation not found") - - return {"message": "Simulation deleted successfully"} - -# LLM Config Routes -@api_router.get("/llm/config") -async def get_llm_configs(current_user: User = Depends(get_current_user)): - configs = await db.llm_configs.find({}, {"_id": 0}).to_list(100) - # Filter and mask configs - active_configs = [] - for config in configs: - # Skip configs with empty or no API key - if not config.get('api_key') or config.get('api_key') == '': - continue - # Mask actual API keys from response (security) - config['api_key'] = '***' - config['updated_at'] = config.get('updated_at', datetime.now(timezone.utc).isoformat()) - active_configs.append(config) - return active_configs - -@api_router.post("/llm/config") -async def save_llm_config(config: LLMConfig, current_user: User = Depends(get_current_user)): - doc = config.model_dump() - doc['updated_at'] = doc['updated_at'].isoformat() - - # If API key is empty, delete the config (revoke) - if not config.api_key or config.api_key == '': - await db.llm_configs.delete_one({"provider": config.provider}) - return {"message": "LLM config deleted"} - - # Update or insert - await db.llm_configs.update_one( - {"provider": config.provider}, - {"$set": doc}, - upsert=True - ) - - return {"message": "LLM config saved"} - -@api_router.post("/llm/generate") -async def generate_pretext(request: Dict[str, Any], current_user: User = Depends(get_current_user)): - """Generate pretext using LLM""" - requested_provider = request.get('provider', None) - prompt = request.get('prompt', '') - context = request.get('context', {}) - - # Get LLM config - use requested provider or first enabled one - if requested_provider: - config = await db.llm_configs.find_one({"provider": requested_provider, "enabled": True}, {"_id": 0}) - else: - config = await db.llm_configs.find_one({"enabled": True}, {"_id": 0}) - - if not config: - raise HTTPException(status_code=400, detail="LLM provider not configured or not enabled. Please configure in Settings.") - - provider = config['provider'] - - # Import langchain chat models - from langchain_openai import ChatOpenAI - from langchain_google_genai import ChatGoogleGenerativeAI - from langchain_anthropic import ChatAnthropic - from langchain_core.messages import HumanMessage, SystemMessage - - # Set model based on provider - model_map = { - "gemini": "gemini-1.5-flash", - "claude": "claude-3-5-sonnet-20240620" - } - - # Priority: Configured Model -> Provider Default -> Fallback - model_name = config.get('model_name') or model_map.get(provider) - if not model_name: - model_name = "gemini-1.5-flash" - - - # Create the appropriate chat model based on provider - try: - if provider == "gemini": - # Gemini Model Fallback Strategy - # Some keys/regions don't support 1.5-flash yet, or require 'models/' prefix - model_candidates = [ - model_name, # configured default (e.g. gemini-1.5-flash) - "gemini-1.5-flash", # explicit preferred - "models/gemini-1.5-flash", # prefix variation - "gemini-pro", # old reliable fallback - "models/gemini-pro" - ] - - # Deduplicate preserving order - model_candidates = list(dict.fromkeys(model_candidates)) - - last_error = None - response = None - - for candidate in model_candidates: - try: - logger.info(f"Attempting Gemini generation with model: {candidate}") - chat_model = ChatGoogleGenerativeAI( - google_api_key=config['api_key'], - model=candidate, - temperature=0.7, - convert_system_message_to_human=True - ) - - # Prepare messages - context_str = json.dumps(context, indent=2) if isinstance(context, dict) else str(context) - system_message = SystemMessage(content="You are a social engineering pretext generator. Generate realistic, ethically-sound pretexts for security awareness training. Always mark outputs as training material.\n\nContext: " + context_str + "\n\n") - user_message = HumanMessage(content=prompt) - - response = await chat_model.ainvoke([system_message, user_message]) - if response: - break # Success! - except Exception as e: - logger.warning(f"Failed with model {candidate}: {str(e)}") - last_error = e - - if not response: - raise last_error or Exception("All Gemini models failed") - - elif provider == "claude": - chat_model = ChatAnthropic( - api_key=config['api_key'], - model=model_name, - temperature=0.7 - ) - # Standard invocation for Claude - context_str = json.dumps(context, indent=2) if isinstance(context, dict) else str(context) - system_message = SystemMessage(content="You are a social engineering pretext generator. Generate realistic, ethically-sound pretexts for security awareness training. Always mark outputs as training material.\n\nContext: " + context_str + "\n\n") - user_message = HumanMessage(content=prompt) - response = await chat_model.ainvoke([system_message, user_message]) - - else: - raise HTTPException(status_code=400, detail=f"Unsupported provider: {provider}") - - # Sanitize output (remove PII) - sanitized = repair_json(response.content) - - return {"generated_text": sanitized, "provider": provider} - - except Exception as e: - logger.error(f"LLM generation failed: {str(e)}") - # Return a clearer error to the frontend - error_msg = str(e) - if "NOT_FOUND" in error_msg: - error_msg = "Model not found. Your API Key might not support the selected model, or the region is restricted." - raise HTTPException(status_code=500, detail=f"LLM Generation Error: {error_msg}") - - # Remove markdown code blocks if present - text = re.sub(r'```(?:json)?', '', text) - text = text.replace('```', '') - - # Remove training markers - text = text.replace('\\[TRAINING\\]', '').replace('\\[TRAINING MATERIAL\\]', '') - - return text.strip() - -@api_router.post("/llm/chat") -async def chat_interaction(request: Dict[str, Any], current_user: User = Depends(get_current_user)): - """Real-time Chat Interaction for Roleplay""" - history = request.get('history', []) - persona = request.get('persona', {}) - user_message = request.get('message', '') - - # Get Config - config = await db.llm_configs.find_one({"enabled": True}, {"_id": 0}) - if not config: - raise HTTPException(status_code=400, detail="LLM config missing") - - provider = config['provider'] - api_key = config['api_key'] - - from langchain_core.messages import HumanMessage, SystemMessage, AIMessage - from langchain_google_genai import ChatGoogleGenerativeAI - from langchain_anthropic import ChatAnthropic - - # Construct System Prompt - system_prompt = f"""You are a roleplay actor in a cybersecurity simulation. - Role: {persona.get('name', 'Attacker')} - Goal: {persona.get('goal', 'Trick the user')} - Personality: {persona.get('style', 'Manipulative')} - Context: {persona.get('context', 'Corporate Environment')} - - INSTRUCTIONS: - 1. Respond naturally as your character. Short, realistic messages (whatsapp/email style). - 2. Do NOT break character. - 3. If the user successfully spots the attack or refuses securely, react accordingly (e.g. get angry, give up, or try a different angle). - 4. If the user FAILS (gives password, clicks link), output a special marker in your text: [SUCCESS_ATTACK]. - 5. If the user permanently BLOCKS the attack, output: [ATTACK_FAILED]. - """ - - messages = [SystemMessage(content=system_prompt)] - - # Reconstruct history - for msg in history: - if msg['role'] == 'user': - messages.append(HumanMessage(content=msg['content'])) - elif msg['role'] == 'assistant': - messages.append(AIMessage(content=msg['content'])) - - # Add current message - messages.append(HumanMessage(content=user_message)) - - response = None - last_error = None - error_logs = [] - - try: - if provider == "groq": - # Groq Logic (Fast & Free Tier) - from langchain_groq import ChatGroq - chat = ChatGroq( - api_key=api_key, - model_name="llama-3.3-70b-versatile", # High quality default - temperature=0.7 - ) - response = await chat.ainvoke(messages) - - elif provider == "gemini": - # Gemini Model Fallback Strategy - # Simplified fallback - model_candidates = ["gemini-1.5-flash", "gemini-pro"] - - for candidate in model_candidates: - try: - logger.info(f"Chat attempt with model: {candidate}") - convert_system = "1.5" not in candidate - - chat = ChatGoogleGenerativeAI( - google_api_key=api_key, - model=candidate, - temperature=0.8, - convert_system_message_to_human=convert_system - ) - - # Timeout protection - import asyncio - try: - response = await asyncio.wait_for(chat.ainvoke(messages), timeout=15.0) - except asyncio.TimeoutError: - raise Exception("Request timed out") - - if response: - logger.info(f"Chat success with model: {candidate}") - break - except Exception as e: - logger.warning(f"Chat failed with model {candidate}: {e}") - last_error = e - - if not response: - raise Exception(f"Gemini failed: {last_error}") - - elif provider == "claude": - chat = ChatAnthropic(api_key=api_key, model="claude-3-5-sonnet-20240620") - response = await chat.ainvoke(messages) - - else: - # Default to Groq if unknown, assuming user has groq key - from langchain_groq import ChatGroq - chat = ChatGroq(api_key=api_key, model_name="llama3-70b-8192") - response = await chat.ainvoke(messages) - - content = response.content - - status = "ongoing" - if "[SUCCESS_ATTACK]" in content: - status = "failed" # User failed the test - content = content.replace("[SUCCESS_ATTACK]", "") - elif "[ATTACK_FAILED]" in content: - status = "completed" # User passed - content = content.replace("[ATTACK_FAILED]", "") - - return { - "role": "assistant", - "content": content, - "status": status - } - - except Exception as e: - logger.error(f"Chat error: {e}") - error_msg = str(e) - if "401" in error_msg: - error_msg = f"Unauthorized. Please check your API Key for {provider}." - elif "404" in error_msg: - error_msg = f"Model Not Found. Provider: {provider}." - elif "429" in error_msg: - error_msg = f"Rate Limit Exceeded. Please try again later. Provider: {provider}." - - raise HTTPException(status_code=500, detail=error_msg) - -def repair_json(text: str) -> str: - """Attempt to repair and extract valid JSON from LLM output""" - text = sanitize_llm_output(text) - - # Try to find JSON object - start = text.find('{') - end = text.rfind('}') - - if start != -1 and end != -1: - text = text[start:end+1] - +@api_router.get("/health") +async def health_check(): + """Health check endpoint for Docker and monitoring.""" try: - # Validate if it's already good - json.loads(text) - return text - except json.JSONDecodeError: - # Simple repairs - # 1. Replace single quotes with double quotes (imperfect but helps) - # text = text.replace("'", '"') - # CAUTION: This might break text content. Only use if desperate. - pass - - return text + await db.command("ping") + return {"status": "healthy", "database": "connected"} + except Exception: + return {"status": "degraded", "database": "disconnected"} + + +# Register all route modules +api_router.include_router(auth_router) +api_router.include_router(challenges_router) +api_router.include_router(quizzes_router) +api_router.include_router(simulations_router) +api_router.include_router(llm_router) +api_router.include_router(settings_router) +api_router.include_router(imports_router) +api_router.include_router(reports_router) +api_router.include_router(leaderboard_router) +api_router.include_router(analytics_router) +api_router.include_router(organizations_router) +api_router.include_router(campaigns_router) +api_router.include_router(notifications_router) +api_router.include_router(webhooks_router) +api_router.include_router(scenario_builder_router) +api_router.include_router(debrief_router) +api_router.include_router(certificates_router) +api_router.include_router(adaptive_router) -# Settings Routes -@api_router.get("/settings", response_model=Settings) -async def get_settings(current_user: User = Depends(get_current_user)): - settings = await db.settings.find_one({"id": "settings"}, {"_id": 0}) - if not settings: - settings = Settings().model_dump() - await db.settings.insert_one(settings) - return settings - -@api_router.put("/settings") -async def update_settings(updates: Dict[str, Any], current_user: User = Depends(get_current_user)): - await db.settings.update_one( - {"id": "settings"}, - {"$set": updates}, - upsert=True - ) - return {"message": "Settings updated"} - -# YAML Import Route -@api_router.post("/import/yaml") -async def import_yaml_file(file_content: Dict[str, Any], current_user: User = Depends(get_current_user)): - """Import YAML challenge or quiz""" - try: - yaml_type = file_content.get('type') - data = file_content.get('data') - - if yaml_type == 'challenge': - challenge = Challenge(**data) - doc = challenge.model_dump() - doc['created_at'] = doc['created_at'].isoformat() - await db.challenges.insert_one(doc) - return {"message": "Challenge imported", "id": challenge.id} - - elif yaml_type == 'quiz': - quiz = Quiz(**data) - doc = quiz.model_dump() - doc['created_at'] = doc['created_at'].isoformat() - await db.quizzes.insert_one(doc) - return {"message": "Quiz imported", "id": quiz.id} - - else: - raise HTTPException(status_code=400, detail="Unknown YAML type") - - except Exception as e: - raise HTTPException(status_code=400, detail=f"Import failed: {str(e)}") - -# Report Generation Route -@api_router.get("/reports/{simulation_id}/json") -async def get_report_json(simulation_id: str, current_user: User = Depends(get_current_user)): - sim = await db.simulations.find_one({"id": simulation_id}, {"_id": 0}) - if not sim: - raise HTTPException(status_code=404, detail="Simulation not found") - - # Calculate detailed score - score_data = calculate_susceptibility_score(sim) - - report = { - "simulation_id": simulation_id, - "score": score_data, - "events": sim.get('events', []), - "started_at": sim.get('started_at'), - "completed_at": sim.get('completed_at'), - "participant_name": sim.get('participant_name') - } - - return report - -def calculate_susceptibility_score(simulation: Dict[str, Any]) -> Dict[str, Any]: - """Calculate susceptibility score 0-100""" - events = simulation.get('events', []) - - if not events: - return {"total": 0, "breakdown": {}} - - # Simple scoring logic - compliance_count = sum(1 for e in events if e.get('action') == 'complied') - total_events = len(events) - - # Lower score = more susceptible - base_score = max(0, 100 - (compliance_count / total_events * 100)) if total_events > 0 else 50 - - return { - "total": round(base_score, 2), - "breakdown": { - "compliance_rate": round((compliance_count / total_events * 100) if total_events > 0 else 0, 2), - "total_events": total_events - } - } - -# Include the router in the main app app.include_router(api_router) +# Middleware (order matters: last added = first executed) app.add_middleware( CORSMiddleware, allow_credentials=True, - allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','), + allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","), allow_methods=["*"], allow_headers=["*"], ) -@app.on_event("startup") -async def startup_db(): - """Initialize database with seed user""" - # Check if seed user exists - existing_user = await db.users.find_one({"username": "soceng"}) - - if not existing_user: - seed_user = User( - username="soceng", - password_hash=hash_password("Cialdini@2025!") - ) - doc = seed_user.model_dump() - doc['created_at'] = doc['created_at'].isoformat() - await db.users.insert_one(doc) - logger.info("Seed user created: soceng / Cialdini@2025!") - -@app.on_event("shutdown") -async def shutdown_db_client(): - client.close() +app.add_middleware(RateLimitMiddleware, max_attempts=10, window_seconds=300) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..a6bc195 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,22 @@ +from services.auth import hash_password, verify_password, create_token, get_current_user +from services.llm import get_llm_chat_model, get_llm_generate_model, sanitize_llm_output, repair_json +from services.scoring import calculate_susceptibility_score +from services.gamification import award_xp, check_simulation_badges, BADGE_DEFINITIONS +from services.adaptive import get_recommended_difficulty, get_recommended_categories + +__all__ = [ + "hash_password", + "verify_password", + "create_token", + "get_current_user", + "get_llm_chat_model", + "get_llm_generate_model", + "sanitize_llm_output", + "repair_json", + "calculate_susceptibility_score", + "award_xp", + "check_simulation_badges", + "BADGE_DEFINITIONS", + "get_recommended_difficulty", + "get_recommended_categories", +] diff --git a/backend/services/adaptive.py b/backend/services/adaptive.py new file mode 100644 index 0000000..ab2a73d --- /dev/null +++ b/backend/services/adaptive.py @@ -0,0 +1,106 @@ +import logging +from typing import Optional + +from services.database import db + +logger = logging.getLogger(__name__) + +# Difficulty scaling rules +DIFFICULTY_ORDER = ["easy", "medium", "hard"] + + +async def get_recommended_difficulty(user_id: str) -> str: + """Calculate recommended difficulty based on user performance.""" + sims = await db.simulations.find( + {"user_id": user_id, "status": "completed"}, + {"_id": 0, "score": 1, "difficulty": 1, "started_at": 1}, + ).sort("started_at", -1).to_list(10) + + if not sims or len(sims) < 3: + return "easy" + + # Look at last 5 simulations + recent = sims[:5] + recent_scores = [s.get("score", 50) or 50 for s in recent] + avg_score = sum(recent_scores) / len(recent_scores) + + current_difficulty = recent[0].get("difficulty", "medium") + current_idx = DIFFICULTY_ORDER.index(current_difficulty) if current_difficulty in DIFFICULTY_ORDER else 1 + + # Escalate if consistently scoring high + if avg_score >= 85 and current_idx < len(DIFFICULTY_ORDER) - 1: + return DIFFICULTY_ORDER[current_idx + 1] + # De-escalate if struggling + elif avg_score < 40 and current_idx > 0: + return DIFFICULTY_ORDER[current_idx - 1] + + return current_difficulty + + +async def get_recommended_categories(user_id: str) -> list: + """Suggest Cialdini categories the user needs to practice.""" + sims = await db.simulations.find( + {"user_id": user_id, "status": "completed"}, + {"_id": 0, "score": 1, "challenge_data": 1}, + ).to_list(100) + + category_scores = {} + category_counts = {} + + for sim in sims: + categories = sim.get("challenge_data", {}).get("cialdini_categories", []) + score = sim.get("score", 50) or 50 + for cat in categories: + category_scores.setdefault(cat, []).append(score) + category_counts[cat] = category_counts.get(cat, 0) + 1 + + # Find weak areas (lowest avg scores) and unexplored areas + all_categories = ["reciprocity", "scarcity", "authority", "commitment", "liking", "social_proof"] + recommendations = [] + + for cat in all_categories: + if cat not in category_scores: + recommendations.append({"category": cat, "reason": "not_attempted", "avg_score": 0}) + else: + avg = sum(category_scores[cat]) / len(category_scores[cat]) + if avg < 70: + recommendations.append({"category": cat, "reason": "needs_improvement", "avg_score": round(avg, 1)}) + + # Sort: not_attempted first, then lowest scores + recommendations.sort(key=lambda x: (x["reason"] != "not_attempted", x["avg_score"])) + + return recommendations[:3] + + +async def get_adaptive_persona_params(user_id: str) -> dict: + """Get adaptive parameters for AI chat persona based on user skill.""" + difficulty = await get_recommended_difficulty(user_id) + + params = { + "easy": { + "aggressiveness": 0.3, + "persistence": 2, + "technique_complexity": "basic", + "hints_enabled": True, + "instruction": "Be somewhat obvious in your manipulation. Use simple techniques. Give the user clear red flags to catch.", + }, + "medium": { + "aggressiveness": 0.6, + "persistence": 4, + "technique_complexity": "intermediate", + "hints_enabled": False, + "instruction": "Use moderately sophisticated manipulation. Mix techniques. Don't be too obvious.", + }, + "hard": { + "aggressiveness": 0.9, + "persistence": 6, + "technique_complexity": "advanced", + "hints_enabled": False, + "instruction": "Use highly sophisticated, multi-layered manipulation. Combine Cialdini principles. Be very convincing and persistent. Adapt your approach when resisted.", + }, + } + + return { + "recommended_difficulty": difficulty, + "params": params.get(difficulty, params["medium"]), + } diff --git a/backend/services/auth.py b/backend/services/auth.py new file mode 100644 index 0000000..dac791e --- /dev/null +++ b/backend/services/auth.py @@ -0,0 +1,50 @@ +import os +import bcrypt +import jwt +from datetime import datetime, timezone, timedelta +from fastapi import Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from models.schemas import User +from services.database import db + +JWT_SECRET = os.environ.get("JWT_SECRET", "soceng-lab-secret-key-change-in-production") +JWT_ALGORITHM = "HS256" +JWT_EXPIRATION_HOURS = 24 + +security = HTTPBearer() + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + + +def create_token(user_id: str) -> str: + expiration = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRATION_HOURS) + payload = {"user_id": user_id, "exp": expiration} + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> User: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + user_id = payload.get("user_id") + + user_doc = await db.users.find_one({"id": user_id}, {"_id": 0}) + if not user_doc: + raise HTTPException(status_code=401, detail="User not found") + + return User(**user_doc) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=401, detail="Invalid token") diff --git a/backend/services/database.py b/backend/services/database.py new file mode 100644 index 0000000..796952d --- /dev/null +++ b/backend/services/database.py @@ -0,0 +1,9 @@ +import os +from dotenv import load_dotenv +from motor.motor_asyncio import AsyncIOMotorClient + +load_dotenv() + +mongo_url = os.environ["MONGO_URL"] +client = AsyncIOMotorClient(mongo_url) +db = client[os.environ["DB_NAME"]] diff --git a/backend/services/gamification.py b/backend/services/gamification.py new file mode 100644 index 0000000..6e3f814 --- /dev/null +++ b/backend/services/gamification.py @@ -0,0 +1,189 @@ +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +from services.database import db + +logger = logging.getLogger(__name__) + +# XP thresholds per level +LEVEL_THRESHOLDS = [0, 100, 300, 600, 1000, 1500, 2200, 3000, 4000, 5500, 7500, 10000] + +# Badge definitions +BADGE_DEFINITIONS = [ + {"id": "first_blood", "name": "First Blood", "description": "Complete your first simulation", "icon": "Sword", "condition": "complete_1_simulation", "xp_reward": 50}, + {"id": "phishing_detector", "name": "Phishing Detector", "description": "Score 80%+ on 3 phishing scenarios", "icon": "Shield", "condition": "phishing_score_80_x3", "xp_reward": 100}, + {"id": "social_proof_immune", "name": "Social Proof Immune", "description": "Resist all social proof attacks", "icon": "Users", "condition": "resist_social_proof_x3", "xp_reward": 100}, + {"id": "authority_challenger", "name": "Authority Challenger", "description": "Score 90%+ on authority-based attacks", "icon": "Crown", "condition": "authority_score_90", "xp_reward": 150}, + {"id": "streak_3", "name": "On Fire", "description": "Maintain a 3-day streak", "icon": "Flame", "condition": "streak_3", "xp_reward": 75}, + {"id": "streak_7", "name": "Unstoppable", "description": "Maintain a 7-day streak", "icon": "Zap", "condition": "streak_7", "xp_reward": 150}, + {"id": "streak_30", "name": "Iron Will", "description": "Maintain a 30-day streak", "icon": "Trophy", "condition": "streak_30", "xp_reward": 500}, + {"id": "quiz_master", "name": "Quiz Master", "description": "Score 100% on 5 quizzes", "icon": "BookCheck", "condition": "quiz_perfect_x5", "xp_reward": 200}, + {"id": "all_categories", "name": "Cialdini Scholar", "description": "Complete scenarios in all 6 Cialdini categories", "icon": "Brain", "condition": "all_cialdini_categories", "xp_reward": 300}, + {"id": "campaign_hero", "name": "Campaign Hero", "description": "Complete a full campaign with 80%+ avg score", "icon": "Flag", "condition": "campaign_complete_80", "xp_reward": 250}, + {"id": "team_player", "name": "Team Player", "description": "Join an organization", "icon": "Users2", "condition": "join_organization", "xp_reward": 50}, + {"id": "scenario_creator", "name": "Scenario Creator", "description": "Publish a custom scenario", "icon": "Pencil", "condition": "publish_scenario", "xp_reward": 200}, +] + + +def calculate_level(xp: int) -> int: + """Calculate level from XP.""" + for i in range(len(LEVEL_THRESHOLDS) - 1, -1, -1): + if xp >= LEVEL_THRESHOLDS[i]: + return i + 1 + return 1 + + +def xp_for_next_level(current_xp: int) -> dict: + """Get XP progress to next level.""" + level = calculate_level(current_xp) + if level >= len(LEVEL_THRESHOLDS): + return {"current": current_xp, "next_level_xp": current_xp, "progress": 100} + + current_threshold = LEVEL_THRESHOLDS[level - 1] + next_threshold = LEVEL_THRESHOLDS[level] if level < len(LEVEL_THRESHOLDS) else current_xp + progress = ((current_xp - current_threshold) / (next_threshold - current_threshold)) * 100 + + return { + "current_xp": current_xp, + "level": level, + "current_threshold": current_threshold, + "next_threshold": next_threshold, + "progress": round(min(progress, 100), 1), + } + + +async def award_xp(user_id: str, xp_amount: int, check_streak: bool = False) -> dict: + """Award XP to user and check for level ups and badges.""" + user = await db.users.find_one({"id": user_id}, {"_id": 0}) + if not user: + return {} + + now = datetime.now(timezone.utc) + updates = {} + new_badges = [] + + # Streak logic + if check_streak: + last_active = user.get("last_active") + streak = user.get("streak_days", 0) + + if last_active: + if isinstance(last_active, str): + last_active = datetime.fromisoformat(last_active.replace("Z", "+00:00")) + days_diff = (now.date() - last_active.date()).days + + if days_diff == 1: + streak += 1 + elif days_diff > 1: + streak = 1 + # Same day = no change + else: + streak = 1 + + updates["streak_days"] = streak + updates["last_active"] = now.isoformat() + + # Streak badges + if streak >= 3 and "streak_3" not in user.get("badges", []): + new_badges.append("streak_3") + xp_amount += 75 + if streak >= 7 and "streak_7" not in user.get("badges", []): + new_badges.append("streak_7") + xp_amount += 150 + if streak >= 30 and "streak_30" not in user.get("badges", []): + new_badges.append("streak_30") + xp_amount += 500 + + # Update XP + new_xp = user.get("xp", 0) + xp_amount + new_level = calculate_level(new_xp) + updates["xp"] = new_xp + updates["level"] = new_level + + # Apply badge updates + if new_badges: + await db.users.update_one( + {"id": user_id}, + {"$addToSet": {"badges": {"$each": new_badges}}}, + ) + + await db.users.update_one({"id": user_id}, {"$set": updates}) + + # Create notifications for new badges + for badge_id in new_badges: + badge_def = next((b for b in BADGE_DEFINITIONS if b["id"] == badge_id), None) + if badge_def: + await db.notifications.insert_one({ + "id": str(__import__("uuid").uuid4()), + "user_id": user_id, + "title": f"Badge Earned: {badge_def['name']}", + "message": badge_def["description"], + "type": "achievement", + "read": False, + "created_at": now.isoformat(), + }) + + leveled_up = new_level > user.get("level", 1) + if leveled_up: + await db.notifications.insert_one({ + "id": str(__import__("uuid").uuid4()), + "user_id": user_id, + "title": f"Level Up! You're now Level {new_level}", + "message": f"Keep training to reach Level {new_level + 1}!", + "type": "achievement", + "read": False, + "created_at": now.isoformat(), + }) + + return { + "xp_earned": xp_amount, + "total_xp": new_xp, + "level": new_level, + "leveled_up": leveled_up, + "new_badges": new_badges, + } + + +async def check_simulation_badges(user_id: str): + """Check and award badges based on simulation history.""" + user = await db.users.find_one({"id": user_id}, {"_id": 0}) + if not user: + return + + existing_badges = user.get("badges", []) + sims = await db.simulations.find( + {"user_id": user_id, "status": "completed"}, {"_id": 0} + ).to_list(1000) + + new_badges = [] + total_xp = 0 + + # First Blood + if "first_blood" not in existing_badges and len(sims) >= 1: + new_badges.append("first_blood") + total_xp += 50 + + # Quiz Master: 5 perfect quiz scores + quiz_sims = [s for s in sims if s.get("simulation_type") == "quiz" and s.get("score", 0) == 100] + if "quiz_master" not in existing_badges and len(quiz_sims) >= 5: + new_badges.append("quiz_master") + total_xp += 200 + + # All Cialdini categories + all_categories = set() + for s in sims: + cats = s.get("challenge_data", {}).get("cialdini_categories", []) + all_categories.update(cats) + cialdini_6 = {"reciprocity", "scarcity", "authority", "commitment", "liking", "social_proof"} + if "all_categories" not in existing_badges and cialdini_6.issubset(all_categories): + new_badges.append("all_categories") + total_xp += 300 + + if new_badges: + await db.users.update_one( + {"id": user_id}, + {"$addToSet": {"badges": {"$each": new_badges}}}, + ) + if total_xp > 0: + await award_xp(user_id, total_xp) diff --git a/backend/services/llm.py b/backend/services/llm.py new file mode 100644 index 0000000..2605eda --- /dev/null +++ b/backend/services/llm.py @@ -0,0 +1,145 @@ +import asyncio +import json +import logging +import re +from typing import Any, Dict, List, Optional + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + +logger = logging.getLogger(__name__) + +# Default model mappings +MODEL_DEFAULTS = { + "gemini": "gemini-1.5-flash", + "claude": "claude-3-5-sonnet-20240620", + "groq": "llama-3.3-70b-versatile", +} + +GEMINI_FALLBACK_MODELS = [ + "gemini-1.5-flash", + "models/gemini-1.5-flash", + "gemini-pro", + "models/gemini-pro", +] + + +def sanitize_llm_output(text: str) -> str: + """Remove markdown code blocks and training markers from LLM output.""" + text = re.sub(r"```(?:json)?", "", text) + text = text.replace("```", "") + text = text.replace("[TRAINING]", "").replace("[TRAINING MATERIAL]", "") + return text.strip() + + +def repair_json(text: str) -> str: + """Attempt to repair and extract valid JSON from LLM output.""" + text = sanitize_llm_output(text) + + start = text.find("{") + end = text.rfind("}") + + if start != -1 and end != -1: + text = text[start : end + 1] + + try: + json.loads(text) + return text + except json.JSONDecodeError: + pass + + return text + + +async def _invoke_gemini(api_key: str, model_name: str, messages: list, temperature: float = 0.7): + """Invoke Gemini with fallback model strategy.""" + from langchain_google_genai import ChatGoogleGenerativeAI + + model_candidates = [model_name] + GEMINI_FALLBACK_MODELS + # Deduplicate preserving order + model_candidates = list(dict.fromkeys(model_candidates)) + + last_error = None + for candidate in model_candidates: + try: + logger.info(f"Attempting Gemini with model: {candidate}") + convert_system = "1.5" not in candidate + chat = ChatGoogleGenerativeAI( + google_api_key=api_key, + model=candidate, + temperature=temperature, + convert_system_message_to_human=convert_system, + ) + response = await asyncio.wait_for(chat.ainvoke(messages), timeout=15.0) + if response: + logger.info(f"Success with model: {candidate}") + return response + except asyncio.TimeoutError: + logger.warning(f"Timeout with model {candidate}") + last_error = Exception(f"Request timed out for {candidate}") + except Exception as e: + logger.warning(f"Failed with model {candidate}: {e}") + last_error = e + + raise last_error or Exception("All Gemini models failed") + + +async def get_llm_generate_model(config: Dict[str, Any], prompt: str, context: Dict[str, Any]): + """Generate pretext content using configured LLM provider.""" + provider = config["provider"] + api_key = config["api_key"] + model_name = config.get("model_name") or MODEL_DEFAULTS.get(provider, "gemini-1.5-flash") + + context_str = json.dumps(context, indent=2) if isinstance(context, dict) else str(context) + system_message = SystemMessage( + content=( + "You are a social engineering pretext generator. Generate realistic, " + "ethically-sound pretexts for security awareness training. Always mark " + "outputs as training material.\n\nContext: " + context_str + "\n\n" + ) + ) + user_message = HumanMessage(content=prompt) + messages = [system_message, user_message] + + if provider == "gemini": + return await _invoke_gemini(api_key, model_name, messages) + elif provider == "claude": + from langchain_anthropic import ChatAnthropic + + chat = ChatAnthropic(api_key=api_key, model=model_name, temperature=0.7) + return await chat.ainvoke(messages) + else: + raise ValueError(f"Unsupported provider: {provider}") + + +async def get_llm_chat_model(config: Dict[str, Any], messages: list): + """Chat interaction using configured LLM provider.""" + provider = config["provider"] + api_key = config["api_key"] + + if provider == "groq": + from langchain_groq import ChatGroq + + chat = ChatGroq( + api_key=api_key, + model_name=MODEL_DEFAULTS["groq"], + temperature=0.7, + ) + return await chat.ainvoke(messages) + + elif provider == "gemini": + model_name = config.get("model_name") or MODEL_DEFAULTS["gemini"] + return await _invoke_gemini(api_key, model_name, messages, temperature=0.8) + + elif provider == "claude": + from langchain_anthropic import ChatAnthropic + + model_name = config.get("model_name") or MODEL_DEFAULTS["claude"] + chat = ChatAnthropic(api_key=api_key, model=model_name) + return await chat.ainvoke(messages) + + else: + # Default fallback to Groq + from langchain_groq import ChatGroq + + chat = ChatGroq(api_key=api_key, model_name="llama3-70b-8192") + return await chat.ainvoke(messages) diff --git a/backend/services/scoring.py b/backend/services/scoring.py new file mode 100644 index 0000000..6259768 --- /dev/null +++ b/backend/services/scoring.py @@ -0,0 +1,24 @@ +from typing import Any, Dict + + +def calculate_susceptibility_score(simulation: Dict[str, Any]) -> Dict[str, Any]: + """Calculate susceptibility score 0-100. Lower = more susceptible.""" + events = simulation.get("events", []) + + if not events: + return {"total": 0, "breakdown": {}} + + compliance_count = sum(1 for e in events if e.get("action") == "complied") + total_events = len(events) + + base_score = max(0, 100 - (compliance_count / total_events * 100)) if total_events > 0 else 50 + + return { + "total": round(base_score, 2), + "breakdown": { + "compliance_rate": round( + (compliance_count / total_events * 100) if total_events > 0 else 0, 2 + ), + "total_events": total_events, + }, + } diff --git a/docker-compose.yml b/docker-compose.yml index 3ac7c90..737e931 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,9 @@ services: container_name: soceng_mongodb restart: unless-stopped environment: - MONGO_INITDB_ROOT_USERNAME: soceng_admin - MONGO_INITDB_ROOT_PASSWORD: soceng_secure_password_2025 - MONGO_INITDB_DATABASE: Pretexta + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME:-soceng_admin} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-soceng_secure_password_2025} + MONGO_INITDB_DATABASE: ${DB_NAME:-Pretexta} volumes: - mongodb_data:/data/db - ./docker/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro @@ -16,6 +16,12 @@ services: - "27017:27017" networks: - soceng_network + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s backend: build: @@ -23,11 +29,13 @@ services: dockerfile: Dockerfile.backend container_name: soceng_backend restart: unless-stopped + env_file: + - .env environment: - MONGO_URL: mongodb://soceng_admin:soceng_secure_password_2025@mongodb:27017/Pretexta?authSource=admin - DB_NAME: Pretexta + MONGO_URL: mongodb://${MONGO_USERNAME:-soceng_admin}:${MONGO_PASSWORD:-soceng_secure_password_2025}@mongodb:27017/${DB_NAME:-Pretexta}?authSource=admin + DB_NAME: ${DB_NAME:-Pretexta} JWT_SECRET: ${JWT_SECRET:-change-this-secret-key-in-production} - CORS_ORIGINS: http://localhost:3000,http://localhost:80 + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:80} volumes: - ./backend:/app - ./data:/app/data @@ -35,9 +43,16 @@ services: ports: - "8001:8001" depends_on: - - mongodb + mongodb: + condition: service_healthy networks: - soceng_network + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/health')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s command: uvicorn server:app --host 0.0.0.0 --port 8001 --reload frontend: @@ -47,17 +62,24 @@ services: container_name: soceng_frontend restart: unless-stopped environment: - REACT_APP_BACKEND_URL: http://localhost:8001 + REACT_APP_BACKEND_URL: ${REACT_APP_BACKEND_URL:-http://localhost:8001} ports: - "3000:3000" depends_on: - - backend + backend: + condition: service_healthy networks: - soceng_network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s volumes: mongodb_data: driver: local networks: - soceng_network: \ No newline at end of file + soceng_network: diff --git a/frontend/public/index.html b/frontend/public/index.html index 7bb4ea8..dd36858 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,28 +1,19 @@ - + + + Pretexta | Human Defense Lab
- - - \ No newline at end of file + diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..7624b3d --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,22 @@ +{ + "short_name": "Pretexta", + "name": "Pretexta - Social Engineering Lab", + "description": "Learn to defend against social engineering attacks through interactive simulations", + "icons": [ + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#000000", + "orientation": "portrait-primary" +} diff --git a/frontend/src/App.js b/frontend/src/App.js index aea93d4..0811c77 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,45 +1,64 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { Toaster } from 'sonner'; import './i18n/config'; import './App.css'; -// Pages +// Eagerly loaded (always needed) import LoginPage from './pages/LoginPage'; -import ProfilePage from './pages/ProfilePage'; -import GlossaryPage from './pages/GlossaryPage'; // New Feature -import DashboardPage from './pages/DashboardPage'; -import ScenariosPage from './pages/ScenariosPage'; -import QuizzesPage from './pages/QuizzesPage'; -import SimulationsPage from './pages/SimulationsPage'; -import SettingsPage from './pages/SettingsPage'; -import InstallerPage from './pages/InstallerPage'; -import SimulationPlayerPage from './pages/SimulationPlayerPage'; -import AIChatPage from './pages/AIChatPage'; -import QuizPlayerPage from './pages/QuizPlayerPage'; - -// Layout +import RegisterPage from './pages/RegisterPage'; import Layout from './components/Layout'; -import ProtectedRoute from './components/ProtectedRoute'; // Added for new routes +import ProtectedRoute from './components/ProtectedRoute'; +import ErrorBoundary from './components/ErrorBoundary'; + +// Lazy loaded pages +const DashboardPage = lazy(() => import('./pages/DashboardPage')); +const ScenariosPage = lazy(() => import('./pages/ScenariosPage')); +const QuizzesPage = lazy(() => import('./pages/QuizzesPage')); +const QuizPlayerPage = lazy(() => import('./pages/QuizPlayerPage')); +const SimulationsPage = lazy(() => import('./pages/SimulationsPage')); +const SimulationPlayerPage = lazy(() => import('./pages/SimulationPlayerPage')); +const AIChatPage = lazy(() => import('./pages/AIChatPage')); +const SettingsPage = lazy(() => import('./pages/SettingsPage')); +const InstallerPage = lazy(() => import('./pages/InstallerPage')); +const ProfilePage = lazy(() => import('./pages/ProfilePage')); +const GlossaryPage = lazy(() => import('./pages/GlossaryPage')); +const LeaderboardPage = lazy(() => import('./pages/LeaderboardPage')); +const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage')); +const CampaignsPage = lazy(() => import('./pages/CampaignsPage')); +const ScenarioBuilderPage = lazy(() => import('./pages/ScenarioBuilderPage')); +const DebriefPage = lazy(() => import('./pages/DebriefPage')); +const CertificatePage = lazy(() => import('./pages/CertificatePage')); + +function PageLoader() { + return ( +
+
LOADING...
+
+ ); +} function App() { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); const [firstRunCompleted, setFirstRunCompleted] = useState(true); + const [showRegister, setShowRegister] = useState(false); useEffect(() => { - // Check for auth token const token = localStorage.getItem('soceng_token'); if (token) { setIsAuthenticated(true); } - // Check first run status - skip if already has token (for testing/production) const firstRun = localStorage.getItem('soceng_first_run'); if (!firstRun && !token) { setFirstRunCompleted(false); } + // Apply saved theme + const savedTheme = localStorage.getItem('soceng_theme') || 'dark'; + document.documentElement.classList.toggle('light', savedTheme === 'light'); + setIsLoading(false); }, []); @@ -53,45 +72,71 @@ function App() { if (!firstRunCompleted) { return ( - - - { - setFirstRunCompleted(true); - localStorage.setItem('soceng_first_run', 'true'); - }} /> - + + + + }> + { + setFirstRunCompleted(true); + localStorage.setItem('soceng_first_run', 'true'); + }} /> + + + ); } if (!isAuthenticated) { return ( - - - setIsAuthenticated(true)} /> - + + + + {showRegister ? ( + setIsAuthenticated(true)} + onSwitchToLogin={() => setShowRegister(false)} + /> + ) : ( + setIsAuthenticated(true)} + onSwitchToRegister={() => setShowRegister(true)} + /> + )} + + ); } return ( - - - setIsAuthenticated(false)}> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + setIsAuthenticated(false)}> + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/EmailRenderer.js b/frontend/src/components/EmailRenderer.js new file mode 100644 index 0000000..94857a6 --- /dev/null +++ b/frontend/src/components/EmailRenderer.js @@ -0,0 +1,69 @@ +import React from 'react'; +import { Star, Reply, Forward, Trash2, MoreHorizontal, Paperclip } from 'lucide-react'; + +export default function EmailRenderer({ email }) { + const { from, to, subject, body, date, attachments, cc } = email || {}; + + return ( +
+ {/* Email Header Bar */} +
+
+ + + +
+ +
+ + {/* Subject */} +
+

{subject || 'No Subject'}

+
+ + {/* Sender Info */} +
+
+
+ {(from || 'U').charAt(0).toUpperCase()} +
+
+
+ {from || 'Unknown Sender'} +
+

+ to {to || 'me'} + {cc && , cc: {cc}} +

+
+
+
+ {date || new Date().toLocaleString()} + +
+
+ + {/* Body */} +
+
+ {body || 'No content'} +
+
+ + {/* Attachments */} + {attachments && attachments.length > 0 && ( +
+
+ {attachments.map((att, idx) => ( +
+ + {att.name || `attachment_${idx + 1}`} + {att.size || ''} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ErrorBoundary.js b/frontend/src/components/ErrorBoundary.js new file mode 100644 index 0000000..713062c --- /dev/null +++ b/frontend/src/components/ErrorBoundary.js @@ -0,0 +1,48 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('ErrorBoundary caught:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+
!
+

SYSTEM ERROR

+

+ Something went wrong. The application encountered an unexpected error. +

+
+              {this.state.error?.message || 'Unknown error'}
+            
+ +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js index fe873bf..26fb79e 100644 --- a/frontend/src/components/Layout.js +++ b/frontend/src/components/Layout.js @@ -1,21 +1,30 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from './ui/button'; -import { Terminal, LayoutDashboard, FileCode, ListChecks, Activity, Settings, LogOut, BookOpen } from 'lucide-react'; +import NotificationBell from './NotificationBell'; +import { + Terminal, LayoutDashboard, FileCode, ListChecks, Activity, Settings, LogOut, BookOpen, + Trophy, BarChart3, Flag, Pencil, Zap, Sun, Moon, User +} from 'lucide-react'; export default function Layout({ children, onLogout }) { const { t } = useTranslation(); const location = useLocation(); + const [theme, setTheme] = useState(localStorage.getItem('soceng_theme') || 'dark'); const navigation = [ { name: t('nav.dashboard'), path: '/', icon: LayoutDashboard }, { name: t('nav.scenarios'), path: '/scenarios', icon: FileCode }, { name: t('nav.quizzes'), path: '/quizzes', icon: ListChecks }, - { name: t('nav.ai_challenge'), path: '/ai-challenge', icon: Activity }, + { name: t('nav.ai_challenge'), path: '/ai-challenge', icon: Zap }, + { name: 'Campaigns', path: '/campaigns', icon: Flag }, { name: t('nav.history'), path: '/simulations', icon: Activity }, + { name: 'Leaderboard', path: '/leaderboard', icon: Trophy }, + { name: 'Analytics', path: '/analytics', icon: BarChart3 }, + { name: 'Scenario Builder', path: '/scenario-builder', icon: Pencil }, { name: t('nav.glossary', 'Glossary'), path: '/glossary', icon: BookOpen }, - { name: t('nav.settings'), path: '/settings', icon: Settings } + { name: t('nav.settings'), path: '/settings', icon: Settings }, ]; const handleLogout = () => { @@ -24,6 +33,13 @@ export default function Layout({ children, onLogout }) { onLogout(); }; + const toggleTheme = () => { + const newTheme = theme === 'dark' ? 'light' : 'dark'; + setTheme(newTheme); + localStorage.setItem('soceng_theme', newTheme); + document.documentElement.classList.toggle('light', newTheme === 'light'); + }; + return (
{/* Global Scanlines Overlay */} @@ -49,7 +65,7 @@ export default function Layout({ children, onLogout }) {
{/* Navigation */} -