Hi sir!!
This commit is contained in:
parent
2663d1f5fc
commit
ba933c7903
5
.env
Normal file
5
.env
Normal 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
26
Cargo.toml
Normal 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
54
Dockerfile
Normal 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
59
docker-compose.yml
Normal 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
21
src/config.rs
Normal 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
217
src/db.rs
Normal 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
81
src/errors.rs
Normal 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
496
src/handlers.rs
Normal 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
62
src/main.rs
Normal 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
166
src/models.rs
Normal 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?
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user