diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0f5cdd --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# PostgreSQL +DB_USER=admin +DB_PASSWORD=test +DB_HOST=127.0.0.1 +DB_PORT=5432 +REDIS_URL=redis://localhost:6379/0 +SECRET_KEY=02247f40-a769-4c49-9178-4c038048e7ad +DEBUG=True +OFFERINGS_THRESHOLD_FOR_TERM_UPDATE=100 + +# Frontend +FRONTEND_URL=http://localhost:5173 + +# wj platform +SIGNUP_QUEST_API_KEY= +SIGNUP_QUEST_URL= +SIGNUP_QUEST_QUESTIONID= +LOGIN_QUEST_API_KEY= +LOGIN_QUEST_URL= +LOGIN_QUEST_QUESTIONID= +RESET_QUEST_API_KEY= +RESET_QUEST_URL= +RESET_QUEST_QUESTIONID= + +# Turnstile +TURNSTILE_SECRET_KEY= \ No newline at end of file diff --git a/Makefile b/Makefile index 48d26fb..ad8f5f9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run clean collect format format-backend format-frontend makemigrations migrate shell createsuperuser dev-frontend help +.PHONY: run dev-frontend clean collect install-frontend format format-backend format-frontend lint lint-backend lint-frontend makemigrations migrate shell createsuperuser help # Default target when 'make' is run without arguments .DEFAULT_GOAL := help @@ -9,9 +9,13 @@ help: @echo " dev-frontend - Starts the frontend development server (formats frontend code first)" @echo " clean - Clears Django session data" @echo " collect - Collects Django static files" + @echo " install-frontend - Installs frontend dependencies using bun" @echo " format - Formats both backend (Python) and frontend (JS/TS/CSS) code" @echo " format-backend - Formats Python code using isort and black" @echo " format-frontend - Formats frontend code using prettier" + @echo " lint - Lints both backend (Python) and frontend (JS/TS/CSS) code" + @echo " lint-backend - Lints Python code using ruff" + @echo " lint-frontend - Lints frontend code using eslint" @echo " makemigrations - Creates new Django model migrations" @echo " migrate - Applies Django database migrations" @echo " shell - Opens a Django shell" @@ -33,6 +37,10 @@ collect: @echo "Collecting Django static files (confirming 'yes')..." echo 'yes' | uv run manage.py collectstatic +install-frontend: + @echo "Installing frontend dependencies with bun..." + cd frontend && bun install + format: format-backend format-frontend @echo "All code formatted successfully!" @@ -42,7 +50,18 @@ format-backend: format-frontend: @echo "Formatting frontend code with prettier..." - cd frontend && bunx prettier . -w + cd frontend && bun run format | grep -v -F '(unchanged)' || true + +lint: lint-backend lint-frontend + @echo "All code linted successfully!" + +lint-backend: format-backend + @echo "Linting backend (Python) code with ruff..." + uvx ruff check + +lint-frontend: format-frontend + @echo "Linting frontend code with eslint..." + cd frontend && bun run lint makemigrations: @echo "Creating Django database migrations..." diff --git a/apps/auth/__init__.py b/apps/auth/__init__.py new file mode 100644 index 0000000..ec9487c --- /dev/null +++ b/apps/auth/__init__.py @@ -0,0 +1 @@ +default_app_config = "apps.auth.apps.OAuthConfig" diff --git a/apps/auth/admin.py b/apps/auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/auth/apps.py b/apps/auth/apps.py new file mode 100644 index 0000000..f2da6af --- /dev/null +++ b/apps/auth/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.auth" + label = "oauth" # Unique label to avoid conflict with django.contrib.auth diff --git a/apps/auth/migrations/__init__.py b/apps/auth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/auth/models.py b/apps/auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/apps/auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/auth/tests.py b/apps/auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/auth/utils.py b/apps/auth/utils.py new file mode 100644 index 0000000..0ca4a8b --- /dev/null +++ b/apps/auth/utils.py @@ -0,0 +1,288 @@ +import json +import logging +import re + +import httpx +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from rest_framework.response import Response + +from apps.web.models import Student + +PASSWORD_LENGTH_MIN = settings.AUTH["PASSWORD_LENGTH_MIN"] +PASSWORD_LENGTH_MAX = settings.AUTH["PASSWORD_LENGTH_MAX"] +OTP_TIME_OUT = settings.AUTH["OTP_TIME_OUT"] +QUEST_BASE_URL = settings.AUTH["QUEST_BASE_URL"] +EMAIL_DOMAIN_NAME = settings.AUTH["EMAIL_DOMAIN_NAME"] + + +def get_survey_url(action: str) -> str | None: + """Helper function to get the survey URL based on action type""" + if action == "signup": + return settings.SIGNUP_QUEST_URL + if action == "login": + return settings.LOGIN_QUEST_URL + if action == "reset_password": + return settings.RESET_QUEST_URL + return None + + +def get_survey_api_key(action: str) -> str | None: + """Helper function to get the survey API key based on action type""" + if action == "signup": + return settings.SIGNUP_QUEST_API_KEY + if action == "login": + return settings.LOGIN_QUEST_API_KEY + if action == "reset_password": + return settings.RESET_QUEST_API_KEY + return None + + +def get_survey_questionid(action: str) -> int | None: + """Helper function to get the survey question ID for the verification code based on action type""" + question_id_str = None + if action == "signup": + question_id_str = settings.SIGNUP_QUEST_QUESTIONID + elif action == "login": + question_id_str = settings.LOGIN_QUEST_QUESTIONID + elif action == "reset_password": + question_id_str = settings.RESET_QUEST_QUESTIONID + + if question_id_str: + try: + return int(question_id_str) + except (ValueError, TypeError): + return None + return None + + +async def verify_turnstile_token( + turnstile_token, client_ip +) -> tuple[bool, Response | None]: + """Helper function to verify Turnstile token with Cloudflare's API""" + + try: + async with httpx.AsyncClient(timeout=OTP_TIME_OUT) as client: + response = await client.post( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={ + "secret": settings.TURNSTILE_SECRET_KEY, + "response": turnstile_token, + "remoteip": client_ip, + }, + ) + if not response.json().get("success"): + logging.warning(f"Turnstile verification failed: {response.json()}") + return False, Response( + {"error": "Turnstile verification failed"}, status=403 + ) + return True, None + except httpx.TimeoutException: + logging.error("Turnstile verification timed out") + return False, Response( + {"error": "Turnstile verification timed out"}, status=504 + ) + except Exception as e: + logging.error(f"Error verifying Turnstile token: {e}") + return False, Response({"error": "Turnstile verification error"}, status=500) + + +async def get_latest_answer( + action: str, + account: str, +) -> tuple[dict | None, Response | None]: + """Fetch the latest questionnaire answer for a given account from the WJ API(specific api for actions). + Returns a tuple of (filtered_data, error_response). + `filtered_data` contains: id, submitted_at, user.account, and otp. + `error_response` is a DRF Response object if an error occurs, otherwise None. + """ + quest_api = get_survey_api_key(action) + if not quest_api: + return None, Response({"error": "Invalid action"}, status=400) + + # Get the target question ID for the verification code + question_id = get_survey_questionid(action) + if not question_id: + return None, Response( + {"error": "Configuration error: question ID not found for action"}, + status=500, + ) + + # Build the 'params' and 'sort' dictionaries + params_dict = { + "account": account, + "current": 1, + "pageSize": 1, + } + sort_dict = {"id": "desc"} + + params_json_str = json.dumps(params_dict, ensure_ascii=False) + sort_json_str = json.dumps(sort_dict) + + # Prepare the final query parameters + final_query_params = {"params": params_json_str, "sort": sort_json_str} + + # Combine to form the full URL path + full_url_path = f"{QUEST_BASE_URL}/{quest_api}/json" + + try: + async with httpx.AsyncClient(timeout=OTP_TIME_OUT) as client: + response = await client.get( + full_url_path, + params=final_query_params, + ) + response.raise_for_status() # Raise an exception for bad status codes + full_data = response.json() + except httpx.TimeoutException: + logging.exception("Questionnaire API query timed out") + return None, Response( + {"error": "Questionnaire API query timed out"}, + status=504, + ) + except httpx.RequestError as e: + logging.exception(f"Error querying questionnaire API: {e}") + return None, Response( + {"error": "Failed to query questionnaire API"}, + status=500, + ) + except Exception as e: + logging.exception(f"An unexpected error occurred: {e}") + return None, Response({"error": "An unexpected error occurred"}, status=500) + + # Filter and return only the required fields from the first row + if ( + full_data.get("success") + and full_data.get("data") + and full_data["data"].get("rows") + and len(full_data["data"]["rows"]) > 0 + ): + latest_answer = full_data["data"]["rows"][0] # Get the first (latest) row + + # Find the otp by matching the question ID + otp = None + answers = latest_answer.get("answers", []) + for ans in answers: + if str(ans.get("question", {}).get("id")) == str(question_id): + otp = ans.get("answer") + break + + # Extract only the required fields from this row + filtered_data = { + "id": latest_answer.get("id"), + "submitted_at": latest_answer.get("submitted_at"), + "account": latest_answer.get("user", {}).get("account") + if latest_answer.get("user") + else None, + "otp": otp, + } + + # Check if all required fields are present + if not all( + key in filtered_data and filtered_data[key] is not None + for key in ["id", "submitted_at", "account", "otp"] + ): + logging.warning("Missing required field(s) in questionnaire response") + return None, Response( + {"error": "Missing required field(s) in questionnaire response"}, + status=400, + ) + + return filtered_data, None + + return None, Response( + {"error": "No questionnaire submission found or submission invalid"}, + status=403, + ) + + +def rate_password_strength(password: str) -> int: + """Helper function to rate password strength""" + + if len(password) < PASSWORD_LENGTH_MIN or len(password) > PASSWORD_LENGTH_MAX: + return 0 + + score = 1 + + if re.search(r"[a-z]", password): + score += 1 + if re.search(r"[A-Z]", password): + score += 1 + if re.search(r"\d", password): + score += 1 + if re.search(r"[^a-zA-Z0-9\s]", password): + score += 1 + + length_step = (PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) // 10 + + score += (len(password) - PASSWORD_LENGTH_MIN) // length_step + + return min(score, 5) + + +def validate_password_strength(password: str) -> tuple[bool, dict | None]: + """Helper function to validate password complexity and strength. + + Returns: A tuple of (is_valid, error_response). + `is_valid` is True if the password is valid, otherwise False. + `error_response` is a dict with a detailed error message if invalid, otherwise None. + """ + + score = rate_password_strength(password) + + if score == 0: + return False, { + "error": "Password is too short or too long.", + } + + if score < 3: + return False, { + "error": "Password is too weak.", + } + + # Use Django's built-in validators for additional checks + try: + validate_password(password) + return True, None + except ValidationError as e: + return False, {"error": list(e.messages)} + + +def create_user_session( + request, + account, +) -> tuple[AbstractUser | None, Response | None]: + """Helper function includes session management, user creation and Student model integration. + Returns a tuple of (user, error_response). + `user` is the user object on success, otherwise None. + `error_response` is a DRF Response object if an error occurs, otherwise None. + """ + try: + # Ensure session exists - create one if it doesn't exist + if not request.session.session_key: + request.session.create() + + # Get or create user + user_model = get_user_model() + + user, _ = user_model.objects.get_or_create( + username=account, + defaults={"email": f"{account}@{EMAIL_DOMAIN_NAME}"}, + ) + + if not user: + return None, Response( + {"error": "Failed to retrieve or create user"}, status=500 + ) + + # Handle Student model integration + Student.objects.get_or_create(user=user) + + # Update session to use authenticated username + request.session["user_id"] = user.username + return user, None + + except Exception: + return None, Response({"error": "Failed to create user session"}, status=500) diff --git a/apps/auth/views.py b/apps/auth/views.py new file mode 100644 index 0000000..cf20b0f --- /dev/null +++ b/apps/auth/views.py @@ -0,0 +1,476 @@ +import asyncio +import base64 +import hashlib +import json +import logging +import secrets +import time + +import dateutil.parser +import httpx +from django.conf import settings +from django.contrib.auth import authenticate, get_user_model, login, logout +from django_redis import get_redis_connection +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, +) +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.auth import utils +from apps.web.models import Student + + +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return + + +OTP_TIME_OUT = settings.AUTH["OTP_TIME_OUT"] +TEMP_TOKEN_TIMEOUT = settings.AUTH["TEMP_TOKEN_TIMEOUT"] +ACTION_LIST = settings.AUTH["ACTION_LIST"] +TOKEN_RATE_LIMIT = settings.AUTH["TOKEN_RATE_LIMIT"] +TOKEN_RATE_LIMIT_TIME = settings.AUTH["TOKEN_RATE_LIMIT_TIME"] + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def auth_initiate_api(request): + """Step 1: Authentication Initiation (/api/auth/initiate) + + 1. Receives action and turnstile_token from frontend + 2. Verifies Turnstile token with Cloudflare's API + 3. Generates cryptographically secure OTP and temp_token + 4. Stores OTP->temp_token mapping and temp_token state in Redis + 5. Sets temp_token as HttpOnly cookie and returns OTP and redirect_url + """ + # Get required fields from request data + action = request.data.get("action") + turnstile_token = request.data.get("turnstile_token") + + if not action or not turnstile_token: + return Response({"error": "Missing action or turnstile_token"}, status=400) + + if action not in ACTION_LIST: + return Response({"error": "Invalid action"}, status=400) + + client_ip = ( + request.META.get("HTTP_CF_CONNECTING_IP") + or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + or request.META.get("REMOTE_ADDR") + ) + + # Verify Turnstile token + success, error_response = asyncio.run( + utils.verify_turnstile_token(turnstile_token, client_ip) + ) + if not success: + return error_response + + # Generate cryptographically secure OTP and temp_token + otp = "".join([str(secrets.randbelow(10)) for _ in range(8)]) + temp_token = secrets.token_urlsafe(32) + + # Create Redis storage and clean up existing tokens + r = get_redis_connection("default") + + # Clean up any existing temp_token for this client to prevent memory leaks + existing_temp_token = request.COOKIES.get("temp_token") + if existing_temp_token: + try: + existing_hash = hashlib.sha256(existing_temp_token.encode()).hexdigest() + existing_state_key = f"temp_token_state:{existing_hash}" + existing_state_data = r.get(existing_state_key) + if existing_state_data: + existing_state = json.loads(existing_state_data) + r.delete(existing_state_key) + logging.info( + f"Cleaned up existing temp_token_state for action {existing_state.get('action', 'unknown')}" + ) + except Exception as e: + logging.warning(f"Error cleaning up existing temp_token: {e}") + + # Store OTP -> temp_token mapping with initiated_at timestamp + current_time = time.time() + otp_data = {"temp_token": temp_token, "initiated_at": current_time} + r.setex(f"otp:{otp}", OTP_TIME_OUT, json.dumps(otp_data)) + + # Store temp_token with SHA256 hash as key, and status of pending as well as action + temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() + temp_token_state = {"status": "pending", "action": action} + r.setex( + f"temp_token_state:{temp_token_hash}", + TEMP_TOKEN_TIMEOUT, + json.dumps(temp_token_state), + ) + + logging.info(f"Created auth intent for action {action} with OTP and temp_token") + + survey_url = utils.get_survey_url(action) + if not survey_url: + return Response( + {"error": "Something went wrong when fetching the survey URL"}, + status=500, + ) + + # Create response and set temp_token as HttpOnly cookie + response = Response({"otp": otp, "redirect_url": survey_url}, status=200) + response.set_cookie( + "temp_token", + temp_token, + max_age=TEMP_TOKEN_TIMEOUT, + httponly=True, + secure=getattr(settings, "SECURE_COOKIES", True), + samesite="Lax", + ) + return response + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def verify_callback_api(request): + """Callback Verification (/api/auth/verify) + request data includes account, answer_id, action + Handles the verification of questionnaire callback using temp_token from cookie. + """ + # Get required parameters from request + account = request.data.get("account") + answer_id = request.data.get("answer_id") + action = request.data.get("action") + + if not account or not answer_id or not action: + return Response({"error": "Missing account, answer_id, or action"}, status=400) + + if action not in ACTION_LIST: + return Response({"error": "Invalid action"}, status=400) + + # Get temp_token from HttpOnly cookie + temp_token = request.COOKIES.get("temp_token") + if not temp_token: + return Response({"error": "No temp_token found"}, status=401) + + r = get_redis_connection("default") + + # Step 1: Look up temp_token state record + temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() + state_key = f"temp_token_state:{temp_token_hash}" + state_data = r.get(state_key) + + if not state_data: + return Response({"error": "Temp token state not found or expired"}, status=401) + + try: + state_data = json.loads(state_data) + except json.JSONDecodeError: + return Response({"error": "Invalid temp token state data"}, status=401) + + # Verify status is pending and action matches + if state_data.get("status") != "pending": + return Response({"error": "Invalid temp token state"}, status=401) + + if state_data.get("action") != action: + return Response({"error": "Action mismatch"}, status=403) + + # Step 2: Apply rate limiting per temp_token to prevent brute-force attempts + rate_limit_key = ( + f"verify_attempts:{hashlib.sha256(temp_token.encode()).hexdigest()}" + ) + + attempts = r.incr(rate_limit_key) + + if attempts == 1: + r.expire(rate_limit_key, TOKEN_RATE_LIMIT_TIME) + + if attempts > TOKEN_RATE_LIMIT: + return Response({"error": "Too many verification attempts"}, status=429) + + # Step 3: Query questionnaire API for latest submission of the specific questionnaire of the action + latest_answer, error_response = asyncio.run( + utils.get_latest_answer(action=action, account=account), + ) + if error_response: + return error_response + + if latest_answer is None: + return Response({"error": "No questionnaire submission found"}, status=404) + + # Check if this is the submission we're looking for + if str(latest_answer.get("id")) != str(answer_id): + return Response({"error": "Answer ID mismatch"}, status=403) + + # Extract OTP and quest_id from submission + submitted_otp = latest_answer.get("otp") + + # Atomically get and delete OTP record to prevent reuse + otp_key = f"otp:{submitted_otp}" + otp_data_raw = r.getdel(otp_key) + + if not otp_data_raw: + return Response({"error": "Invalid or expired OTP"}, status=401) + + try: + otp_data = json.loads(otp_data_raw.decode("utf-8")) + expected_temp_token = otp_data.get("temp_token") + initiated_at = otp_data.get("initiated_at") + except (json.JSONDecodeError, AttributeError): + return Response({"error": "Invalid OTP data format"}, status=401) + + if not expected_temp_token or not initiated_at: + return Response({"error": "Incomplete OTP data"}, status=401) + + # Step 5: StepVerify temp_token matches + if expected_temp_token != temp_token: + return Response({"error": "Invalid temp_token"}, status=401) + + # Step 6: Validate submission timestamp after OTP extraction + try: + submitted_at_str = latest_answer.get("submitted_at") + if submitted_at_str is None: + return Response({"error": "Missing submission timestamp"}, status=400) + + submitted_at = dateutil.parser.parse(submitted_at_str).timestamp() + + # Additional validation: check submission is after initiation and within window + if submitted_at < initiated_at or (submitted_at - initiated_at) > OTP_TIME_OUT: + return Response( + {"error": "Submission timestamp outside validity window"}, + status=401, + ) + + except (ValueError, TypeError) as e: + logging.exception(f"Error parsing submission timestamp: {e}") + return Response({"error": "Invalid submission timestamp"}, status=401) + + # Step 7: Update state to verified and add user details + state_data.update( + { + "status": "verified", + "account": account, + }, + ) + + # Update temp_token_state in Redis with refreshed TTL + r.setex(state_key, TEMP_TOKEN_TIMEOUT, json.dumps(state_data)) + expires_at = int(time.time() + TEMP_TOKEN_TIMEOUT) + + # Clear rate limiting on success + r.delete(rate_limit_key) + + logging.info( + f"Successfully verified temp_token for user {account} with action {action}", + ) + + # For login action, handle immediate session creation and cleanup + is_logged_in = False + if action == "login": + user, error_response = utils.create_user_session(request, account) + if user is None: + if error_response: + logging.error( + f"Failed to create session for login: {getattr(error_response, 'data', {}).get('error', 'Unknown error')}", + ) + return error_response + else: + return Response({"error": "Failed to create user session"}, status=500) + if not user.is_active: + logging.warning(f"Inactive user attempted OAuth login: {account}") + return Response({"error": "User account is inactive"}, status=403) + try: + # Create Django session + login(request, user) + is_logged_in = True + # Delete temp_token_state after successful login + r.delete(state_key) + except Exception as e: + logging.exception( + f"Error during login session creation or cleanup for user {account}: {e}", + ) + return Response({"error": "Failed to finalize login process"}, status=500) + + # Create response + response = Response( + {"action": action, "expires_at": expires_at, "is_logged_in": is_logged_in}, + status=200, + ) + + # Clear temp_token cookie if login succeeded + if is_logged_in: + response.delete_cookie("temp_token") + + return response + + +def verify_psd_checker(request, action: str) -> tuple[dict | None, Response | None]: + # Get temp_token from HttpOnly cookie + temp_token = request.COOKIES.get("temp_token") + if not temp_token: + return None, Response({"error": "No temp_token found"}, status=401) + + r = get_redis_connection("default") + + # Look up temp_token state record + temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() + state_key = f"temp_token_state:{temp_token_hash}" + state_data = r.get(state_key) + + if not state_data: + return None, Response( + {"error": "Temp token state not found or expired"}, + status=401, + ) + + try: + state_data = json.loads(state_data) + except json.JSONDecodeError: + return None, Response({"error": "Invalid temp token state data"}, status=401) + + # Verify status is verified and action is signup + if state_data.get("status") != "verified" or state_data.get("action") != action: + return None, Response({"error": "Invalid temp token state"}, status=403) + + # Get password from request data + password = request.data.get("password") + if not password: + return None, Response({"error": "Missing password"}, status=400) + + # Validate password strength + is_valid, error_response = utils.validate_password_strength(password) + if not is_valid: + return None, Response(error_response, status=400) + # Get account from verified state + account = state_data.get("account") + if not account: + return None, Response({"error": "No account in verified state"}, status=401) + return {"account": account, "password": password, "state_key": state_key}, None + + +@api_view(["POST"]) +def auth_signup_api(request) -> Response: + """Signup API (/api/auth/signup) + + Handles user signup using verified temp_token. + """ + try: + verification_data, error_response = verify_psd_checker(request, action="signup") + if verification_data is None: + return error_response or Response( + {"error": "Verification failed"}, status=400 + ) + + account = verification_data.get("account") + password = verification_data.get("password") + state_key = verification_data.get("state_key") + + # Create user session + user, error_response = utils.create_user_session(request, account) + if user is None: + return error_response or Response( + {"error": "Failed to create user session"}, status=500 + ) + if user.password: + return Response({"error": "User already exists with password."}, status=409) + + user.is_active = True + # Set password + user.set_password(password) + user.save() + + # Cleanup: Delete temp_token_state and clear cookie + r = get_redis_connection("default") + r.delete(state_key) + response = Response({"success": True, "username": user.username}, status=200) + response.delete_cookie("temp_token") + return response + + except Exception as e: + logging.error(f"Error in auth_signup_api: {e}") + return Response({"error": "Failed to complete signup"}, status=500) + + +@api_view(["POST"]) +def auth_reset_password_api(request) -> Response: + """Reset Password API (/api/auth/password) + + Handles password reset using verified temp_token. + """ + try: + verification_data, error_response = verify_psd_checker( + request, + action="reset_password", + ) + if verification_data is None: + return error_response or Response( + {"error": "Verification failed"}, status=400 + ) + account = verification_data.get("account") + password = verification_data.get("password") + state_key = verification_data.get("state_key") + + # Get the user object and update password + user_model = get_user_model() + try: + user = user_model.objects.get(username=account) + user.set_password(password) + user.save() + except user_model.DoesNotExist: + return Response({"error": "User does not exist"}, status=404) + + # Cleanup: Delete temp_token_state and clear cookie + r = get_redis_connection("default") + r.delete(state_key) + response = Response({"success": True, "username": user.username}, status=200) + response.delete_cookie("temp_token") + return response + + except Exception as e: + logging.error(f"Error in auth_reset_password_api: {e}") + return Response({"error": "Failed to reset password"}, status=500) + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def auth_login_api(request) -> Response: + account = request.data.get("account", "") + password = request.data.get("password", "") + turnstile_token = request.data.get("turnstile_token", "") + + if not account or not password or not turnstile_token: + return Response( + {"error": "Account, password, and Turnstile token are missing"}, status=400 + ) + + client_ip = ( + request.META.get("HTTP_CF_CONNECTING_IP") + or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + or request.META.get("REMOTE_ADDR") + ) + + # Verify Turnstile token + success, error_response = asyncio.run( + utils.verify_turnstile_token(turnstile_token, client_ip) + ) + if not success: + return error_response + + user = authenticate(username=account, password=password) + + if user is not None and user.is_active: + login(request, user) + Student.objects.get_or_create(user=user) + return Response({"message": "Login successfully"}, status=200) + return Response({"error": "Invalid account or password"}, status=401) + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def auth_logout_api(request) -> Response: + """Logout a user.""" + logout(request) + return Response({"message": "Logged out successfully"}, status=200) diff --git a/apps/spider/crawlers/orc.py b/apps/spider/crawlers/orc.py index a9bc782..aba3fdd 100644 --- a/apps/spider/crawlers/orc.py +++ b/apps/spider/crawlers/orc.py @@ -39,8 +39,9 @@ def crawl_program_urls(): + program_urls = set() # Initialize to empty set for orc_url in [UNDERGRAD_URL]: - program_urls = _get_department_urls_from_url(orc_url) + program_urls.update(_get_department_urls_from_url(orc_url)) return program_urls @@ -58,7 +59,11 @@ def _is_department_url(candidate_url): def _crawl_course_data(course_url): soup = retrieve_soup(course_url) - course_heading = soup.find("h2").get_text() + course_heading_element = soup.find("h2") + if course_heading_element is None: + return None # Return early if no h2 element found + + course_heading = course_heading_element.get_text() if course_heading: split_course_heading = course_heading.split(" – ") children = list(soup.find_all(class_="et_pb_text_inner")[3].children) diff --git a/apps/web/models/forms/review_form.py b/apps/web/models/forms/review_form.py index b376945..1d7b69f 100644 --- a/apps/web/models/forms/review_form.py +++ b/apps/web/models/forms/review_form.py @@ -5,7 +5,7 @@ from lib import constants from lib.terms import is_valid_term -REVIEW_MINIMUM_LENGTH = 100 +REVIEW_MINIMUM_LENGTH = 30 class ReviewForm(forms.ModelForm): diff --git a/apps/web/models/review.py b/apps/web/models/review.py index d4f15da..3da86c2 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -18,11 +18,15 @@ def get_user_review_for_course(self, user, course): """ Get the review written by a user for a specific course. Returns the Review object if found, None otherwise. + If multiple reviews exist, returns the most recent one. """ try: return self.get(user=user, course=course) except self.model.DoesNotExist: return None + except self.model.MultipleObjectsReturned: + # If somehow there are multiple reviews, return the most recent one + return self.filter(user=user, course=course).order_by("-created_at").first() class Review(models.Model): diff --git a/apps/web/views.py b/apps/web/views.py index 3b67564..c2e3cf7 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,57 +1,40 @@ -import datetime -import uuid -import traceback - -import dateutil.parser -from django.contrib.auth import authenticate, login, logout -from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Count -from django.http import ( - HttpResponseBadRequest, - HttpResponseForbidden, - HttpResponseRedirect, - JsonResponse, -) -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views.decorators.http import require_POST, require_safe -from rest_framework.authentication import SessionAuthentication -from rest_framework.decorators import ( - api_view, - authentication_classes, - permission_classes, -) -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.response import Response - - -class CsrfExemptSessionAuthentication(SessionAuthentication): - def enforce_csrf(self, request): - return - - from apps.web.models import ( Course, CourseMedian, - DistributiveRequirement, Instructor, Review, ReviewVote, - Student, Vote, ) -from apps.web.models.forms import ReviewForm, SignupForm + +from apps.web.models.forms import ReviewForm + from apps.web.serializers import ( CourseSearchSerializer, CourseSerializer, ReviewSerializer, ) + from lib import constants from lib.departments import get_department_name from lib.grades import numeric_value_for_grade from lib.terms import numeric_value_of_term +import datetime +import uuid +import dateutil.parser + +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator +from django.db.models import Count +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import ( + api_view, + permission_classes, +) +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + + LIMITS = { "courses": 20, "reviews": 5, @@ -105,113 +88,6 @@ def get_prior_course_id(request, current_course_id): return prior_course_id -def signup(request): - if request.method == "POST": - form = SignupForm(request.POST) - if form.is_valid(): - form.save_and_send_confirmation(request) - return render(request, "instructions.html") - else: - return render(request, "signup.html", {"form": form}) - - else: - return render(request, "signup.html", {"form": SignupForm()}) - - -@api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) -@permission_classes([AllowAny]) -def auth_login_api(request): - email = request.data.get("email", "").lower() - password = request.data.get("password", "") - next_url = request.data.get("next", "/courses") - - if not email or not password: - return Response({"error": "Email and password are required"}, status=400) - - username = email.split("@")[0] - user = authenticate(username=username, password=password) - - if user is not None: - if user.is_active: - login(request, user) - if "user_id" in request.session: - try: - student = Student.objects.get(user=user) - student.unauth_session_ids.append(request.session["user_id"]) - student.save() - except Student.DoesNotExist: - student = Student.objects.create( - user=user, unauth_session_ids=[request.session["user_id"]] - ) - request.session["user_id"] = user.username - - return Response( - {"success": True, "next": next_url, "username": user.username} - ) - else: - return Response( - { - "error": "Please activate your account via the activation link first." - }, - status=403, - ) - else: - return Response({"error": "Invalid email or password"}, status=401) - - -@api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) -@permission_classes([AllowAny]) -def auth_logout_api(request): - """ - API endpoint for user logout. - """ - if request.user.is_authenticated: - try: - student = Student.objects.get(user=request.user) - if "user_id" in request.session: - if request.session["user_id"] in student.unauth_session_ids: - student.unauth_session_ids.remove(request.session["user_id"]) - student.save() - except Student.DoesNotExist: - pass - - logout(request) - request.session["userID"] = uuid.uuid4().hex - return Response({"success": True, "message": "Logged out successfully"}) - else: - return Response( - {"success": False, "message": "User not authenticated"}, status=400 - ) - - -@require_safe -def confirmation(request): - link = request.GET.get("link") - - if link: - try: - student = Student.objects.get(confirmation_link=link) - except Student.DoesNotExist: - return render( - request, - "confirmation.html", - {"error": "Confirmation code expired or does not exist."}, - ) - - if student.user.is_active: - return render(request, "confirmation.html", {"already_confirmed": True}) - - student.user.is_active = True - student.user.save() - return render(request, "confirmation.html", {"already_confirmed": False}) - else: - return render( - request, "confirmation.html", {"error": "Please provide confirmation code."} - ) - - @api_view(["GET"]) @permission_classes([AllowAny]) def courses_api(request): @@ -320,11 +196,8 @@ def course_detail_api(request, course_id): @api_view(["DELETE"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def delete_review_api(request, course_id): - # Check if user is authenticated - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) course = Course.objects.get(id=course_id) Review.objects.delete_reviews_for_user_course(user=request.user, course=course) serializer = CourseSerializer(course, context={"request": request}) @@ -403,7 +276,7 @@ def course_review_search_api(request, course_id): ) -@require_safe +@api_view(["GET"]) def medians(request, course_id): # retrieve course medians for term, and group by term for averaging medians_by_term = {} @@ -420,7 +293,7 @@ def medians(request, course_id): } ) - return JsonResponse( + return Response( { "medians": sorted( [ @@ -437,13 +310,14 @@ def medians(request, course_id): key=lambda x: numeric_value_of_term(x["term"]), reverse=True, ) - } + }, + status=200, ) -@require_safe +@api_view(["GET"]) def course_professors(request, course_id): - return JsonResponse( + return Response( { "professors": sorted( set( @@ -459,46 +333,40 @@ def course_professors(request, course_id): .distinct() ) ) - } + }, + status=200, ) -@require_safe +@api_view(["GET"]) def course_instructors(request, course_id): try: course = Course.objects.get(pk=course_id) instructors = course.get_instructors() - return JsonResponse( - {"instructors": [instructor.name for instructor in instructors]} + return Response( + {"instructors": [instructor.name for instructor in instructors]}, status=200 ) except Course.DoesNotExist: - return JsonResponse({"error": "Course not found"}, status=404) + return Response({"error": "Course not found"}, status=404) -@require_POST +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) def course_vote_api(request, course_id): - if not request.user.is_authenticated: - return HttpResponseForbidden() - try: - import json - - if request.content_type == "application/json": - data = json.loads(request.body) - value = data["value"] - forLayup = data["forLayup"] - else: - value = request.POST["value"] - forLayup = request.POST["forLayup"] - except (KeyError, json.JSONDecodeError): - return HttpResponseBadRequest() + value = request.data["value"] + forLayup = request.data["forLayup"] + except KeyError: + return Response( + {"detail": "Missing required fields: value, forLayup"}, status=400 + ) category = Vote.CATEGORIES.DIFFICULTY if forLayup else Vote.CATEGORIES.QUALITY new_score, is_unvote, new_vote_count = Vote.objects.vote( int(value), course_id, category, request.user ) - return JsonResponse( + return Response( { "new_score": new_score, "was_unvote": is_unvote, @@ -508,7 +376,7 @@ def course_vote_api(request, course_id): @api_view(["POST"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def review_vote_api(request, review_id): """ API endpoint for voting on reviews (kudos/dislike). @@ -522,8 +390,6 @@ def review_vote_api(request, review_id): - dislike_count: updated dislike count - user_vote: user's current vote (True/False/None) """ - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) try: is_kudos = request.data.get("is_kudos") @@ -558,7 +424,7 @@ def review_vote_api(request, review_id): @api_view(["GET"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def get_user_review_api(request, course_id): """ API endpoint to get the authenticated user's review for a specific course. @@ -568,8 +434,6 @@ def get_user_review_api(request, course_id): - 404 if no review found - 403 if user is not authenticated """ - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) try: # Get the course diff --git a/docs/Setup.md b/docs/Setup.md index de64cab..9b70362 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -22,7 +22,7 @@ Environment: 6. Make directory for builds of static files: `mkdir staticfiles` -7. Create .env file for storing secrets. The contents should be like: +7. cp .env.example and rename it .env at root dir. The contents of PostgreSQL should be like: ```ini # PostgreSQL @@ -35,6 +35,7 @@ Environment: DEBUG=True OFFERINGS_THRESHOLD_FOR_TERM_UPDATE=100 ``` + Also cp .env.example in frontend/ and rename it .env. 8. Build static files: `make collect` diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..fe8ddce --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# the env variable used for vite-processed frontend should begin with `VITE_` +VITE_TURNSTILE_SITE_KEY=0x4AAAAAABz2ci0ZN9OaO-dg \ No newline at end of file diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..775d29f --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,12 @@ +# Build artifacts +dist +.turbo + +# Dependencies +node_modules +TailwindPlus + +# Environment files +.env +.env.* +!.env.example diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..65952cb --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,24 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "objectWrap": "preserve", + "bracketSameLine": false, + "arrowParens": "always", + "requirePragma": false, + "insertPragma": false, + "checkIgnorePragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "vueIndentScriptAndStyle": false, + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto", + "singleAttributePerLine": false, + "plugins": [] +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..1f5e9d3 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,36 @@ +import js from "@eslint/js"; +import pluginVue from "eslint-plugin-vue"; +import prettierConfig from "eslint-config-prettier"; +import globals from "globals"; + +export default [ + // Global ignores + { + ignores: ["dist", "node_modules", "TailwindPlus"], + }, + + // Base configuration for all JavaScript/Vue files + js.configs.recommended, + ...pluginVue.configs["flat/recommended"], + + // Custom rules and settings + { + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, // For browser environments like document, window + process: "readonly", // To allow process.env.NODE_ENV + "import.meta": "readonly", // Vite uses import.meta.env + }, + }, + rules: { + "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", + "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", + "vue/multi-word-component-names": "off", + }, + }, + + // Turns off any ESLint rules that would conflict with Prettier's formatting. + prettierConfig, +]; diff --git a/frontend/package.json b/frontend/package.json index fcad6d7..adc9c2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,22 +6,30 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "lint": "eslint . --fix", + "format": "prettier . --write" }, "dependencies": { "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.2.0", - "@tailwindcss/postcss": "^4.1.11", - "axios": "^1.7.9", - "dompurify": "^3.2.6", - "md-editor-v3": "^5.8.4", - "tailwindcss": "^4.1.11", - "vue": "^3.5.13", - "vue-router": "^4.5.0" + "@tailwindcss/postcss": "^4.1.13", + "axios": "^1.12.2", + "dompurify": "^3.2.7", + "md-editor-v3": "^5.8.5", + "tailwindcss": "^4.1.13", + "vue": "^3.5.21", + "vue-router": "^4.5.1" }, "devDependencies": { - "@vitejs/plugin-vue": "^5.2.1", + "@eslint/js": "^9.36.0", + "@vitejs/plugin-vue": "^5.2.4", "autoprefixer": "^10.4.21", - "vite": "^6.1.0" + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-vue": "^10.5.0", + "globals": "^16.4.0", + "prettier": "^3.6.2", + "vite": "^6.3.6" } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6179a34..6abacb4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,11 +5,10 @@ diff --git a/frontend/src/components/CourseList.vue b/frontend/src/components/CourseList.vue index 446fc9e..f235947 100644 --- a/frontend/src/components/CourseList.vue +++ b/frontend/src/components/CourseList.vue @@ -1,7 +1,6 @@ - Courses @@ -10,7 +9,6 @@ - @@ -18,7 +16,6 @@ Filters & Sorting - All Departments - - - Course Code Number of Reviews @@ -104,7 +98,6 @@ - Ascending Descending @@ -124,17 +117,16 @@ - Apply Filters Reset @@ -143,7 +135,6 @@ - - - - Showing {{ courses.length }} of {{ pagination.total_courses }} courses - - + - - 0" class="mt-2" @@ -264,9 +248,7 @@ - - {{ course.review_count }} @@ -274,7 +256,6 @@ Reviews - {{ @@ -286,7 +267,6 @@ Quality - {{ @@ -298,7 +278,6 @@ Difficulty - - Previous Next @@ -346,7 +324,6 @@ - @@ -359,8 +336,8 @@ Clear all filters @@ -371,8 +348,10 @@ diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue new file mode 100644 index 0000000..7581da1 --- /dev/null +++ b/frontend/src/components/Icon.vue @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/Login.vue b/frontend/src/components/Login.vue deleted file mode 100644 index 7551ba3..0000000 --- a/frontend/src/components/Login.vue +++ /dev/null @@ -1,200 +0,0 @@ - - - - - Sign in to your account - - - - - - - - - - Login failed - {{ error }} - - - - - - - - Email address - - - - - - - - - - Password - - - - Forgot password? - - - - - - - - - - - - - - - - Signing in... - - Sign in - - - - - - Don't have an account? - - Sign up here - - - - - - - diff --git a/frontend/src/components/ReviewCard.vue b/frontend/src/components/ReviewCard.vue index 10b78e2..857e122 100644 --- a/frontend/src/components/ReviewCard.vue +++ b/frontend/src/components/ReviewCard.vue @@ -17,20 +17,18 @@ - - {{ expanded ? "Show Less" : "Read More" }} - - - - import { ref, computed } from "vue"; import { useRouter } from "vue-router"; +import { useReviews } from "../composables/useReviews"; import { MdPreview } from "md-editor-v3"; import { HandThumbUpIcon, HandThumbDownIcon } from "@heroicons/vue/24/outline"; import "md-editor-v3/lib/style.css"; @@ -175,6 +169,8 @@ const needsTruncation = computed(() => { return props.review?.comments?.split("\n").length > props.maxLines; }); +const { voteOnReview } = useReviews(); + const handleVote = async (reviewId, isKudos) => { if (!props.isAuthenticated) { if (confirm("Please login to vote on reviews!")) { @@ -184,22 +180,8 @@ const handleVote = async (reviewId, isKudos) => { } try { - const response = await fetch(`/api/review/${reviewId}/vote/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": getCookie("csrftoken"), - }, - body: JSON.stringify({ is_kudos: isKudos }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - - // Emit event to parent so it can update the review data + const data = await voteOnReview(reviewId, isKudos); + if (!data) return; emit("reviewUpdated", { reviewId, kudos_count: data.kudos_count, @@ -211,21 +193,6 @@ const handleVote = async (reviewId, isKudos) => { alert("Error voting on review. Please try again."); } }; - -function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== "") { - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === name + "=") { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} diff --git a/frontend/src/components/ToastNotifications.vue b/frontend/src/components/ToastNotifications.vue new file mode 100644 index 0000000..9846622 --- /dev/null +++ b/frontend/src/components/ToastNotifications.vue @@ -0,0 +1,101 @@ + + + + + + + + + + + {{ errorToastMessage }} + + + + + + + + + + + + + + + + + + + + + + + {{ successToastMessage }} + + + + + + + + + + + + + + + diff --git a/frontend/src/components/Turnstile.vue b/frontend/src/components/Turnstile.vue new file mode 100644 index 0000000..98b91aa --- /dev/null +++ b/frontend/src/components/Turnstile.vue @@ -0,0 +1,288 @@ + + + + + + + Security Verification Error + + + {{ error }} + + + + Try Again + + + + + + + + + + + Loading security verification... + + + + + + + + Security Check Complete + + + Verification successful. You may proceed. + + + + + + + + + Verify you're human + + + Complete the security check below to continue + + + + + + + + + + + + + diff --git a/frontend/src/composables/useAuth.js b/frontend/src/composables/useAuth.js new file mode 100644 index 0000000..7da303a --- /dev/null +++ b/frontend/src/composables/useAuth.js @@ -0,0 +1,63 @@ +import { ref, onMounted, onUnmounted } from "vue"; +import { checkAuthentication as checkAuthUtil } from "../utils/api"; +import { getCookie } from "../utils/cookies"; + +export function useAuth() { + const isAuthenticated = ref(false); + + const checkAuthentication = async () => { + try { + const auth = await checkAuthUtil(); + isAuthenticated.value = !!auth; + return isAuthenticated.value; + } catch (e) { + console.error("useAuth: checkAuthentication error:", e); + isAuthenticated.value = false; + return false; + } + }; + + const logout = async () => { + try { + const response = await fetch("/api/auth/logout/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCookie("csrftoken"), + }, + }); + if (response.ok) { + isAuthenticated.value = false; + return true; + } else { + console.error("useAuth: logout failed", response.status); + return false; + } + } catch (e) { + console.error("useAuth: logout error:", e); + return false; + } + }; + + const onAuthStateChanged = () => { + // Re-check authentication when other parts of app signal change + checkAuthentication(); + }; + + onMounted(() => { + checkAuthentication(); + window.addEventListener("auth-state-changed", onAuthStateChanged); + }); + + onUnmounted(() => { + window.removeEventListener("auth-state-changed", onAuthStateChanged); + }); + + return { + isAuthenticated, + checkAuthentication, + logout, + }; +} + +export default { useAuth }; diff --git a/frontend/src/composables/useCourses.js b/frontend/src/composables/useCourses.js new file mode 100644 index 0000000..9b4a4cd --- /dev/null +++ b/frontend/src/composables/useCourses.js @@ -0,0 +1,138 @@ +import { ref, reactive } from "vue"; + +export function useCourses() { + const courses = ref([]); + const departments = ref([]); + const loading = ref(false); + const error = ref(null); + + const pagination = reactive({ + current_page: 1, + total_pages: 1, + total_courses: 0, + limit: 20, + }); + + const filters = reactive({ + department: "", + code: "", + min_quality: null, + min_difficulty: null, + }); + + const sorting = reactive({ + sort_by: "course_code", + sort_order: "asc", + }); + + const fetchDepartments = async () => { + try { + const response = await fetch("/api/departments/"); + if (!response.ok) throw new Error("Failed to fetch departments"); + departments.value = await response.json(); + } catch (e) { + console.error("useCourses: Error fetching departments:", e); + } + }; + + const fetchCourses = async (isAuth = false) => { + loading.value = true; + error.value = null; + + const params = new URLSearchParams(); + if (filters.department) params.append("department", filters.department); + if (filters.code) params.append("code", filters.code.trim()); + if (filters.min_quality && isAuth) + params.append("min_quality", filters.min_quality); + params.append("sort_by", sorting.sort_by); + params.append("sort_order", sorting.sort_order); + params.append("page", pagination.current_page); + + try { + const response = await fetch(`/api/courses/?${params.toString()}`); + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Unknown error" })); + throw new Error( + errorData.detail || `HTTP error! status: ${response.status}`, + ); + } + const data = await response.json(); + courses.value = data.courses; + pagination.current_page = data.pagination.current_page; + pagination.total_pages = data.pagination.total_pages; + pagination.total_courses = data.pagination.total_courses; + pagination.limit = data.pagination.limit; + } catch (e) { + console.error("useCourses: Error fetching courses:", e); + error.value = e.message; + courses.value = []; + } finally { + loading.value = false; + } + }; + + const getQueryObject = (isAuth = false) => { + const query = {}; + if (filters.department) query.department = filters.department; + if (filters.code) query.code = filters.code.trim(); + if (filters.min_quality && isAuth) query.min_quality = filters.min_quality; + if (sorting.sort_by !== "course_code" || sorting.sort_order !== "asc") { + query.sort_by = sorting.sort_by; + query.sort_order = sorting.sort_order; + } + if (pagination.current_page > 1) query.page = pagination.current_page; + return query; + }; + + const applyFiltersAndSort = () => { + pagination.current_page = 1; + }; + + const resetFiltersAndSort = () => { + filters.department = ""; + filters.code = ""; + filters.min_quality = null; + filters.min_difficulty = null; + sorting.sort_by = "course_code"; + sorting.sort_order = "asc"; + pagination.current_page = 1; + }; + + const changePage = (newPage) => { + if (newPage >= 1 && newPage <= pagination.total_pages) { + pagination.current_page = newPage; + } + }; + + const syncStateFromQuery = (query) => { + filters.department = query.department || ""; + filters.code = query.code || ""; + filters.min_quality = query.min_quality + ? parseInt(query.min_quality, 10) + : null; + sorting.sort_by = query.sort_by || "course_code"; + sorting.sort_order = query.sort_order || "asc"; + pagination.current_page = query.page ? parseInt(query.page, 10) : 1; + }; + + return { + courses, + departments, + loading, + error, + pagination, + filters, + sorting, + fetchDepartments, + fetchCourses, + getQueryObject, + applyFiltersAndSort, + resetFiltersAndSort, + changePage, + syncStateFromQuery, + }; +} + +export default { useCourses }; diff --git a/frontend/src/composables/useNotifications.js b/frontend/src/composables/useNotifications.js new file mode 100644 index 0000000..bf3564f --- /dev/null +++ b/frontend/src/composables/useNotifications.js @@ -0,0 +1,128 @@ +import { ref } from "vue"; + +// Global state for notifications (singleton pattern) +const showErrorToast = ref(false); +const errorToastVisible = ref(false); +const errorToastMessage = ref(""); +const showSuccessToast = ref(false); +const successToastVisible = ref(false); +const successToastMessage = ref(""); + +let errorToastTimer = null; +let successToastTimer = null; + +/** + * Composable for managing UI notifications (toasts/alerts) + * Uses singleton pattern to ensure consistent state across components + */ +export function useNotifications() { + /** + * Show an error toast notification + * @param {string} message - Error message to display + * @param {number} duration - Duration in milliseconds (default: 5000) + */ + function showError(message, duration = 5000) { + errorToastMessage.value = message; + showErrorToast.value = true; + + setTimeout(() => { + errorToastVisible.value = true; + }, 10); + + if (errorToastTimer) { + clearTimeout(errorToastTimer); + } + errorToastTimer = setTimeout(hideError, duration); + } + + /** + * Hide error toast notification + */ + function hideError() { + errorToastVisible.value = false; + setTimeout(() => { + showErrorToast.value = false; + }, 300); + + if (errorToastTimer) { + clearTimeout(errorToastTimer); + errorToastTimer = null; + } + } + + /** + * Show a success toast notification + * @param {string} message - Success message to display + * @param {number} duration - Duration in milliseconds (default: 3000) + */ + function showSuccess(message, duration = 3000) { + successToastMessage.value = message; + showSuccessToast.value = true; + + setTimeout(() => { + successToastVisible.value = true; + }, 10); + + if (successToastTimer) { + clearTimeout(successToastTimer); + } + successToastTimer = setTimeout(hideSuccess, duration); + } + + /** + * Hide success toast notification + */ + function hideSuccess() { + successToastVisible.value = false; + setTimeout(() => { + showSuccessToast.value = false; + }, 300); + + if (successToastTimer) { + clearTimeout(successToastTimer); + successToastTimer = null; + } + } + + /** + * Clear all active notifications + */ + function clearAll() { + hideError(); + hideSuccess(); + } + + /** + * Cleanup timers (call in onUnmounted) + */ + function cleanup() { + if (errorToastTimer) { + clearTimeout(errorToastTimer); + errorToastTimer = null; + } + if (successToastTimer) { + clearTimeout(successToastTimer); + successToastTimer = null; + } + } + + return { + // Error toast state (globally shared) + showErrorToast, + errorToastVisible, + errorToastMessage, + + // Success toast state (globally shared) + showSuccessToast, + successToastVisible, + successToastMessage, + + // Methods + showError, + hideError, + showSuccess, + hideSuccess, + clearAll, + cleanup, + }; +} diff --git a/frontend/src/composables/useReviews.js b/frontend/src/composables/useReviews.js new file mode 100644 index 0000000..9b5c788 --- /dev/null +++ b/frontend/src/composables/useReviews.js @@ -0,0 +1,115 @@ +import { ref } from "vue"; +import { getCookie } from "../utils/cookies"; + +export function useReviews() { + const loading = ref(false); + const error = ref(null); + + const fetchUserReview = async (courseId) => { + if (!courseId) return null; + try { + const response = await fetch(`/api/course/${courseId}/my-review/`); + if (response.ok) { + const data = await response.json(); + return Array.isArray(data) ? data[0] : data; + } else if (response.status === 404) { + return null; + } else { + console.error( + "useReviews: Error fetching user review", + response.status, + ); + return null; + } + } catch (e) { + console.error("useReviews: Error fetching user review", e); + return null; + } + }; + + const submitReview = async (courseId, newReview) => { + try { + const response = await fetch(`/api/course/${courseId}/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCookie("csrftoken"), + }, + body: JSON.stringify(newReview), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || "Failed to submit review"); + } + return await response.json(); + } catch (e) { + console.error("useReviews: submitReview error", e); + throw e; + } + }; + + const deleteReview = async (courseId) => { + try { + const response = await fetch(`/api/course/${courseId}/review/`, { + method: "DELETE", + headers: { "X-CSRFToken": getCookie("csrftoken") }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || "Failed to delete review"); + } + return await response.json(); + } catch (e) { + console.error("useReviews: deleteReview error", e); + throw e; + } + }; + + const vote = async (courseId, value, forLayup) => { + try { + const response = await fetch(`/api/course/${courseId}/vote/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCookie("csrftoken"), + }, + body: JSON.stringify({ value, forLayup }), + }); + if (!response.ok) throw new Error("Vote failed"); + return await response.json(); + } catch (e) { + console.error("useReviews: vote error", e); + throw e; + } + }; + + const voteOnReview = async (reviewId, isKudos) => { + try { + const response = await fetch(`/api/review/${reviewId}/vote/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCookie("csrftoken"), + }, + body: JSON.stringify({ is_kudos: isKudos }), + }); + if (!response.ok) throw new Error("Vote on review failed"); + return await response.json(); + } catch (e) { + console.error("useReviews: voteOnReview error", e); + throw e; + } + }; + + return { + loading, + error, + fetchUserReview, + submitReview, + deleteReview, + vote, + voteOnReview, + }; +} + +export default { useReviews }; diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/layout/AppLayout.vue similarity index 81% rename from frontend/src/components/AppLayout.vue rename to frontend/src/layout/AppLayout.vue index fe05540..a630a2e 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/layout/AppLayout.vue @@ -1,9 +1,9 @@ @@ -34,10 +34,9 @@ - Search courses @@ -52,11 +51,11 @@ @@ -64,7 +63,6 @@ - @@ -97,11 +95,11 @@ v-slot="{ active }" > {{ item.name }} @@ -121,7 +119,6 @@ - @@ -155,7 +152,7 @@ {{ item.name }} - + diff --git a/frontend/src/main.js b/frontend/src/main.js index babf164..9c16070 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,17 +1,26 @@ import { createApp } from "vue"; import { createRouter, createWebHistory } from "vue-router"; import App from "./App.vue"; -import Login from "./components/Login.vue"; -import CourseDetail from "./components/CourseDetail.vue"; -import Landing from "./components/Landing.vue"; -import CourseReviewSearch from "./components/CourseReviewSearch.vue"; +import Login from "./views/Login.vue"; +import CourseDetail from "./views/CourseDetail.vue"; +import Landing from "./views/Home.vue"; +import CourseReviewSearch from "./views/CourseReviewSearch.vue"; import CourseList from "./components/CourseList.vue"; +import AuthCallback from "./views/AuthCallback.vue"; +import Signup from "./views/Signup.vue"; +import ResetPassword from "./views/ResetPassword.vue"; import "./style.css"; const routes = [ { path: "/", component: Landing }, - { path: "/accounts/login", component: Login }, - { path: "/course/:course_id", component: CourseDetail, props: true }, + { path: "/login", component: Login }, + { path: "/accounts/login", component: Login }, // Legacy route for backward compatibility + { path: "/signup", component: Signup }, + { path: "/accounts/signup", component: Signup }, + { path: "/reset", component: ResetPassword }, + { path: "/accounts/reset", component: ResetPassword }, + { path: "/callback", component: AuthCallback }, + { path: "/course/:courseId", component: CourseDetail, props: true }, { path: "/course/:courseId/review_search", component: CourseReviewSearch, diff --git a/frontend/src/styles/MarkdownContent.css b/frontend/src/styles/MarkdownContent.css index 8a06249..602e7fb 100644 --- a/frontend/src/styles/MarkdownContent.css +++ b/frontend/src/styles/MarkdownContent.css @@ -1,23 +1,3 @@ -:deep(.markdown-content) ul { - list-style-type: disc; - padding-left: 1.5rem; -} - -:deep(.markdown-content) ol { - list-style-type: decimal; - padding-left: 1.5rem; -} - -:deep(.markdown-content) ul ul, -:deep(.markdown-content) ol ul { - list-style-type: circle; -} - -:deep(.markdown-content) ul ol, -:deep(.markdown-content) ol ol { - list-style-type: lower-alpha; -} - /* MarkdownContent.css: Unified style for markdown preview/editor in the frontend */ :deep(.markdown-content) { font-family: @@ -26,7 +6,6 @@ sans-serif; font-size: 1.05rem; line-height: 1.8; - /* max-width: 48rem; */ margin-left: 0; margin-right: auto; text-align: left; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..c52e641 --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,17 @@ +// Lightweight API utilities used across components +// checkAuthentication: returns a boolean indicating auth status +export async function checkAuthentication() { + try { + const response = await fetch("/api/user/status/"); + if (response.ok) { + const data = await response.json(); + return !!data.isAuthenticated; + } + return false; + } catch (e) { + console.error("Error checking authentication:", e); + return false; + } +} + +export default { checkAuthentication }; diff --git a/frontend/src/utils/auth.js b/frontend/src/utils/auth.js new file mode 100644 index 0000000..b526a82 --- /dev/null +++ b/frontend/src/utils/auth.js @@ -0,0 +1,123 @@ +/** + * Authentication utilities - centralized auth state management + */ + +/** + * Get authentication state from various storage sources + * @returns {Object|null} Auth state object or null if not found + */ +export function getAuthState() { + try { + // Check URL parameters first (highest priority) + const urlParams = new URLSearchParams(window.location.search); + if ( + urlParams.get("verified") === "true" && + urlParams.get("from_callback") === "true" + ) { + const urlAuthState = { + status: "verified", + action: urlParams.get("action"), + account: urlParams.get("account"), + expires_at: urlParams.get("expires_at"), + verified_at: new Date().toISOString(), + source: "url_params", + }; + return urlAuthState; + } + + // Check for simple verified state in URL + if (urlParams.has("verified") && urlParams.get("verified") === "true") { + const account = urlParams.get("account"); + const action = urlParams.get("action"); + const expires_at = urlParams.get("expires_at"); + + if (account && action) { + return { + status: "verified", + action: action, + account: account, + expires_at: expires_at, + source: "url", + }; + } + } + + // Check localStorage + const localStorageState = localStorage.getItem("auth_flow"); + if (localStorageState) { + const parsed = JSON.parse(localStorageState); + return { ...parsed, source: "localStorage" }; + } + + // Check sessionStorage + const sessionStorageState = sessionStorage.getItem("auth_flow"); + if (sessionStorageState) { + const parsed = JSON.parse(sessionStorageState); + return { ...parsed, source: "sessionStorage" }; + } + + // Check backup sessionStorage location + const backupSessionState = sessionStorage.getItem("auth_verification_data"); + if (backupSessionState) { + const parsed = JSON.parse(backupSessionState); + return { ...parsed, source: "sessionStorage_backup" }; + } + + return null; + } catch (error) { + console.error("Error reading auth state:", error); + return null; + } +} + +/** + * Clear all authentication state from storage + */ +export function clearAuthState() { + try { + localStorage.removeItem("auth_flow"); + localStorage.removeItem("auth_otp"); + localStorage.removeItem("auth_redirect_time"); + localStorage.removeItem("authState"); + sessionStorage.removeItem("auth_flow"); + sessionStorage.removeItem("auth_verification_data"); + } catch (error) { + console.error("Failed to clear auth state:", error); + } +} + +/** + * Save authentication state to storage + * @param {Object} state - Auth state to save + * @param {boolean} persistent - Whether to save to localStorage (true) or sessionStorage (false) + */ +export function saveAuthState(state, persistent = true) { + try { + const stateToSave = { ...state }; + delete stateToSave.source; // Remove source field before saving + + const stateString = JSON.stringify(stateToSave); + + if (persistent) { + localStorage.setItem("auth_flow", stateString); + } + sessionStorage.setItem("auth_flow", stateString); + } catch (error) { + console.error("Failed to save auth state:", error); + } +} + +/** + * Check if current auth state is valid for given action + * @param {string} action - The action to validate against + * @returns {boolean} Whether the current state is valid + */ +export function isAuthStateValid(action) { + const state = getAuthState(); + return ( + state && + state.action === action && + state.status === "verified" && + (!state.expires_at || Date.now() < parseInt(state.expires_at)) + ); +} diff --git a/frontend/src/utils/cookies.js b/frontend/src/utils/cookies.js new file mode 100644 index 0000000..19f475b --- /dev/null +++ b/frontend/src/utils/cookies.js @@ -0,0 +1,17 @@ +// Utility to read cookies (used for CSRF token and similar) +export function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === name + "=") { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +export default { getCookie }; diff --git a/frontend/src/utils/sanitize.js b/frontend/src/utils/sanitize.js new file mode 100644 index 0000000..bb8582f --- /dev/null +++ b/frontend/src/utils/sanitize.js @@ -0,0 +1,13 @@ +import DOMPurify from "dompurify"; + +export const sanitize = (html) => + DOMPurify.sanitize(html, { + FORBID_TAGS: ["img", "svg", "math", "script", "iframe"], + FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onmouseout"], + USE_PROFILES: { html: true }, + SAFE_FOR_TEMPLATES: true, + SANITIZE_DOM: true, + KEEP_CONTENT: false, + }); + +export default { sanitize }; diff --git a/frontend/src/utils/validation.js b/frontend/src/utils/validation.js new file mode 100644 index 0000000..616430d --- /dev/null +++ b/frontend/src/utils/validation.js @@ -0,0 +1,111 @@ +/** + * Password validation utilities + */ + +/** + * Calculate password strength score + * @param {string} password - Password to evaluate + * @returns {number} Strength score from 0-5 + */ +export function calculatePasswordStrength(password) { + if (!password) return 0; + + const minLength = 10; + const maxLength = 32; + + if (password.length < minLength || password.length > maxLength) { + return 0; + } + + let score = 1; + + if (/[a-z]/.test(password)) score += 1; + if (/[A-Z]/.test(password)) score += 1; + if (/[0-9]/.test(password)) score += 1; + if (/[^A-Za-z0-9\s]/.test(password)) score += 1; + + const lengthStep = Math.floor((maxLength - minLength) / 10); + score += Math.floor((password.length - minLength) / lengthStep); + + return Math.min(score, 5); +} + +/** + * Get password strength text description + * @param {number} strength - Strength score from calculatePasswordStrength + * @returns {string} Text description + */ +export function getPasswordStrengthText(strength) { + if (strength <= 1) return "Weak"; + if (strength <= 2) return "Fair"; + if (strength <= 3) return "Good"; + if (strength <= 4) return "Strong"; + return "Very Strong"; +} + +/** + * Get password strength color classes + * @param {number} strength - Strength score from calculatePasswordStrength + * @returns {string} CSS color classes + */ +export function getPasswordStrengthColor(strength) { + if (strength <= 1) return "text-red-600 bg-red-600"; + if (strength <= 2) return "text-orange-600 bg-orange-600"; + if (strength <= 3) return "text-yellow-600 bg-yellow-600"; + if (strength <= 4) return "text-blue-600 bg-blue-600"; + return "text-green-600 bg-green-600"; +} + +/** + * Get password strength percentage for progress bar + * @param {number} strength - Strength score from calculatePasswordStrength + * @returns {number} Percentage (0-100) + */ +export function getPasswordStrengthPercentage(strength) { + return (strength / 5) * 100; +} + +/** + * Validate password with comprehensive rules + * @param {string} password - Password to validate + * @returns {Object} Validation result with isValid and errors array + */ +export function validatePassword(password) { + const errors = []; + + if (!password) { + errors.push("Please enter a password"); + } else if (password.length < 10) { + errors.push("Password must be at least 10 characters"); + } else if (password.length > 32) { + errors.push("Password cannot exceed 32 characters"); + } else if (!/(?=.*[a-zA-Z])(?=.*[0-9])/.test(password)) { + errors.push("Password must contain both letters and numbers"); + } + + return { + isValid: errors.length === 0, + errors, + }; +} + +/** + * Validate password confirmation + * @param {string} password - Original password + * @param {string} confirmPassword - Confirmation password + * @returns {Object} Validation result with isValid and errors array + */ +export function validatePasswordConfirmation(password, confirmPassword) { + const errors = []; + + if (!confirmPassword) { + errors.push("Please confirm your password"); + } else if (password !== confirmPassword) { + errors.push("Passwords do not match"); + } + + return { + isValid: errors.length === 0, + errors, + }; +} diff --git a/frontend/src/views/AuthCallback.vue b/frontend/src/views/AuthCallback.vue new file mode 100644 index 0000000..bbbeb4f --- /dev/null +++ b/frontend/src/views/AuthCallback.vue @@ -0,0 +1,237 @@ + + + + + + Verifying Authentication + + + + + + + Please wait while we verify your authentication... + + + + + + + + Verification Failed + + + {{ error }} + + + + Retry Verification + + + Return to {{ getActionDisplayName() }} + + + + + + + + + + + Verification Successful + + + Authentication verified successfully. Redirecting... + + + + + + + + + diff --git a/frontend/src/components/CourseDetail.vue b/frontend/src/views/CourseDetail.vue similarity index 75% rename from frontend/src/components/CourseDetail.vue rename to frontend/src/views/CourseDetail.vue index 0327953..520bae1 100644 --- a/frontend/src/components/CourseDetail.vue +++ b/frontend/src/views/CourseDetail.vue @@ -1,6 +1,5 @@ - - @@ -47,9 +45,7 @@ - - {{ course.course_code }} | {{ course.course_title }} @@ -78,11 +74,8 @@ - - - - - - @@ -158,13 +148,13 @@ - @@ -227,7 +216,6 @@ - - @@ -329,7 +316,9 @@ > {{ item[0] }} @@ -352,7 +341,6 @@ - - + @@ -408,14 +396,26 @@ - Write a Review for {{ course.course_code }} - + + + + + Please fix the following errors: + + + + {{ field }}: + {{ Array.isArray(msgs) ? msgs[0] : msgs }} + + + + + + {{ formErrors.term[0] }} + + + {{ formErrors.professor[0] }} + Review @@ -481,11 +491,14 @@ 'preview', 'htmlPreview', ]" - previewTheme="github" + preview-theme="github" tabindex="0" style="height: 300px" class="mt-1 block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 markdown-content" /> + + {{ formErrors.comments[0] }} + - - - Your Review + Your Review Delete Review - {{ userReview.term }} @@ -531,20 +541,18 @@ }} - - {{ userReviewExpanded ? "Show Less" : "Read More" }} - Thanks for writing a review of this course! @@ -585,8 +592,8 @@ Delete Review @@ -603,18 +610,16 @@ import { useRoute, useRouter } from "vue-router"; import { ExclamationTriangleIcon, CheckIcon, - ChevronUpIcon, - ChevronDownIcon, LockClosedIcon, UsersIcon, InformationCircleIcon, - HandThumbUpIcon, - HandThumbDownIcon, } from "@heroicons/vue/24/outline"; import { MdEditor, MdPreview } from "md-editor-v3"; import "md-editor-v3/lib/style.css"; -import DOMPurify from "dompurify"; -import ReviewPagination from "./ReviewPagination.vue"; +import { sanitize } from "../utils/sanitize"; +import { useAuth } from "../composables/useAuth"; +import { useReviews } from "../composables/useReviews"; +import ReviewPagination from "../components/ReviewPagination.vue"; const route = useRoute(); const router = useRouter(); @@ -622,7 +627,13 @@ const course = ref(null); const loading = ref(true); const error = ref(null); const currentTerm = "25S"; -const isAuthenticated = ref(false); +const { isAuthenticated, checkAuthentication } = useAuth(); +const { + fetchUserReview: fetchUserReviewFn, + submitReview: submitReviewFn, + deleteReview: deleteReviewFn, + vote: voteFn, +} = useReviews(); const userReview = ref(null); const userReviewExpanded = ref(false); const newReview = ref({ @@ -630,9 +641,10 @@ const newReview = ref({ professor: "", comments: "", }); +const formErrors = ref({}); const courseId = computed(() => { - return route.params.course_id; + return route.params.courseId; }); const truncatedUserReviewContent = computed(() => { @@ -659,8 +671,6 @@ onMounted(async () => { } await checkAuthentication(); - // Only fetch user review if authenticated and they are NOT able to write a review - // (can_write_review === false means they already have a review) if (isAuthenticated.value && course.value && !course.value.can_write_review) { await fetchUserReview(); } @@ -682,37 +692,14 @@ const fetchCourse = async () => { } }; -const checkAuthentication = async () => { - try { - const response = await fetch("/api/user/status/"); - if (response.ok) { - const data = await response.json(); - isAuthenticated.value = data.isAuthenticated; - } else { - isAuthenticated.value = false; - } - } catch (e) { - console.error("Error checking authentication:", e); - isAuthenticated.value = false; - } -}; const fetchUserReview = async () => { if (!isAuthenticated.value || !courseId.value) return; - try { - const response = await fetch(`/api/course/${courseId.value}/my-review/`); - if (response.ok) { - const data = await response.json(); - // Handle case where API might return a list - take first one - userReview.value = Array.isArray(data) ? data[0] : data; - } else if (response.status === 404) { - // User hasn't written a review yet - this is expected - userReview.value = null; - } else { - console.error("Error fetching user review:", response.status); - } + const data = await fetchUserReviewFn(courseId.value); + userReview.value = data ?? null; } catch (e) { console.error("Error fetching user review:", e); + userReview.value = null; } }; @@ -724,91 +711,26 @@ const vote = async (value, forLayup) => { return; } try { - const postData = { value, forLayup }; - const response = await fetch(`/api/course/${courseId.value}/vote`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": getCookie("csrftoken"), - }, - body: JSON.stringify(postData), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); + const data = await voteFn(courseId.value, value, forLayup); + if (!data) return; if (forLayup) { course.value.difficulty_score = data.new_score; - if (data.was_unvote) { - course.value.difficulty_vote = null; - } else { - course.value.difficulty_vote = { - value: value, - }; - } + course.value.difficulty_vote = data.was_unvote ? null : { value }; if (typeof data.new_vote_count !== "undefined") { course.value.difficulty_vote_count = data.new_vote_count; } } else { course.value.quality_score = data.new_score; - if (data.was_unvote) { - course.value.quality_vote = null; - } else { - course.value.quality_vote = { - value: value, - }; - } + course.value.quality_vote = data.was_unvote ? null : { value }; if (typeof data.new_vote_count !== "undefined") { course.value.quality_vote_count = data.new_vote_count; } } - // Update new_vote_count if present in response } catch (e) { console.error("Error voting:", e); } }; -const voteOnReview = async (reviewId, isKudos) => { - if (!isAuthenticated.value) { - if (confirm("Please login to vote on reviews!")) { - router.push("/accounts/login"); - } - return; - } - - try { - const response = await fetch(`/api/review/${reviewId}/vote/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": getCookie("csrftoken"), - }, - body: JSON.stringify({ is_kudos: isKudos }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - - // Update the review in the course data - const reviewIndex = course.value.review_set.findIndex( - (r) => r.id === reviewId, - ); - if (reviewIndex !== -1) { - course.value.review_set[reviewIndex].kudos_count = data.kudos_count; - course.value.review_set[reviewIndex].dislike_count = data.dislike_count; - course.value.review_set[reviewIndex].user_vote = data.user_vote; - } - } catch (e) { - console.error("Error voting on review:", e); - alert("Error voting on review. Please try again."); - } -}; - const updateReviewData = (updateData) => { const reviewIndex = course.value.review_set.findIndex( (r) => r.id === updateData.reviewId, @@ -821,96 +743,52 @@ const updateReviewData = (updateData) => { } }; -function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== "") { - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === name + "=") { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } +const validateReview = () => { + const errs = {}; + if (!newReview.value.term || newReview.value.term.length !== 3) { + errs.term = ["Please provide a valid term, e.g. 24F"]; + } + if ( + !newReview.value.professor || + newReview.value.professor.trim().split(/\s+/).length < 2 + ) { + errs.professor = [ + "Please provide the professor's full name, e.g. John Smith", + ]; + } + if (!newReview.value.comments || newReview.value.comments.length < 30) { + errs.comments = ["Please write a longer review (at least 30 characters)"]; } - return cookieValue; -} -// Sanitize function using DOMPurify with enhanced security configuration -const sanitize = (html) => - DOMPurify.sanitize(html, { - FORBID_TAGS: ["img", "svg", "math", "script", "iframe"], - FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onmouseout"], - USE_PROFILES: { html: true }, // Only allow HTML, no SVG or MathML - SAFE_FOR_TEMPLATES: true, // Protect against template injection - SANITIZE_DOM: true, // Protect against DOM clobbering - KEEP_CONTENT: false, // Remove content of forbidden tags - }); + formErrors.value = errs; + return Object.keys(errs).length === 0; +}; const submitReview = async () => { + formErrors.value = {}; if (!isAuthenticated.value) { alert("You must be logged in to submit a review."); return; } - try { - const response = await fetch(`/api/course/${courseId.value}/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": getCookie("csrftoken"), - }, - body: JSON.stringify(newReview.value), - }); - if (!response.ok) { - let errorMessage = `HTTP error! status: ${response.status}`; - try { - const errorData = await response.json(); - // Handle Django REST Framework serializer errors (which are objects with field arrays) - if ( - errorData && - typeof errorData === "object" && - !Array.isArray(errorData) - ) { - const errorLines = []; - for (const [field, messages] of Object.entries(errorData)) { - if (Array.isArray(messages) && messages.length > 0) { - // Join multiple messages for a single field with a space - errorLines.push(`${field}: ${messages.join(" ")}`); - } else if (typeof messages === "string") { - errorLines.push(`${field}: ${messages}`); - } - } - if (errorLines.length > 0) { - errorMessage = errorLines.join("\n"); // Join fields with a newline - } else { - // Fallback if structure is not as expected - errorMessage = JSON.stringify(errorData); - } - } else if (errorData.detail) { - // Handle generic DRF error responses - errorMessage = errorData.detail; - } else if (typeof errorData === "string") { - errorMessage = errorData; - } else { - // Fallback for other object types or arrays - errorMessage = JSON.stringify(errorData); - } - } catch (e) { - // If parsing JSON fails, use the status text - errorMessage = response.statusText || errorMessage; - } - throw new Error(errorMessage); - } - course.value = await response.json(); - newReview.value = { term: "", professor: "", comments: "" }; - // Refresh user review after successful submission - await fetchUserReview(); + if (!validateReview()) { + return; + } - alert("Review submitted successfully!"); + try { + const updatedCourse = await submitReviewFn(courseId.value, newReview.value); + if (updatedCourse) { + course.value = updatedCourse; + newReview.value = { term: "", professor: "", comments: "" }; + await fetchUserReview(); + alert("Review submitted successfully!"); + } } catch (error) { console.error("Error submitting review:", error); - // Use alert with newline characters preserved - alert(`Error submitting review:\n${error.message}`); + if (error && error.errors && typeof error.errors === "object") { + formErrors.value = error.errors; + } else { + alert(`Error submitting review:\n${error.message}`); + } } }; @@ -927,27 +805,12 @@ const deleteReview = async () => { } try { - const response = await fetch(`/api/course/${courseId.value}/review/`, { - method: "DELETE", - headers: { - "X-CSRFToken": getCookie("csrftoken"), - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - `HTTP error! status: ${response.status}, detail: ${JSON.stringify(errorData)}`, - ); + const updatedCourse = await deleteReviewFn(courseId.value); + if (updatedCourse) { + course.value = updatedCourse; + userReview.value = null; + alert("Review deleted successfully!"); } - - // Refresh the course data to reflect the deletion - course.value = await response.json(); - - // Clear user review after successful deletion - userReview.value = null; - - alert("Review deleted successfully!"); } catch (error) { console.error("Error deleting review:", error); alert(`Error deleting review: ${error.message}`); diff --git a/frontend/src/components/CourseReviewSearch.vue b/frontend/src/views/CourseReviewSearch.vue similarity index 76% rename from frontend/src/components/CourseReviewSearch.vue rename to frontend/src/views/CourseReviewSearch.vue index b2ab944..299711e 100644 --- a/frontend/src/components/CourseReviewSearch.vue +++ b/frontend/src/views/CourseReviewSearch.vue @@ -26,15 +26,15 @@ >" - + @@ -74,8 +74,9 @@ import { ref, onMounted, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import { MagnifyingGlassIcon } from "@heroicons/vue/20/solid"; import "md-editor-v3/lib/style.css"; -import DOMPurify from "dompurify"; -import ReviewPagination from "./ReviewPagination.vue"; +import { sanitize } from "../utils/sanitize"; +import { useAuth } from "../composables/useAuth"; +import ReviewPagination from "../components/ReviewPagination.vue"; const props = defineProps({ courseId: { @@ -84,17 +85,6 @@ const props = defineProps({ }, }); -// Sanitize function using DOMPurify with enhanced security configuration -const sanitize = (html) => - DOMPurify.sanitize(html, { - FORBID_TAGS: ["img", "svg", "math", "script", "iframe"], - FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onmouseout"], - USE_PROFILES: { html: true }, // Only allow HTML, no SVG or MathML - SAFE_FOR_TEMPLATES: true, // Protect against template injection - SANITIZE_DOM: true, // Protect against DOM clobbering - KEEP_CONTENT: false, // Remove content of forbidden tags - }); - const route = useRoute(); const router = useRouter(); const searchQuery = ref(""); @@ -105,13 +95,12 @@ const reviewsFullCount = ref(0); const remaining = ref(0); const courseShortName = ref(""); const query = ref(""); -const isAuthenticated = ref(false); +const { isAuthenticated, checkAuthentication } = useAuth(); const fetchReviews = async () => { loading.value = true; error.value = null; - // Check authentication before fetching reviews if (!isAuthenticated.value) { error.value = "Please log in to search reviews."; loading.value = false; @@ -120,7 +109,9 @@ const fetchReviews = async () => { try { const response = await fetch( - `/api/course/${props.courseId}/review_search/?q=${encodeURIComponent(searchQuery.value)}`, + `/api/course/${props.courseId}/review_search/?q=${encodeURIComponent( + searchQuery.value, + )}`, ); if (!response.ok) { if (response.status === 401 || response.status === 403) { @@ -137,7 +128,7 @@ const fetchReviews = async () => { reviewsFullCount.value = data.reviews_full_count; remaining.value = data.remaining; courseShortName.value = data.course_short_name; - query.value = data.query; // Update the displayed query + query.value = data.query; } catch (e) { error.value = e.message; } finally { @@ -146,24 +137,20 @@ const fetchReviews = async () => { }; const performSearch = () => { - router.push({ query: { q: searchQuery.value } }); // Update the query in the route + router.push({ query: { q: searchQuery.value } }); }; -// Watch for changes in the route query watch( () => route.query.q, (newQuery) => { - // Always update searchQuery and fetch when route changes searchQuery.value = newQuery || ""; fetchReviews(); }, { immediate: true }, ); -// Watch for authentication status changes watch(isAuthenticated, (newAuth) => { if (newAuth) { - // User just logged in, fetch reviews fetchReviews(); } }); @@ -174,21 +161,6 @@ onMounted(async () => { await fetchReviews(); }); -const checkAuthentication = async () => { - try { - const response = await fetch("/api/user/status/"); - if (response.ok) { - const data = await response.json(); - isAuthenticated.value = data.isAuthenticated; - } else { - isAuthenticated.value = false; - } - } catch (e) { - console.error("Error checking authentication:", e); - isAuthenticated.value = false; - } -}; - const updateReviewData = (updateData) => { const reviewIndex = reviews.value.findIndex( (r) => r.id === updateData.reviewId, diff --git a/frontend/src/components/Landing.vue b/frontend/src/views/Home.vue similarity index 89% rename from frontend/src/components/Landing.vue rename to frontend/src/views/Home.vue index c0d84d4..2b231e2 100644 --- a/frontend/src/components/Landing.vue +++ b/frontend/src/views/Home.vue @@ -1,6 +1,5 @@ - - Search for courses @@ -60,40 +58,39 @@ Search Courses - Best Classes Layups {{ !isAuthenticated ? "(login required)" : "" }} Browse All @@ -137,15 +134,15 @@ import { ref, onMounted } from "vue"; import { useRouter } from "vue-router"; import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline"; +import { useAuth } from "../composables/useAuth"; const router = useRouter(); const reviewCount = ref(0); -const isAuthenticated = ref(false); +const { isAuthenticated } = useAuth(); const searchQuery = ref(""); onMounted(async () => { await fetchLandingData(); - await checkAuthentication(); }); const fetchLandingData = async () => { @@ -161,23 +158,11 @@ const fetchLandingData = async () => { } }; -const checkAuthentication = async () => { - try { - const response = await fetch("/api/user/status/"); - if (response.ok) { - const data = await response.json(); - isAuthenticated.value = data.isAuthenticated; - } - } catch (error) { - console.error("Error checking authentication:", error); - } -}; - const performSearch = () => { if (searchQuery.value.trim().length >= 2) { router.push({ - path: "/courses", // Navigate to the new courses page - query: { code: searchQuery.value.trim().toUpperCase() }, // Use 'code' query param + path: "/courses", + query: { code: searchQuery.value.trim().toUpperCase() }, }); } else { alert("Search query must be at least 2 characters long"); @@ -199,6 +184,6 @@ const goToLayups = () => { }; const goToDepartments = () => { - router.push("/courses"); // Simply go to the main courses page + router.push("/courses"); }; diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..b30ec2c --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,247 @@ + + + + + + Sign in to your account + + + Access your JI Course Review dashboard + + + + + + + + + + Password Login + + + SJTU Authentication + + + + + + + + + Login failed + {{ error }} + + + + + + + + Email address + + + + + + + + + + Password + + + + Forgot password? + + + + + + + + + + + + + + + + {{ loading ? "Signing in..." : "Sign in" }} + + + + + + + + + + + Don't have an account? + + Sign up here + + + + + + + diff --git a/frontend/src/views/ResetPassword.vue b/frontend/src/views/ResetPassword.vue new file mode 100644 index 0000000..2a93577 --- /dev/null +++ b/frontend/src/views/ResetPassword.vue @@ -0,0 +1,88 @@ + + + + + + Reset your password + + + Forgot your password? Verify your identity to reset it + + + Or + back to sign in + + + + + + + + + + + + + + diff --git a/frontend/src/views/Signup.vue b/frontend/src/views/Signup.vue new file mode 100644 index 0000000..1ce8f5d --- /dev/null +++ b/frontend/src/views/Signup.vue @@ -0,0 +1,91 @@ + + + + + + Create your account + + + Join JI Course Review community + + + Already have an account? + + Sign in + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 3f3dfb5..d099abc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "django-crispy-forms>=2.3", "django-debug-toolbar>=5.0.1", "django-pipeline>=4.0.0", + "httpx>=0.28.1", "psycopg2-binary>=2.9.10", "python-dateutil>=2.9.0", "python-dotenv>=1.0.1", @@ -22,6 +23,8 @@ dependencies = [ "ptpython>=3.0.29", "djangorestframework>=3.16.0", "django-cors-headers>=4.7.0", + "django-redis", + "pyyaml>=6.0.2", ] [tool.uv] diff --git a/scripts/__init__.py b/scripts/__init__.py index d73d887..15bc822 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -97,7 +97,7 @@ def crawl_and_save_instructors(): break if not instructor_names: - print(f" No instructors found") + print("No instructors found") continue print(f" Found instructors: {', '.join(instructor_names)}") diff --git a/scripts/migrate_votes_to_rating_system.py b/scripts/migrate_votes_to_rating_system.py deleted file mode 100644 index 08ff54f..0000000 --- a/scripts/migrate_votes_to_rating_system.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python -""" -Script to migrate existing votes to new rating system. -This script converts old +1/-1 votes to the new 1-5 rating system. -""" - -import os -import sys - -import django - -# Add the project directory to the path dynamically -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) -sys.path.insert(0, project_root) - -# Set Django settings -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website.settings") -django.setup() - -from django.db import transaction - -from apps.web.models import Course, Vote - - -def migrate_votes(): - """ - Migrate existing votes to new rating system: - - Old +1 votes become 4 (good rating) - - Old -1 votes become 2 (poor rating) - - Old 0 votes remain 0 (unvoted) - """ - print("Starting vote migration...") - - with transaction.atomic(): - # Convert +1 votes to 4 (good rating) - positive_votes = Vote.objects.filter(value=1) - positive_count = positive_votes.count() - positive_votes.update(value=4) - print(f"Converted {positive_count} positive votes to rating 4") - - # Convert -1 votes to 2 (poor rating) - negative_votes = Vote.objects.filter(value=-1) - negative_count = negative_votes.count() - negative_votes.update(value=2) - print(f"Converted {negative_count} negative votes to rating 2") - - # Recalculate all course scores - print("Recalculating course scores...") - courses = Course.objects.all() - for course in courses: - # Recalculate quality score - quality_votes = Vote.objects.filter( - course=course, category=Vote.CATEGORIES.QUALITY - ).exclude(value=0) - - if quality_votes.exists(): - avg_quality = ( - sum(vote.value for vote in quality_votes) / quality_votes.count() - ) - course.quality_score = round(avg_quality, 1) - else: - course.quality_score = 0.0 - - # Recalculate difficulty score - difficulty_votes = Vote.objects.filter( - course=course, category=Vote.CATEGORIES.DIFFICULTY - ).exclude(value=0) - - if difficulty_votes.exists(): - avg_difficulty = ( - sum(vote.value for vote in difficulty_votes) - / difficulty_votes.count() - ) - course.difficulty_score = round(avg_difficulty, 1) - else: - course.difficulty_score = 0.0 - - course.save() - - print(f"Updated scores for {courses.count()} courses") - print("Vote migration completed successfully!") - - -if __name__ == "__main__": - migrate_votes() diff --git a/scripts/pre-commit-format.py b/scripts/pre-commit-format.py index 5c9e216..e0e8c90 100644 --- a/scripts/pre-commit-format.py +++ b/scripts/pre-commit-format.py @@ -10,7 +10,7 @@ def main(): try: # Run make format and capture exit code - result = subprocess.run(["make", "format"], check=True) + subprocess.run(["make", "format"], check=True) # Format succeeded, stage the changes subprocess.run(["git", "add", "--update"], check=True) diff --git a/uv.lock b/uv.lock index cf9c4de..7e5ca33 100644 --- a/uv.lock +++ b/uv.lock @@ -19,12 +19,15 @@ dependencies = [ { name = "django-crispy-forms" }, { name = "django-debug-toolbar" }, { name = "django-pipeline" }, + { name = "django-redis" }, { name = "djangorestframework" }, + { name = "httpx" }, { name = "psycopg2-binary" }, { name = "ptpython" }, { name = "python-dateutil" }, { name = "python-dotenv" }, { name = "pytz" }, + { name = "pyyaml" }, { name = "redis" }, { name = "requests" }, ] @@ -48,12 +51,15 @@ requires-dist = [ { name = "django-crispy-forms", specifier = ">=2.3" }, { name = "django-debug-toolbar", specifier = ">=5.0.1" }, { name = "django-pipeline", specifier = ">=4.0.0" }, + { name = "django-redis" }, { name = "djangorestframework", specifier = ">=3.16.0" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "ptpython", specifier = ">=3.0.29" }, { name = "python-dateutil", specifier = ">=2.9.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "pytz", specifier = ">=2025.1" }, + { name = "pyyaml", specifier = ">=6.0.2" }, { name = "redis", specifier = ">=5.2.1" }, { name = "requests", specifier = ">=2.32.3" }, ] @@ -82,6 +88,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/f9/f1c10e223c7b56a38109a3f2eb4e7fe9a757ea3ed3a166754fb30f65e466/ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec", size = 63675, upload-time = "2019-04-29T20:23:53.83Z" }, ] +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + [[package]] name = "appdirs" version = "1.4.4" @@ -456,6 +476,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/05/7258f4b27839d186dfc29f9416475d236c2a64f25ff64a22e77d700eb753/django_pipeline-4.0.0-py3-none-any.whl", hash = "sha256:90e50c15387a6e051ee1a6ce2aaca333823ccfb23695028790f74412bde7d7db", size = 75384, upload-time = "2024-12-06T12:10:08.162Z" }, ] +[[package]] +name = "django-redis" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" }, +] + [[package]] name = "django-timezone-field" version = "7.1" @@ -523,6 +556,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/e6/f9d759788518a6248684e3afeb3691f3ab0276d769b6217a1533362298c8/greenlet-3.2.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d6668caf15f181c1b82fb6406f3911696975cc4c37d782e19cb7ba499e556189", size = 269897, upload-time = "2025-04-22T14:27:14.044Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.13" @@ -768,11 +838,11 @@ wheels = [ [[package]] name = "redis" -version = "5.2.1" +version = "6.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355, upload-time = "2024-12-06T09:50:41.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/0551e01ba52b944f97480721656578c8a7c46b51b99d66814f85fe3a4f3e/redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977", size = 4639129, upload-time = "2025-05-28T05:01:18.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502, upload-time = "2024-12-06T09:50:39.656Z" }, + { url = "https://files.pythonhosted.org/packages/13/67/e60968d3b0e077495a8fee89cf3f2373db98e528288a48f1ee44967f6e8c/redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e", size = 278659, upload-time = "2025-05-28T05:01:16.955Z" }, ] [[package]] @@ -808,6 +878,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "soupsieve" version = "2.7" diff --git a/website/development.yaml b/website/development.yaml new file mode 100644 index 0000000..14cd0b8 --- /dev/null +++ b/website/development.yaml @@ -0,0 +1,134 @@ +# website/development.yaml + +INSTALLED_APPS: + - "django.contrib.admin" + - "django.contrib.auth" + - "django.contrib.contenttypes" + - "django.contrib.sessions" + - "django.contrib.messages" + - "django.contrib.staticfiles" + - "django.contrib.humanize" + - "debug_toolbar" + - "pipeline" + - "crispy_forms" + - "crispy_bootstrap4" + - "django_celery_beat" + - "django_celery_results" + - "rest_framework" + - "corsheaders" + - "apps.analytics" + - "apps.recommendations" + - "apps.spider" + - "apps.web" + - "apps.auth" + +MIDDLEWARE: + - "corsheaders.middleware.CorsMiddleware" + - "django.middleware.security.SecurityMiddleware" + - "django.contrib.sessions.middleware.SessionMiddleware" + - "django.middleware.common.CommonMiddleware" + - "django.middleware.csrf.CsrfViewMiddleware" + - "django.contrib.auth.middleware.AuthenticationMiddleware" + - "django.contrib.messages.middleware.MessageMiddleware" + - "django.middleware.clickjacking.XFrameOptionsMiddleware" + - "debug_toolbar.middleware.DebugToolbarMiddleware" + +ROOT_URLCONF: "website.urls" + +TEMPLATES: + - BACKEND: "django.template.backends.django.DjangoTemplates" + DIRS: [] + APP_DIRS: True + OPTIONS: + context_processors: + - "django.template.context_processors.debug" + - "django.template.context_processors.request" + - "django.contrib.auth.context_processors.auth" + - "django.contrib.messages.context_processors.messages" + +CRISPY_TEMPLATE_PACK: "bootstrap4" + +WSGI_APPLICATION: "website.wsgi.application" + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators +AUTH_PASSWORD_VALIDATORS: + - NAME: "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + - NAME: "django.contrib.auth.password_validation.MinimumLengthValidator" + - NAME: "django.contrib.auth.password_validation.CommonPasswordValidator" + - NAME: "django.contrib.auth.password_validation.NumericPasswordValidator" + +CELERY_RESULT_BACKEND: "django-db" +CELERY_TIMEZONE: "Asia/Shanghai" + +AUTO_IMPORT_CRAWLED_DATA: "True" +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ +LANGUAGE_CODE: "en-us" +TIME_ZONE: "UTC" +USE_I18N: True +USE_TZ: True +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ +STATIC_ROOT: "staticfiles" +STATIC_URL: "/static/" +STATICFILES_STORAGE: "pipeline.storage.ManifestStaticFilesStorage" +STATICFILES_FINDERS: + - "django.contrib.staticfiles.finders.FileSystemFinder" + - "django.contrib.staticfiles.finders.AppDirectoriesFinder" + - "pipeline.finders.PipelineFinder" + +PIPELINE: + JAVASCRIPT: + app: + source_filenames: + - "js/plugins.js" + - "js/vendor/jquery.highlight-5.js" + - "js/web/base.jsx" + - "js/web/common.jsx" + - "js/web/landing.jsx" + - "js/web/current_term.jsx" + - "js/web/course_detail.jsx" + - "js/web/course_review_search.jsx" + output_filename: "js/app.js" + STYLESHEETS: + app: + source_filenames: + - "css/web/base.css" + - "css/web/current_term.css" + - "css/web/course_detail.css" + - "css/web/course_review_search.css" + - "css/web/landing.css" + - "css/web/auth.css" + output_filename: "css/app.css" + extra_context: + media: "screen,projection" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field +DEFAULT_AUTO_FIELD: "django.db.models.BigAutoField" + +SESSION_COOKIE_AGE: 2592000 # 30 days +SESSION_SAVE_EVERY_REQUEST: True +SESSION_ENGINE: "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS: "default" + +ALLOWED_HOSTS: + - "localhost" + - "127.0.0.1" + - "0.0.0.0" + +# OAuth +AUTH: + OTP_TIME_OUT: 120 # 2 min + TEMP_TOKEN_TIMEOUT: 600 # 10 min + ACTION_LIST: + - "signup" + - "login" + - "reset_password" + TOKEN_RATE_LIMIT: 5 # max 5 callback attempts per temp_token + TOKEN_RATE_LIMIT_TIME: 600 # 10 minutes window + PASSWORD_LENGTH_MIN: 10 + PASSWORD_LENGTH_MAX: 32 + QUEST_BASE_URL: "https://wj.sjtu.edu.cn/api/v1/public/export" + EMAIL_DOMAIN_NAME: "sjtu.edu.cn" diff --git a/website/settings.py b/website/settings.py index 1985ae6..b0295bf 100644 --- a/website/settings.py +++ b/website/settings.py @@ -1,25 +1,35 @@ -""" -Django settings for CourseReview project. - -Generated by 'django-admin startproject' using Django 5.0.8. - -For more information on this file, see -https://docs.djangoproject.com/en/5.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.0/ref/settings/ -""" - import os - +import yaml +from pathlib import Path from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() +TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY") +# url and api for wj platform +SIGNUP_QUEST_API_KEY = os.getenv("SIGNUP_QUEST_API_KEY") +SIGNUP_QUEST_URL = os.getenv("SIGNUP_QUEST_URL") +SIGNUP_QUEST_QUESTIONID = os.getenv("SIGNUP_QUEST_QUESTIONID") +LOGIN_QUEST_API_KEY = os.getenv("LOGIN_QUEST_API_KEY") +LOGIN_QUEST_URL = os.getenv("LOGIN_QUEST_URL") +LOGIN_QUEST_QUESTIONID = os.getenv("LOGIN_QUEST_QUESTIONID") +RESET_QUEST_API_KEY = os.getenv("RESET_QUEST_API_KEY") +RESET_QUEST_URL = os.getenv("RESET_QUEST_URL") +RESET_QUEST_QUESTIONID = os.getenv("RESET_QUEST_QUESTIONID") + +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Load development config +with open(Path(BASE_DIR) / "development.yaml") as f: + config = yaml.safe_load(f) + +for key, value in config.items(): + globals()[key] = value + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ @@ -29,67 +39,6 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.getenv("DEBUG") == "True" -ALLOWED_HOSTS = [] - -# Application definition - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "django.contrib.humanize", - "debug_toolbar", - "pipeline", - "crispy_forms", - "crispy_bootstrap4", - # "hijack", # hijack-admin (relies on compact) deprecated and merged into hijack - "django_celery_beat", - "django_celery_results", - "rest_framework", - "corsheaders", - "apps.analytics", - "apps.recommendations", - "apps.spider", - "apps.web", -] - -MIDDLEWARE = [ - "corsheaders.middleware.CorsMiddleware", - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "debug_toolbar.middleware.DebugToolbarMiddleware", -] - -ROOT_URLCONF = "website.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -CRISPY_TEMPLATE_PACK = "bootstrap4" - -WSGI_APPLICATION = "website.wsgi.application" - # Rest Framework @@ -97,7 +46,7 @@ CORS_ALLOW_ALL_ORIGINS = True else: CORS_ALLOWED_ORIGINS = [ - os.getenv("FRONTEND_URL"), + FRONTEND_URL, "http://127.0.0.1:8080", "http://localhost:8080", ] @@ -117,96 +66,26 @@ } } -# Password validation -# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] CELERY_BROKER_URL = os.environ["REDIS_URL"] -CELERY_RESULT_BACKEND = "django-db" -CELERY_TIMEZONE = "Asia/Shanghai" - -# Spider - -AUTO_IMPORT_CRAWLED_DATA = "True" -# Internationalization -# https://docs.djangoproject.com/en/5.0/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ - - -STATIC_ROOT = "staticfiles" -STATIC_URL = "/static/" STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) -STATICFILES_STORAGE = "pipeline.storage.ManifestStaticFilesStorage" -STATICFILES_FINDERS = ( - "django.contrib.staticfiles.finders.FileSystemFinder", - "django.contrib.staticfiles.finders.AppDirectoriesFinder", - "pipeline.finders.PipelineFinder", -) -ROOT_ASSETS_DIR = os.path.join(BASE_DIR, "root_assets") -PIPELINE = { - # 'COMPILERS': ('react.utils.pipeline.JSXCompiler', ), - "JAVASCRIPT": { - "app": { - "source_filenames": ( - "js/plugins.js", - "js/vendor/jquery.highlight-5.js", - "js/web/base.jsx", - "js/web/common.jsx", - "js/web/landing.jsx", - "js/web/current_term.jsx", - "js/web/course_detail.jsx", - "js/web/course_review_search.jsx", - ), - "output_filename": "js/app.js", - } - }, - "STYLESHEETS": { - "app": { - "source_filenames": ( - "css/web/base.css", - "css/web/current_term.css", - "css/web/course_detail.css", - "css/web/course_review_search.css", - "css/web/landing.css", - "css/web/auth.css", - ), - "output_filename": "css/app.css", - "extra_context": { - "media": "screen,projection", - }, - } - }, -} -# Default primary key field type -# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +ROOT_ASSETS_DIR = os.path.join(BASE_DIR, "root_assets") -SESSION_COOKIE_AGE = 3153600000 # 100 years SESSION_COOKIE_SECURE = not DEBUG + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6379/1"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"max_connections": 100}, + }, + "KEY_PREFIX": "coursereview", + } +} diff --git a/website/urls.py b/website/urls.py index 6df3173..f533d93 100644 --- a/website/urls.py +++ b/website/urls.py @@ -1,34 +1,45 @@ -"""layup_list URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/dev/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Add an import: from blog import urls as blog_urls - 2. Import the include() function: from django.urls import path, include - 3. Add a URL to urlpatterns: path('blog/', include(blog_urls)) -""" - import django.contrib.auth.views as authviews from django.contrib import admin from django.urls import re_path + +from apps.auth import views as auth_views from apps.analytics import views as aviews from apps.recommendations import views as rviews from apps.spider import views as spider_views from apps.web import views urlpatterns = [ + # OAuth + re_path( + r"^api/auth/initiate/$", + auth_views.auth_initiate_api, + name="auth_initiate_api", + ), + re_path( + r"^api/auth/verify/$", + auth_views.verify_callback_api, + name="verify_callback_api", + ), + # Backwards-compatible alias (some front-end code calls verify-callback) + re_path( + r"^api/auth/verify-callback/$", + auth_views.verify_callback_api, + name="verify_callback_api_alias", + ), + re_path( + r"^api/auth/password/$", + auth_views.auth_reset_password_api, + name="auth_reset_password_api", + ), + re_path(r"^api/auth/signup/$", auth_views.auth_signup_api, name="auth_signup_api"), + # email+password login + re_path(r"^api/auth/login/$", auth_views.auth_login_api, name="auth_login_api"), + # log out + re_path(r"^api/auth/logout/?$", auth_views.auth_logout_api, name="auth_logout_api"), # administrative re_path(r"^admin/", admin.site.urls), re_path(r"^api/user/status/?", views.user_status, name="user_status"), - re_path(r"^api/accounts/login/$", views.auth_login_api, name="auth_login_api"), re_path(r"^analytics/$", aviews.home, name="analytics_home"), re_path( r"^eligible_for_recommendations/$", @@ -98,38 +109,4 @@ ), # recommendations re_path(r"^recommendations/?", rviews.recommendations, name="recommendations"), - # authentication - re_path(r"^accounts/signup$", views.signup, name="signup"), - re_path(r"^api/auth/logout/?$", views.auth_logout_api, name="auth_logout_api"), - re_path(r"^accounts/confirmation$", views.confirmation, name="confirmation"), - # password resets - re_path( - r"^accounts/password/reset/$", - authviews.PasswordResetView.as_view( - template_name="password_reset_form.html", - html_email_template_name="password_reset_email.html", - email_template_name="password_reset_email.html", - ), - {"post_reset_redirect": "/accounts/password/reset/done/"}, - name="password_reset", - ), - re_path( - r"^accounts/password/reset/done/$", - authviews.PasswordResetDoneView.as_view( - template_name="password_reset_done.html" - ), - ), - re_path( - r"^accounts/password/reset/(?P[0-9A-Za-z]+)-(?P.+)/$", - authviews.PasswordResetConfirmView.as_view( - template_name="password_reset_confirm.html" - ), - name="password_reset_confirm", - ), - re_path( - r"^accounts/password/done/$", - authviews.PasswordResetCompleteView.as_view( - template_name="password_reset_complete.html" - ), - ), ]
@@ -10,7 +9,6 @@
- Don't have an account? - - Sign up here - -
+ {{ errorToastMessage }} +
+ {{ successToastMessage }} +
{{ error }}
+ Loading security verification... +
Verification successful. You may proceed.
+ Complete the security check below to continue +
+ Please wait while we verify your authentication... +
Authentication verified successfully. Redirecting...
+ {{ formErrors.term[0] }} +
+ {{ formErrors.professor[0] }} +
+ {{ formErrors.comments[0] }} +
Thanks for writing a review of this course! @@ -585,8 +592,8 @@
+ Access your JI Course Review dashboard +
+ Don't have an account? + + Sign up here + +
+ Forgot your password? Verify your identity to reset it +
+ Or + back to sign in +
+ Join JI Course Review community +
+ Already have an account? + + Sign in + +