This commit is contained in:
Rekcel Endencia 2025-04-08 04:53:16 +08:00
parent 2663d1f5fc
commit ba933c7903
10 changed files with 1187 additions and 0 deletions

5
.env Normal file
View File

@ -0,0 +1,5 @@
# .env
DATABASE_URL=mysql://lms_user:lms_password@db:3306/lms_db
SERVER_ADDR=0.0.0.0:8080
# SECRET_KEY=your_very_secret_jwt_key_here # Needed if using JWT later
RUST_LOG=actix_web=info,lms_backend=info # Logging level

26
Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "lms-backend"
version = "0.1.0"
edition = "2024"
[dependencies]
actix-web = "4"
actix-cors = "0.7" # For enabling Cross-Origin Resource Sharing
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15" # For loading .env files
sqlx = { version = "0.7", features = ["runtime-actix-native-tls", "mysql", "chrono", "macros"] }
chrono = { version = "0.4", features = ["serde"] }
# srp = "0.6" # For PAKE SRP (NOTE: Need careful state management for handshake)
# We'll outline the SRP flow, but full implementation requires managing server state (b value) between requests.
# Using simple password hashing for now as a placeholder until full SRP state management is added.
argon2 = "0.5"
rand = "0.8" # Needed for salt generation with Argon2
thiserror = "1.0" # For cleaner error handling
env_logger = "0.11" # For logging
futures = "0.3" # Often needed for async operations
uuid = { version = "1", features = ["v4", "serde"] } # For potential session tokens
hex = "0.4" # For handling hex representations (e.g., for SRP values if used)
# PAKE SRP dependency (choose one, `srp` is common)
srp = "0.6"

54
Dockerfile Normal file
View File

@ -0,0 +1,54 @@
# Dockerfile
# ---- Builder Stage ----
FROM rust:1.78-slim AS builder
# Install build dependencies (like openssl for native-tls feature of sqlx)
# Use Debian package names
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy manifests first to leverage Docker cache for dependencies
COPY Cargo.toml Cargo.lock ./
# Dummy src/main.rs to build dependencies only if needed
RUN mkdir src && echo "fn main(){}" > src/main.rs
# Build dependencies only (can take time)
# Use --release for optimized dependencies if desired, but target/debug might be faster for iteration
# RUN cargo build --release
RUN cargo build
# Copy the actual source code
COPY src ./src
# Build the final application binary (optimized for release)
# Ensure previous build steps are cleaned if needed
RUN touch src/main.rs && cargo build --release
# ---- Runtime Stage ----
FROM debian:stable-slim AS runtime
# Install runtime dependencies (like ca-certificates and openssl libs)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the compiled binary from the builder stage
COPY --from=builder /app/target/release/lms-backend /usr/local/bin/lms-backend
# Copy .env file - Alternatively, manage via Docker Compose environment vars
# COPY .env .env # If you want .env inside the image (less flexible)
# Expose the port the application listens on (from .env or default)
EXPOSE 8080
# Set the entrypoint to run the application
# Use environment variables for configuration (DATABASE_URL, SERVER_ADDR) passed via Docker Compose
CMD ["lms-backend"]

59
docker-compose.yml Normal file
View File

@ -0,0 +1,59 @@
# docker-compose.yml
version: '3.8'
services:
db:
image: mariadb:10.11 # Use a specific stable version
container_name: lms_mariadb
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-supersecretroot} # Use default if not set in host .env
MARIADB_DATABASE: ${MARIADB_DATABASE:-lms_db}
MARIADB_USER: ${MARIADB_USER:-lms_user}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-lms_password}
volumes:
- mariadb_data:/var/lib/mysql # Persist data
ports:
# Only expose if you need direct access from host machine, otherwise backend connects via internal network
# - "3306:3306"
- "13306:3306" # Expose on 13306 to avoid host conflicts
networks:
- lms_network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${MARIADB_USER:-lms_user}", "-p${MARIADB_PASSWORD:-lms_password}"]
interval: 10s
timeout: 5s
retries: 5
backend:
container_name: lms_backend
build:
context: ./lms-backend # Path to the directory containing the Dockerfile
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy # Wait for DB to be healthy
environment:
# Pass necessary environment variables to the backend container
# Ensure these match the .env file used by the Rust app OR override them here
DATABASE_URL: mysql://${MARIADB_USER:-lms_user}:${MARIADB_PASSWORD:-lms_password}@db:3306/${MARIADB_DATABASE:-lms_db}
SERVER_ADDR: 0.0.0.0:8080
RUST_LOG: ${RUST_LOG:-actix_web=info,lms_backend=info}
# SECRET_KEY: ${SECRET_KEY:-your_fallback_secret} # If using JWT
ports:
- "8080:8080" # Map container port 8080 to host port 8080
networks:
- lms_network
# Optional: Mount local code for development (use with caution, requires rebuilds/restarts)
# volumes:
# - ./lms-backend/src:/app/src
# - ./lms-backend/Cargo.toml:/app/Cargo.toml
# - ./lms-backend/Cargo.lock:/app/Cargo.lock
volumes:
mariadb_data: # Define the named volume for persistence
networks:
lms_network: # Define the network for services communication
driver: bridge

21
src/config.rs Normal file
View File

@ -0,0 +1,21 @@
use std::env;
#[derive(Clone)] // Clone needed for potential app_data sharing
pub struct Config {
pub database_url: String,
pub server_addr: String,
// pub secret_key: String, // Add if using JWT
}
impl Config {
pub fn from_env() -> Result {
let database_url = env::var("DATABASE_URL")?;
let server_addr = env::var("SERVER_ADDR")?;
// let secret_key = env::var("SECRET_KEY")?; // Add if using JWT
Ok(Config {
database_url,
server_addr,
// secret_key,
})
}
}

217
src/db.rs Normal file
View File

@ -0,0 +1,217 @@
use crate::errors::AppError;
use crate::models::{Course, Student, Teacher};
use sqlx::{MySql, Pool};
// Function to create tables if they don't exist
pub async fn init_db(pool: &Pool) -> Result<(), AppError> {
log::info!("Creating tables if they do not exist...");
// Use UNSIGNED for IDs if they are always non-negative and you might need larger ranges
// Use TEXT or appropriate VARCHAR length for SRP salt/verifier (hex encoded can be long)
sqlx::query!(
r#"
CREATE TABLE IF NOT EXISTS students (
user_id INT PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
birthday DATE NULL,
srp_salt TEXT NULL,
srp_verifier TEXT NULL,
school_id VARCHAR(50) NULL, -- Added based on frontend expectations
profile_picture VARCHAR(255) NULL -- Added based on frontend expectations
)
"#
)
.execute(pool)
.await?;
sqlx::query!(
r#"
CREATE TABLE IF NOT EXISTS teachers (
user_id INT PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
birthday DATE NULL,
srp_salt TEXT NULL,
srp_verifier TEXT NULL,
profile_picture VARCHAR(255) NULL -- Added based on frontend expectations
)
"#
)
.execute(pool)
.await?;
sqlx::query!(
r#"
CREATE TABLE IF NOT EXISTS courses (
course_code INT PRIMARY KEY, -- As per spec
course_name VARCHAR(255) NOT NULL
)
"#
)
.execute(pool)
.await?;
sqlx::query!(
r#"
CREATE TABLE IF NOT EXISTS enrollments (
student_id INT NOT NULL,
course_id INT NOT NULL,
enrollment_date DATE NULL,
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students(user_id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(course_code) ON DELETE CASCADE
)
"#
)
.execute(pool)
.await?;
log::info!("Tables checked/created successfully.");
Ok(())
}
// Function to populate some initial data (e.g., courses) - MAKE THIS IDEMPOTENT
pub async fn populate_initial_data(pool: &Pool) -> Result<(), AppError> {
log::info!("Attempting to populate initial data (Courses)...");
// Check if courses already exist to avoid errors on restart
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM courses")
.fetch_one(pool)
.await?;
if count.0 == 0 {
log::info!("No existing courses found, adding sample data...");
let courses = vec![
(101, "Introduction to Programming"),
(102, "Data Structures"),
(201, "Calculus I"),
(205, "Linear Algebra"),
];
for (code, name) in courses {
sqlx::query!(
"INSERT INTO courses (course_code, course_name) VALUES (?, ?)",
code,
name
)
.execute(pool)
.await?;
}
log::info!("Sample courses added.");
// Add a sample student and teacher WITHOUT passwords/SRP data
// User ID 123456 is used in frontend mock login
sqlx::query!(
r#"
INSERT INTO students (user_id, first_name, last_name, school_id, birthday)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE user_id=user_id -- Do nothing if exists
"#,
123456, "John", "Doe", "S123456", chrono::NaiveDate::from_ymd_opt(2000, 1, 1)
).execute(pool).await?;
log::info!("Sample student added (ID: 123456).");
// Add a sample teacher (maybe overlapping ID to test login logic)
sqlx::query!(
r#"
INSERT INTO teachers (user_id, first_name, last_name, birthday)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE user_id=user_id -- Do nothing if exists
"#,
5001, "Alice", "Smith", chrono::NaiveDate::from_ymd_opt(1985, 5, 15)
).execute(pool).await?;
log::info!("Sample teacher added (ID: 5001).");
// Example of overlapping ID (same as student)
// This teacher won't be able to log in until password/SRP is set,
// and login logic must handle potential ID collision if SRP data exists for both.
sqlx::query!(
r#"
INSERT INTO teachers (user_id, first_name, last_name, birthday)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE user_id=user_id -- Do nothing if exists
"#,
123456, "Jane", "Teacher", chrono::NaiveDate::from_ymd_opt(1990, 3, 10)
).execute(pool).await?;
log::info!("Sample teacher added with overlapping ID (ID: 123456).");
} else {
log::info!("Courses table already populated.");
}
Ok(())
}
// --- Example Fetch Functions ---
pub async fn find_student_by_id(pool: &Pool, user_id: i32) -> Result, AppError> {
let student = sqlx::query_as!(
Student,
r#"
SELECT user_id, first_name, last_name, birthday, srp_salt, srp_verifier, school_id, profile_picture
FROM students
WHERE user_id = ?
"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(student)
}
pub async fn find_teacher_by_id(pool: &Pool, user_id: i32) -> Result, AppError> {
let teacher = sqlx::query_as!(
Teacher,
r#"
SELECT user_id, first_name, last_name, birthday, srp_salt, srp_verifier, profile_picture
FROM teachers
WHERE user_id = ?
"#,
user_id
)
.fetch_optional(pool)
.await?;
Ok(teacher)
}
// --- Count Functions ---
pub async fn count_students(pool: &Pool) -> Result {
let result = sqlx::query!("SELECT COUNT(*) as count FROM students")
.fetch_one(pool)
.await?;
Ok(result.count)
}
pub async fn count_teachers(pool: &Pool) -> Result {
let result = sqlx::query!("SELECT COUNT(*) as count FROM teachers")
.fetch_one(pool)
.await?;
Ok(result.count)
}
// --- Update Functions ---
// Add functions to update SRP salt/verifier when a user sets/changes their password
pub async fn update_student_srp(pool: &Pool, user_id: i32, salt: &str, verifier: &str) -> Result<(), AppError> {
sqlx::query!(
"UPDATE students SET srp_salt = ?, srp_verifier = ? WHERE user_id = ?",
salt, verifier, user_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_teacher_srp(pool: &Pool, user_id: i32, salt: &str, verifier: &str) -> Result<(), AppError> {
sqlx::query!(
"UPDATE teachers SET srp_salt = ?, srp_verifier = ? WHERE user_id = ?",
salt, verifier, user_id
)
.execute(pool)
.await?;
Ok(())
}
// TODO: Add functions for get_student_list (with joins/filtering), manage students, enrollments etc.

81
src/errors.rs Normal file
View File

@ -0,0 +1,81 @@
use actix_web::{HttpResponse, ResponseError};
use serde::Serialize;
use sqlx::Error as SqlxError;
use std::fmt;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Database error: {0}")]
DatabaseError(#[from] SqlxError),
#[error("Configuration error: {0}")]
ConfigError(#[from] std::env::VarError),
#[error("Not Found: {0}")]
NotFound(String),
#[error("Authentication Failed: {0}")]
AuthenticationError(String),
#[error("Invalid Input: {0}")]
InvalidInput(String),
#[error("Authorization Failed: {0}")]
AuthorizationError(String),
#[error("Internal Server Error")]
InternalError(String), // Keep internal details out of response
}
#[derive(Serialize)]
struct ErrorResponse {
success: bool,
message: String,
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
log::error!("Error handled: {:?}", self); // Log the full error
let (status_code, message) = match self {
AppError::DatabaseError(_) => (
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"Database error occurred.".to_string(),
),
AppError::ConfigError(_) => (
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"Configuration error.".to_string(),
),
AppError::NotFound(ref msg) => (actix_web::http::StatusCode::NOT_FOUND, msg.clone()),
AppError::AuthenticationError(ref msg) => {
(actix_web::http::StatusCode::UNAUTHORIZED, msg.clone())
}
AppError::InvalidInput(ref msg) => {
(actix_web::http::StatusCode::BAD_REQUEST, msg.clone())
}
AppError::AuthorizationError(ref msg) => {
(actix_web::http::StatusCode::FORBIDDEN, msg.clone())
}
AppError::InternalError(_) => (
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
"An internal error occurred.".to_string(),
),
};
HttpResponse::build(status_code).json(ErrorResponse {
success: false,
message,
})
}
}
// Helper for converting Option to AppError::NotFound
pub trait OptionExt {
fn ok_or_not_found(self, message: &str) -> Result;
}
impl OptionExt for Option {
fn ok_or_not_found(self, message: &str) -> Result {
self.ok_or_else(|| AppError::NotFound(message.to_string()))
}
}

496
src/handlers.rs Normal file
View File

@ -0,0 +1,496 @@
use actix_web::{web, get, post, put, HttpResponse, Responder, Scope};
use sqlx::{MySql, Pool};
use crate::{
db,
errors::{AppError, OptionExt},
models::*,
};
use srp::server::{SrpServer, SrpServerChallenge}; // Using the `srp` crate
use srp::{types::*, EphemeralKey, PublicEphemeralKey}; // Import necessary SRP types
use hex; // For encoding/decoding SRP values
use std::collections::HashMap; // Example for temporary state store
use std::sync::Mutex; // For thread-safe access to state store
use uuid::Uuid;
// --- Temporary State Store for SRP Handshake ---
// WARNING: This in-memory store is NOT suitable for production.
// It will lose state on restart and doesn't scale across multiple instances.
// Use a proper distributed cache (like Redis) or database table for production.
struct AppState {
srp_challenges: Mutex, String)>>, // Key: user_id_type (e.g., "123456_student"), Value: (Challenge state, User Type)
active_sessions: Mutex>, // Key: session_token (UUID), Value: User Data
}
impl AppState {
fn new() -> Self {
AppState {
srp_challenges: Mutex::new(HashMap::new()),
active_sessions: Mutex::new(HashMap::new()),
}
}
}
// --- Helper Functions ---
fn create_api_response(data: Option, success: bool, message: Option) -> HttpResponse {
HttpResponse::Ok().json(ApiResponse {
success,
data,
message,
})
}
// Helper to parse user_id string to i32
fn parse_user_id(user_id_str: &str) -> Result {
user_id_str.parse::().map_err(|_| AppError::InvalidInput(format!("Invalid user ID format: {}", user_id_str)))
}
// --- Route Handlers ---
#[get("/health")]
async fn health_check() -> impl Responder {
HttpResponse::Ok().json(ApiResponse {
success: true,
data: Some("Healthy"),
message: None,
})
}
// --- SRP Login Step 1: Start ---
#[post("/auth/srp/start")]
async fn srp_login_start(
pool: web::Data>,
app_state: web::Data,
req: web::Json,
) -> Result {
let user_id_str = &req.user_id;
let user_id = parse_user_id(user_id_str)?;
// --- Handle potential overlapping IDs ---
let student_data = db::find_student_by_id(&pool, user_id).await?;
let teacher_data = db::find_teacher_by_id(&pool, user_id).await?;
let (salt_hex, verifier_hex, user_type) = match (student_data, teacher_data) {
// Found only student with SRP data
(Some(s), None) if s.srp_salt.is_some() && s.srp_verifier.is_some() => {
log::info!("SRP Start: Found student {} with SRP data.", user_id);
(s.srp_salt.unwrap(), s.srp_verifier.unwrap(), "student")
},
// Found only teacher with SRP data
(None, Some(t)) if t.srp_salt.is_some() && t.srp_verifier.is_some() => {
log::info!("SRP Start: Found teacher {} with SRP data.", user_id);
(t.srp_salt.unwrap(), t.srp_verifier.unwrap(), "teacher")
},
// Found both, but only student has SRP data
(Some(s), Some(_)) if s.srp_salt.is_some() && s.srp_verifier.is_some() => {
log::warn!("SRP Start: Found student {} and teacher {} (overlapping ID), using student SRP data.", user_id, user_id);
(s.srp_salt.unwrap(), s.srp_verifier.unwrap(), "student")
},
// Found both, but only teacher has SRP data (less likely scenario if student usually registers first)
(Some(_), Some(t)) if t.srp_salt.is_some() && t.srp_verifier.is_some() => {
log::warn!("SRP Start: Found student {} and teacher {} (overlapping ID), using teacher SRP data.", user_id, user_id);
(t.srp_salt.unwrap(), t.srp_verifier.unwrap(), "teacher")
},
// Found both with SRP data - ambiguous! Prefer student? Or return error?
(Some(s), Some(t)) if s.srp_salt.is_some() && s.srp_verifier.is_some() && t.srp_salt.is_some() && t.srp_verifier.is_some() => {
log::error!("SRP Start: Ambiguous login for user ID {}. Both student and teacher have SRP data set.", user_id);
// For now, let's arbitrarily choose student. A better approach might involve asking the user or having unique usernames.
log::warn!("SRP Start: Ambiguous login for user ID {}. Defaulting to student.", user_id);
(s.srp_salt.unwrap(), s.srp_verifier.unwrap(), "student")
// Alternatively, return an error:
// return Err(AppError::AuthenticationError("Ambiguous user ID. Please contact support.".to_string()));
},
// User exists but SRP data not set (password needs to be created)
(Some(_), _) | (_, Some(_)) => {
log::warn!("SRP Start: User ID {} found, but SRP salt/verifier not set. Cannot log in.", user_id);
return Err(AppError::AuthenticationError(
"Account exists but password not set. Cannot initiate login.".to_string(),
));
}
// User not found in either table
(None, None) => {
log::warn!("SRP Start: User ID {} not found.", user_id);
return Err(AppError::NotFound(format!("User ID {} not found.", user_id)));
}
};
// Decode hex salt and verifier from DB
let salt = Salt::from_hex(&salt_hex)
.map_err(|e| AppError::InternalError(format!("Failed to decode salt: {}", e)))?;
let verifier = Verifier::from_hex(&verifier_hex)
.map_err(|e| AppError::InternalError(format!("Failed to decode verifier: {}", e)))?;
// Generate server ephemeral keys and challenge
// Using SHA256 as the hash function, adjust if needed (must match client)
let server = SrpServer::::new(&verifier);
let challenge = server.challenge(&salt).map_err(|e| {
log::error!("SRP challenge generation error: {}", e);
AppError::InternalError("Failed to generate SRP challenge".to_string())
})?;
let server_public_ephemeral_hex = challenge.public_ephemeral().to_hex();
// --- Store challenge state TEMPORARILY ---
// Associate the challenge state with the user_id and type for the verify step
let state_key = format!("{}_{}", user_id_str, user_type);
let mut challenges = app_state.srp_challenges.lock().unwrap(); // Handle potential poisoning in production
challenges.insert(state_key.clone(), (challenge, user_type.to_string()));
log::info!("Stored SRP challenge state for key: {}", state_key);
// TODO: Add TTL for stored challenges to prevent memory leaks
Ok(create_api_response(
Some(SrpLoginStartResponse {
salt: salt_hex, // Send back the original hex salt
server_public_ephemeral: server_public_ephemeral_hex,
user_type: user_type.to_string(),
}),
true,
None,
))
}
// --- SRP Login Step 2: Verify ---
#[post("/auth/srp/verify")]
async fn srp_login_verify(
pool: web::Data>,
app_state: web::Data,
req: web::Json,
) -> Result {
let user_id_str = &req.user_id;
let user_type = &req.user_type; // Get user type received from client (who got it from /start)
let state_key = format!("{}_{}", user_id_str, user_type);
log::info!("SRP Verify: Attempting for state key: {}", state_key);
// --- Retrieve challenge state ---
let challenge_state_tuple = { // Scope the lock
let mut challenges = app_state.srp_challenges.lock().unwrap();
// IMPORTANT: Remove the state after retrieving it to prevent replay attacks
challenges.remove(&state_key)
.ok_or_else(|| {
log::warn!("SRP Verify: No active SRP challenge found for key: {}", state_key);
AppError::AuthenticationError("SRP session expired or invalid.".to_string())
})?
};
let (challenge_state, retrieved_user_type) = challenge_state_tuple;
if &retrieved_user_type != user_type {
log::error!("SRP Verify: User type mismatch for key {}. Expected {}, got {}", state_key, retrieved_user_type, user_type);
return Err(AppError::AuthenticationError("SRP session data mismatch.".to_string()));
}
// Decode client public ephemeral (A) and proof (M1)
let client_public_ephemeral = PublicEphemeralKey::from_hex(&req.client_public_ephemeral)
.map_err(|_| AppError::InvalidInput("Invalid client public ephemeral key format.".to_string()))?;
let client_proof = ClientProof::from_hex(&req.client_proof)
.map_err(|_| AppError::InvalidInput("Invalid client proof format.".to_string()))?;
// Verify the client's proof using the stored challenge state
let verification = challenge_state.verify_client(&client_public_ephemeral, &client_proof);
match verification {
Ok(session) => {
// Verification successful! Generate server proof (M2) and session token
let server_proof_hex = session.server_proof().to_hex();
let session_key = session.session_key(); // Use this for encrypted communication if needed
log::info!("SRP Verify: Client proof verified for key: {}. Session key generated (first 4 bytes): {:x?}", state_key, &session_key[..4]);
// --- Authentication successful, fetch user details ---
let user_id = parse_user_id(user_id_str)?;
let (full_name, profile_picture, school_id, birthdate) =
match user_type.as_str() {
"student" => {
let student = db::find_student_by_id(&pool, user_id).await?
.ok_or_not_found("Student data not found after successful SRP verification.")?;
(
format!("{} {}", student.first_name, student.last_name),
student.profile_picture,
student.school_id,
student.birthday.map(|d| d.format("%Y-%m-%d").to_string()),
)
}
"teacher" => {
let teacher = db::find_teacher_by_id(&pool, user_id).await?
.ok_or_not_found("Teacher data not found after successful SRP verification.")?;
(
format!("{} {}", teacher.first_name, teacher.last_name),
teacher.profile_picture,
None, // Teachers might not have a school_id in this schema
teacher.birthday.map(|d| d.format("%Y-%m-%d").to_string()),
)
}
_ => return Err(AppError::InternalError("Invalid user type encountered.".to_string())),
};
// --- Generate a session token (e.g., UUID) ---
let session_token = Uuid::new_v4().to_string();
// Create login success data
let login_data = LoginSuccessData {
user_id: user_id_str.clone(),
full_name,
profile_picture,
school_id,
birthdate,
token: session_token.clone(),
user_type: user_type.clone(),
};
// Store active session (replace with DB/Redis in production)
{
let mut sessions = app_state.active_sessions.lock().unwrap();
sessions.insert(session_token.clone(), login_data.clone()); // Clone data for response
log::info!("SRP Verify: Stored active session for token: {}", session_token);
// TODO: Implement session expiry
}
// Respond with server proof and login data
Ok(create_api_response(
Some(SrpLoginVerifyResponse {
server_proof: server_proof_hex,
login_data: Some(login_data),
}),
true,
None,
))
}
Err(e) => {
// Verification failed
log::warn!("SRP Verify: Client proof verification failed for key {}: {}", state_key, e);
Err(AppError::AuthenticationError("Invalid login credentials (SRP verification failed).".to_string()))
}
}
}
// TODO: Add logout handler (needs to invalidate token/session)
#[post("/auth/logout")]
async fn logout(app_state: web::Data, /* TODO: Extract token from header */ ) -> Result {
// 1. Extract token from Authorization header
// let token = extract_token_from_request(req)?;
let token = "placeholder_token_to_remove"; // Replace with actual extraction
// 2. Remove session from state store
let mut sessions = app_state.active_sessions.lock().unwrap();
if sessions.remove(token).is_some() {
log::info!("Logout: Removed session for token: {}", token);
Ok(create_api_response::<()>(None, true, Some("Logged out successfully.".to_string())))
} else {
log::warn!("Logout: Attempted to log out with invalid or expired token: {}", token);
// Still return success, as the user is effectively logged out from the server's perspective.
Ok(create_api_response::<()>(None, true, Some("Logged out successfully.".to_string())))
// Or return an error if strict feedback is needed:
// Err(AppError::AuthenticationError("Invalid session token.".to_string()))
}
}
// TODO: Add get_profile handler (needs token validation middleware)
#[get("/profile/{user_id}")]
async fn get_profile(
pool: web::Data>,
app_state: web::Data, // Needed if validating token here
path: web::Path,
// TODO: Add middleware to extract validated user info from token
// validated_user: AuthenticatedUser, // Example struct from middleware
) -> Result {
let requested_user_id_str = path.into_inner();
let requested_user_id = parse_user_id(&requested_user_id_str)?;
// --- Authorization ---
// TODO: Check if the requesting user (from token) is allowed to see this profile.
// Example: Allow viewing own profile or if requester is admin.
// if validated_user.user_id != requested_user_id_str && !validated_user.is_admin {
// return Err(AppError::AuthorizationError("You are not authorized to view this profile.".to_string()));
// }
// Fetch student or teacher data
let student = db::find_student_by_id(&pool, requested_user_id).await?;
let teacher = db::find_teacher_by_id(&pool, requested_user_id).await?;
match (student, teacher) {
(Some(s), _) => { // Prioritize student if ID overlaps? Or check based on validated_user.user_type?
let profile_data = ProfileData {
user_id: s.user_id.to_string(),
full_name: format!("{} {}", s.first_name, s.last_name),
profile_picture: s.profile_picture,
school_id: s.school_id,
// Only show birthdate if viewing own profile (needs auth check)
birthdate: s.birthday.map(|d| d.format("%Y-%m-%d").to_string()),
user_type: "student".to_string(),
};
Ok(create_api_response(Some(profile_data), true, None))
},
(None, Some(t)) => {
let profile_data = ProfileData {
user_id: t.user_id.to_string(),
full_name: format!("{} {}", t.first_name, t.last_name),
profile_picture: t.profile_picture,
school_id: None,
birthdate: t.birthday.map(|d| d.format("%Y-%m-%d").to_string()),
user_type: "teacher".to_string(),
};
Ok(create_api_response(Some(profile_data), true, None))
},
(None, None) => Err(AppError::NotFound(format!("User profile {} not found.", requested_user_id_str))),
}
}
// TODO: Add update_account_settings handler (needs token validation middleware)
#[put("/profile/settings")]
async fn update_account_settings(
pool: web::Data>,
// validated_user: AuthenticatedUser, // From middleware
payload: web::Json,
) -> Result {
// 1. Get user_id and user_type from validated_user token
// let user_id = parse_user_id(&validated_user.user_id)?;
// let user_type = &validated_user.user_type;
// --- Mocked user for now ---
let user_id = 123456; // Example user ID
let user_type = "student"; // Example user type
// --- ---
log::info!("Updating settings for {} ID: {}", user_type, user_id);
if let Some(pic) = &payload.profile_picture {
match user_type {
"student" => {
sqlx::query!("UPDATE students SET profile_picture = ? WHERE user_id = ?", pic, user_id)
.execute(&**pool).await?;
},
"teacher" => {
sqlx::query!("UPDATE teachers SET profile_picture = ? WHERE user_id = ?", pic, user_id)
.execute(&**pool).await?;
},
_ => return Err(AppError::InternalError("Invalid user type.".to_string())),
}
log::info!("Updated profile picture for {} ID: {}", user_type, user_id);
}
// Handle other updatable fields (e.g., non-sensitive ones)
// Password change requires a separate, secure flow (e.g., SRP re-registration or old/new password check)
Ok(create_api_response::<()>(None, true, Some("Settings updated successfully.".to_string())))
}
// --- Admin Handlers ---
#[get("/admin/dashboard")]
async fn get_admin_dashboard_data(pool: web::Data>, /* TODO: Admin Auth Middleware */) -> Result {
let students_count = db::count_students(&pool).await?;
let teachers_count = db::count_teachers(&pool).await?;
let data = AdminDashboardData {
students_count,
teachers_count,
};
Ok(create_api_response(Some(data), true, None))
}
#[get("/admin/students")]
async fn get_student_list(pool: web::Data>, /* TODO: Admin Auth Middleware */) -> Result {
// Basic implementation: get all students, construct name.
// TODO: Add filtering, pagination, course info joins.
let students = sqlx::query_as::<_, StudentListData>(
r#"
SELECT
user_id as id,
CONCAT(first_name, ' ', last_name) as name
-- Add columns for year_level, courses (requires more complex query/joins)
FROM students
ORDER BY last_name, first_name
"#
)
.fetch_all(&**pool)
.await?;
Ok(create_api_response(Some(students), true, None))
}
#[get("/admin/students/{student_id}/financials")]
async fn get_student_financial_data(
_pool: web::Data>, // Mark as unused for now
path: web::Path,
/* TODO: Admin Auth Middleware */
) -> Result {
let student_id_str = path.into_inner();
let _student_id = parse_user_id(&student_id_str)?; // Validate ID format
// TODO: Check if student_id exists
log::info!("Fetching financial data for student ID: {}", _student_id);
// Mock data as per frontend api.ts, since no financial table exists
let financial_data = StudentFinancialData {
tuition_fee: "5000".to_string(),
miscellaneous_fee: "500".to_string(),
lab_fee: "200".to_string(),
current_account: "-100".to_string(),
down_payment: "2000".to_string(),
midterm_payment: "1500".to_string(),
prefinal_payment: "1000".to_string(),
final_payment: "500".to_string(),
};
Ok(create_api_response(Some(financial_data), true, None))
}
// --- Configure Routes ---
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
// Initialize App State (Singleton for the App)
let app_state = web::Data::new(AppState::new());
cfg.app_data(app_state.clone()) // Make AppState available to handlers
.service(health_check)
.service(
web::scope("/api")
// Authentication (No auth middleware needed)
.service(
web::scope("/auth")
.service(srp_login_start)
.service(srp_login_verify)
.service(logout) // TODO: Add auth middleware if logout requires token
)
// Profile routes (TODO: Add auth middleware)
.service(
web::scope("/profile")
.service(get_profile) // Dynamic part {user_id}
.service(update_account_settings) // Path defined in handler
)
// Admin routes (TODO: Add admin auth middleware)
.service(
web::scope("/admin")
.service(get_admin_dashboard_data)
.service(get_student_list)
.service(get_student_financial_data) // Dynamic part {student_id}
// TODO: Add routes for managing students/teachers/courses/enrollments
)
// TODO: Add routes for classrooms, posts, etc.
);
}
// TODO: Implement Authentication Middleware
// - Extract token from "Authorization: Bearer " header.
// - Verify token against the active_sessions store (or JWT verification).
// - If valid, extract user info (user_id, user_type) and attach it to the request extensions.
// - If invalid, return 401 Unauthorized.
// TODO: Implement Authorization Middleware (or checks within handlers)
// - Check if the authenticated user has the necessary role/permissions (e.g., admin) for the requested route.
// - If not authorized, return 403 Forbidden.
// TODO: Implement SRP Registration/Password Change Handler
// - Takes username and password.
// - Generates salt and verifier using srp::server::generate_verifier.
// - Stores hex-encoded salt and verifier in the appropriate (student/teacher) table.
// - This is needed for users to set their initial password or change it.

62
src/main.rs Normal file
View File

@ -0,0 +1,62 @@
use actix_cors::Cors;
use actix_web::{middleware::Logger, web, App, HttpServer};
use dotenv::dotenv;
use sqlx::mysql::MySqlPoolOptions;
use std::env;
mod config;
mod db;
mod errors;
mod handlers;
mod models;
// mod auth; // Keep auth logic within handlers for now or create dedicated module
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok(); // Load .env file
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); // Use RUST_LOG from .env
let app_config = config::Config::from_env().expect("Failed to load configuration");
log::info!("Connecting to database: {}", &app_config.database_url);
let db_pool = MySqlPoolOptions::new()
.max_connections(10)
.connect(&app_config.database_url)
.await
.expect("Failed to create database connection pool");
log::info!("Running database migrations/setup...");
db::init_db(&db_pool)
.await
.expect("Database initialization failed");
log::info!("Database setup complete.");
// Optional: Populate initial data if needed (courses, etc.)
// Ensure this is idempotent or only runs once
if let Err(e) = db::populate_initial_data(&db_pool).await {
log::warn!("Could not populate initial data (might already exist): {}", e);
} else {
log::info!("Initial data populated (if tables were empty).");
}
log::info!("Starting server at {}", &app_config.server_addr);
HttpServer::new(move || {
let cors = Cors::default()
.allow_any_origin() // Adjust for production!
.allow_any_method()
.allow_any_header()
.max_age(3600);
App::new()
.app_data(web::Data::new(db_pool.clone()))
// .app_data(web::Data::new(app_config.clone())) // If config needed in handlers
.wrap(Logger::default()) // Enable request logging
.wrap(cors) // Enable CORS
.configure(handlers::configure_routes) // Register API routes
})
.bind(&app_config.server_addr)?
.run()
.await
}

166
src/models.rs Normal file
View File

@ -0,0 +1,166 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
// --- Database Table Models ---
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Student {
pub user_id: i32, // Assuming INT translates to i32
pub first_name: String,
pub last_name: String,
pub birthday: Option, // Use Option if nullable
// pub password_hash: String, // Replaced by SRP fields
pub srp_salt: Option, // Store as hex string or base64
pub srp_verifier: Option, // Store as hex string or base64
// Add other fields like school_id if needed based on frontend expectations
pub school_id: Option, // Matches frontend LoginResponseData
pub profile_picture: Option, // Matches frontend LoginResponseData
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Teacher {
pub user_id: i32,
pub first_name: String,
pub last_name: String,
pub birthday: Option,
// pub password_hash: String, // Replaced by SRP fields
pub srp_salt: Option,
pub srp_verifier: Option,
// 'enrolled' was listed as a list of courses, this needs a join table (Enrollments)
// This struct represents the core teacher info. Enrollment is handled separately.
pub profile_picture: Option, // Matches frontend LoginResponseData (assumption)
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Course {
pub course_code: i32, // As per spec, though VARCHAR might be better
pub course_name: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Enrollment {
pub student_id: i32,
pub course_id: i32,
pub enrollment_date: Option,
}
// --- API Request/Response Structs (Matching frontend's api.ts where possible) ---
// Generic API Response Wrapper
#[derive(Serialize)]
pub struct ApiResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option,
}
// Data structure for successful login (after SRP verification)
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginSuccessData {
pub user_id: String, // Use String to match frontend potentially using non-numeric IDs? Keep consistent.
pub full_name: String,
pub profile_picture: Option, // Allow null/optional
pub school_id: Option, // Allow null/optional
pub birthdate: Option, // Format as YYYY-MM-DD string
pub token: String, // Session token (e.g., UUID or JWT)
pub user_type: String, // "student" or "teacher"
}
// Data structure for profile page
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileData {
pub user_id: String,
pub full_name: String,
pub profile_picture: Option,
pub school_id: Option,
pub birthdate: Option, // Only show birthdate if viewing own profile? Needs logic.
// Add posts if implementing that feature
// pub posts: Vec,
pub user_type: String, // "student" or "teacher"
}
// Placeholder for Post data if needed later
// #[derive(Debug, Serialize)]
// pub struct PostData {
// pub id: i32,
// pub content: String,
// }
// Admin Dashboard Data
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardData {
pub students_count: i64, // Use i64 for counts from SQL COUNT(*)
pub teachers_count: i64,
}
// Student List Data for Admin Page
#[derive(Debug, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct StudentListData {
pub id: i32, // Assuming user_id is the ID here
pub name: String, // Need to construct this (first_name + last_name) in query
// These fields need to be fetched/calculated, perhaps via joins or separate queries
// pub year_level: Option, // Not in original schema
// pub courses: Vec, // Need to query Enrollments and Courses
}
// Student Financial Data (Mocked as per frontend)
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StudentFinancialData {
pub tuition_fee: String,
pub miscellaneous_fee: String,
pub lab_fee: String,
pub current_account: String,
pub down_payment: String,
pub midterm_payment: String,
pub prefinal_payment: String,
pub final_payment: String,
}
// --- SRP Related Structs ---
#[derive(Deserialize)]
pub struct SrpLoginStartRequest {
pub user_id: String, // User identifier (I)
}
#[derive(Serialize)]
pub struct SrpLoginStartResponse {
pub salt: String, // Hex encoded salt (s)
pub server_public_ephemeral: String, // Hex encoded server public ephemeral (B)
pub user_type: String, // "student" or "teacher" (needed for verify step)
}
#[derive(Deserialize)]
pub struct SrpLoginVerifyRequest {
pub user_id: String, // Pass user_id again to correlate state if needed
pub client_public_ephemeral: String, // Hex encoded client public ephemeral (A)
pub client_proof: String, // Hex encoded client proof (M1)
pub user_type: String, // "student" or "teacher"
}
#[derive(Serialize)]
pub struct SrpLoginVerifyResponse {
pub server_proof: String, // Hex encoded server proof (M2)
// On successful verification, include the LoginSuccessData
#[serde(flatten)] // Embed fields directly
pub login_data: Option, // Only present on success
}
// --- Other API Structs ---
#[derive(Deserialize)]
pub struct UpdateAccountSettingsPayload {
pub profile_picture: Option,
// Password change needs separate secure flow (e.g., old+new password or SRP re-registration)
// pub new_password: Option,
// Other editable fields?
}