diff --git a/Cargo.toml b/Cargo.toml index a9ebcd7..f4cdec0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,10 @@ [package] -name = "yew-app" +name = "calendar-app" version = "0.1.0" edition = "2021" +# Frontend binary only + [dependencies] yew = { version = "0.21", features = ["csr"] } web-sys = "0.3" @@ -38,16 +40,9 @@ base64 = "0.21" # XML/Regex parsing regex = "1.0" -# Frontend authentication (backend removed for WASM compatibility) -# sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate"] } -# bcrypt = "0.15" -# jsonwebtoken = "9.0" - -# Yew routing and local storage +# Yew routing and local storage (WASM only) yew-router = "0.18" gloo-storage = "0.3" gloo-timers = "0.3" wasm-bindgen-futures = "0.4" -[dev-dependencies] -tokio = { version = "1.0", features = ["macros", "rt"] } \ No newline at end of file diff --git a/Trunk.toml b/Trunk.toml index ed9e74f..fe27fce 100644 --- a/Trunk.toml +++ b/Trunk.toml @@ -4,6 +4,7 @@ dist = "dist" [watch] watch = ["src", "Cargo.toml"] +ignore = ["backend/"] [serve] address = "127.0.0.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..ab43870 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "calendar-backend" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "backend" +path = "src/main.rs" + +[dependencies] +# Backend authentication dependencies +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate"] } +bcrypt = "0.15" +jsonwebtoken = "9.0" +tokio = { version = "1.0", features = ["full"] } +axum = { version = "0.7", features = ["json"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors"] } +hyper = { version = "1.0", features = ["full"] } + +# Shared dependencies +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.0", features = ["v4", "serde"] } +anyhow = "1.0" + +[dev-dependencies] +tokio = { version = "1.0", features = ["macros", "rt"] } \ No newline at end of file diff --git a/backend/src/auth.rs b/backend/src/auth.rs new file mode 100644 index 0000000..912b11f --- /dev/null +++ b/backend/src/auth.rs @@ -0,0 +1,230 @@ +use bcrypt::{hash, verify, DEFAULT_COST}; +use chrono::{DateTime, Duration, Utc}; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use sqlx::{Row, SqlitePool}; +use uuid::Uuid; + +use crate::models::{User, UserInfo, LoginRequest, RegisterRequest, AuthResponse, ApiError}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, // Subject (user ID) + pub exp: i64, // Expiration time + pub iat: i64, // Issued at + pub username: String, + pub email: String, +} + +#[derive(Clone)] +pub struct AuthService { + db: SqlitePool, + jwt_secret: String, +} + +impl AuthService { + pub fn new(db: SqlitePool, jwt_secret: String) -> Self { + Self { db, jwt_secret } + } + + pub async fn init_db(&self) -> Result<(), ApiError> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(&self.db) + .await + .map_err(|e| ApiError::Database(e.to_string()))?; + + Ok(()) + } + + pub async fn register(&self, request: RegisterRequest) -> Result { + // Validate input + self.validate_registration(&request)?; + + // Check if user already exists + if self.user_exists(&request.username, &request.email).await? { + return Err(ApiError::Conflict("Username or email already exists".to_string())); + } + + // Hash password + let password_hash = hash(&request.password, DEFAULT_COST) + .map_err(|e| ApiError::Internal(format!("Password hashing failed: {}", e)))?; + + // Create user + let user_id = Uuid::new_v4().to_string(); + let now = Utc::now(); + + sqlx::query( + r#" + INSERT INTO users (id, username, email, password_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + "#, + ) + .bind(&user_id) + .bind(&request.username) + .bind(&request.email) + .bind(&password_hash) + .bind(now) + .bind(now) + .execute(&self.db) + .await + .map_err(|e| ApiError::Database(e.to_string()))?; + + // Get the created user + let user = self.get_user_by_id(&user_id).await?; + + // Generate token + let token = self.generate_token(&user)?; + + Ok(AuthResponse { + token, + user: UserInfo { + id: user.id, + username: user.username, + email: user.email, + }, + }) + } + + pub async fn login(&self, request: LoginRequest) -> Result { + // Get user by username + let user = self.get_user_by_username(&request.username).await?; + + // Verify password + let is_valid = verify(&request.password, &user.password_hash) + .map_err(|e| ApiError::Internal(format!("Password verification failed: {}", e)))?; + + if !is_valid { + return Err(ApiError::Unauthorized("Invalid credentials".to_string())); + } + + // Generate token + let token = self.generate_token(&user)?; + + Ok(AuthResponse { + token, + user: UserInfo { + id: user.id, + username: user.username, + email: user.email, + }, + }) + } + + pub async fn verify_token(&self, token: &str) -> Result { + let claims = self.decode_token(token)?; + let user = self.get_user_by_id(&claims.sub).await?; + + Ok(UserInfo { + id: user.id, + username: user.username, + email: user.email, + }) + } + + async fn get_user_by_username(&self, username: &str) -> Result { + let row = sqlx::query("SELECT id, username, email, password_hash, created_at FROM users WHERE username = ?") + .bind(username) + .fetch_one(&self.db) + .await + .map_err(|_| ApiError::Unauthorized("Invalid credentials".to_string()))?; + + self.row_to_user(row) + } + + async fn get_user_by_id(&self, user_id: &str) -> Result { + let row = sqlx::query("SELECT id, username, email, password_hash, created_at FROM users WHERE id = ?") + .bind(user_id) + .fetch_one(&self.db) + .await + .map_err(|_| ApiError::NotFound("User not found".to_string()))?; + + self.row_to_user(row) + } + + fn row_to_user(&self, row: sqlx::sqlite::SqliteRow) -> Result { + Ok(User { + id: row.try_get("id").map_err(|e| ApiError::Database(e.to_string()))?, + username: row.try_get("username").map_err(|e| ApiError::Database(e.to_string()))?, + email: row.try_get("email").map_err(|e| ApiError::Database(e.to_string()))?, + password_hash: row.try_get("password_hash").map_err(|e| ApiError::Database(e.to_string()))?, + created_at: row.try_get("created_at").map_err(|e| ApiError::Database(e.to_string()))?, + }) + } + + async fn user_exists(&self, username: &str, email: &str) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users WHERE username = ? OR email = ?" + ) + .bind(username) + .bind(email) + .fetch_one(&self.db) + .await + .map_err(|e| ApiError::Database(e.to_string()))?; + + Ok(count > 0) + } + + fn validate_registration(&self, request: &RegisterRequest) -> Result<(), ApiError> { + if request.username.trim().is_empty() { + return Err(ApiError::BadRequest("Username is required".to_string())); + } + + if request.username.len() < 3 { + return Err(ApiError::BadRequest("Username must be at least 3 characters".to_string())); + } + + if request.email.trim().is_empty() || !request.email.contains('@') { + return Err(ApiError::BadRequest("Valid email is required".to_string())); + } + + if request.password.len() < 6 { + return Err(ApiError::BadRequest("Password must be at least 6 characters".to_string())); + } + + Ok(()) + } + + fn generate_token(&self, user: &User) -> Result { + let now = Utc::now(); + let expires_at = now + Duration::hours(24); // Token valid for 24 hours + + let claims = Claims { + sub: user.id.clone(), + exp: expires_at.timestamp(), + iat: now.timestamp(), + username: user.username.clone(), + email: user.email.clone(), + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.jwt_secret.as_bytes()), + ) + .map_err(|e| ApiError::Internal(format!("Token generation failed: {}", e)))?; + + Ok(token) + } + + fn decode_token(&self, token: &str) -> Result { + let token_data = decode::( + token, + &DecodingKey::from_secret(self.jwt_secret.as_bytes()), + &Validation::new(Algorithm::HS256), + ) + .map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?; + + Ok(token_data.claims) + } +} \ No newline at end of file diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs new file mode 100644 index 0000000..645045b --- /dev/null +++ b/backend/src/handlers.rs @@ -0,0 +1,57 @@ +use axum::{ + extract::{Query, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::Deserialize; +use std::sync::Arc; + +use crate::{AppState, models::{LoginRequest, RegisterRequest, AuthResponse, ApiError}}; + +#[derive(Deserialize)] +pub struct VerifyQuery { + pub token: String, +} + +pub async fn register( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> { + let response = state.auth_service.register(request).await?; + Ok(Json(response)) +} + +pub async fn login( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> { + let response = state.auth_service.login(request).await?; + Ok(Json(response)) +} + +pub async fn verify_token( + State(state): State>, + headers: HeaderMap, +) -> Result, ApiError> { + // Try to get token from Authorization header + let token = if let Some(auth_header) = headers.get("authorization") { + let auth_str = auth_header + .to_str() + .map_err(|_| ApiError::BadRequest("Invalid authorization header".to_string()))?; + + if let Some(token) = auth_str.strip_prefix("Bearer ") { + token.to_string() + } else { + return Err(ApiError::BadRequest("Authorization header must start with 'Bearer '".to_string())); + } + } else { + return Err(ApiError::Unauthorized("Authorization header required".to_string())); + }; + + let user_info = state.auth_service.verify_token(&token).await?; + + Ok(Json(serde_json::json!({ + "valid": true, + "user": user_info + }))) +} \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..c032831 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,85 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{sqlite::SqlitePool, Row}; +use tower_http::cors::{CorsLayer, Any}; +use uuid::Uuid; +use std::sync::Arc; + +mod auth; +mod models; +mod handlers; + +use auth::AuthService; +use models::{LoginRequest, RegisterRequest, AuthResponse, ApiError}; + +#[derive(Clone)] +pub struct AppState { + pub auth_service: AuthService, +} + +pub async fn run_server() -> Result<(), Box> { + // Initialize logging + println!("🚀 Starting Calendar Backend Server"); + + // Set up database + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite:calendar.db?mode=rwc".to_string()); + + let db_pool = SqlitePool::connect(&database_url).await?; + + // Run migrations - create database file if it doesn't exist + // The migrate!() macro looks for migrations in the current directory + // so we don't need to run explicit migrations here since we handle it in init_db() + + // Create auth service + let jwt_secret = std::env::var("JWT_SECRET") + .unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()); + + let auth_service = AuthService::new(db_pool, jwt_secret); + + // Initialize database schema + auth_service.init_db().await?; + + let app_state = AppState { auth_service }; + + // Build our application with routes + let app = Router::new() + .route("/", get(root)) + .route("/api/health", get(health_check)) + .route("/api/auth/register", post(handlers::register)) + .route("/api/auth/login", post(handlers::login)) + .route("/api/auth/verify", get(handlers::verify_token)) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .with_state(Arc::new(app_state)); + + // Start server + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; + println!("📡 Server listening on http://0.0.0.0:3000"); + + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn root() -> &'static str { + "Calendar Backend API v0.1.0" +} + +async fn health_check() -> Json { + Json(serde_json::json!({ + "status": "healthy", + "service": "calendar-backend", + "version": "0.1.0" + })) +} \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..a3375fb --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,7 @@ +// Backend main entry point +use calendar_backend::*; + +#[tokio::main] +async fn main() -> Result<(), Box> { + run_server().await +} \ No newline at end of file diff --git a/backend/src/models.rs b/backend/src/models.rs new file mode 100644 index 0000000..8e156fa --- /dev/null +++ b/backend/src/models.rs @@ -0,0 +1,90 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +// Database models +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: String, + pub username: String, + pub email: String, + pub password_hash: String, + pub created_at: DateTime, +} + +// API request/response types +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInfo { + pub id: String, + pub username: String, + pub email: String, +} + +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub email: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub token: String, + pub user: UserInfo, +} + +// Error handling +#[derive(Debug)] +pub enum ApiError { + Database(String), + NotFound(String), + Unauthorized(String), + BadRequest(String), + Conflict(String), + Internal(String), +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + ApiError::Database(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), + ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), + ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg), + ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + }; + + let body = Json(serde_json::json!({ + "error": error_message, + "status": status.as_u16() + })); + + (status, body).into_response() + } +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiError::Database(msg) => write!(f, "Database error: {}", msg), + ApiError::NotFound(msg) => write!(f, "Not found: {}", msg), + ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), + ApiError::BadRequest(msg) => write!(f, "Bad request: {}", msg), + ApiError::Conflict(msg) => write!(f, "Conflict: {}", msg), + ApiError::Internal(msg) => write!(f, "Internal error: {}", msg), + } + } +} + +impl std::error::Error for ApiError {} \ No newline at end of file diff --git a/calendar.db b/calendar.db new file mode 100644 index 0000000..46985a5 Binary files /dev/null and b/calendar.db differ diff --git a/migrations/001_create_users_table.sql b/migrations/001_create_users_table.sql new file mode 100644 index 0000000..af15c4d --- /dev/null +++ b/migrations/001_create_users_table.sql @@ -0,0 +1,14 @@ +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); \ No newline at end of file diff --git a/src/auth.rs b/src/auth.rs index e33ad4d..45a9207 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,5 +1,8 @@ -// Frontend-only authentication module (simplified for WASM compatibility) +// Frontend authentication module - connects to backend API use serde::{Deserialize, Serialize}; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Request, RequestInit, RequestMode, Response}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { @@ -34,90 +37,140 @@ pub struct AuthResponse { pub user: UserInfo, } -// Simplified frontend-only auth service -pub struct AuthService; +#[derive(Debug, Deserialize)] +pub struct ApiErrorResponse { + pub error: String, + pub status: u16, +} + +// Frontend auth service - connects to backend API +pub struct AuthService { + base_url: String, +} impl AuthService { pub fn new() -> Self { - Self + // Default to localhost backend - could be configurable via env var in the future + Self { + base_url: "http://localhost:3000/api".to_string(), + } } - // Mock authentication methods for development - // In production, these would make HTTP requests to a backend API - pub async fn register(&self, request: RegisterRequest) -> Result { - // Simulate API delay - gloo_timers::future::TimeoutFuture::new(500).await; - - // Basic validation - if request.username.trim().is_empty() || request.email.trim().is_empty() || request.password.is_empty() { - return Err("All fields are required".to_string()); - } - - if request.password.len() < 6 { - return Err("Password must be at least 6 characters".to_string()); - } - - // Mock successful registration - Ok(AuthResponse { - token: format!("mock-jwt-token-{}", request.username), - user: UserInfo { - id: "user-123".to_string(), - username: request.username, - email: request.email, - }, - }) + self.post_json("/auth/register", &request).await } pub async fn login(&self, request: LoginRequest) -> Result { - // Simulate API delay - gloo_timers::future::TimeoutFuture::new(500).await; - - // Basic validation - if request.username.trim().is_empty() || request.password.is_empty() { - return Err("Username and password are required".to_string()); - } - - // Mock authentication - accept demo/password or any user/password combo - if request.username == "demo" && request.password == "password" { - Ok(AuthResponse { - token: "mock-jwt-token-demo".to_string(), - user: UserInfo { - id: "demo-user-123".to_string(), - username: request.username, - email: "demo@example.com".to_string(), - }, - }) - } else if !request.password.is_empty() { - // Accept any non-empty password for development - let username = request.username.clone(); - Ok(AuthResponse { - token: format!("mock-jwt-token-{}", username), - user: UserInfo { - id: format!("user-{}", username), - username: request.username, - email: format!("{}@example.com", username), - }, - }) - } else { - Err("Invalid credentials".to_string()) - } + self.post_json("/auth/login", &request).await } pub async fn verify_token(&self, token: &str) -> Result { - // Simulate API delay - gloo_timers::future::TimeoutFuture::new(100).await; + let response = self.get_with_auth("/auth/verify", token).await?; + let json_value: serde_json::Value = response; - // Mock token verification - if token.starts_with("mock-jwt-token-") { - let username = token.strip_prefix("mock-jwt-token-").unwrap_or("unknown"); - Ok(UserInfo { - id: format!("user-{}", username), - username: username.to_string(), - email: format!("{}@example.com", username), - }) + if let Some(user_obj) = json_value.get("user") { + serde_json::from_value(user_obj.clone()) + .map_err(|e| format!("Failed to parse user info: {}", e)) } else { - Err("Invalid token".to_string()) + Err("Invalid response format".to_string()) + } + } + + // Helper method for POST requests with JSON body + async fn post_json Deserialize<'de>>( + &self, + endpoint: &str, + body: &T, + ) -> Result { + let window = web_sys::window().ok_or("No global window exists")?; + + let json_body = serde_json::to_string(body) + .map_err(|e| format!("JSON serialization failed: {}", e))?; + + let opts = RequestInit::new(); + opts.set_method("POST"); + opts.set_mode(RequestMode::Cors); + opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body)); + + let url = format!("{}{}", self.base_url, endpoint); + let request = Request::new_with_str_and_init(&url, &opts) + .map_err(|e| format!("Request creation failed: {:?}", e))?; + + request.headers().set("Content-Type", "application/json") + .map_err(|e| format!("Header setting failed: {:?}", e))?; + + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| format!("Network request failed: {:?}", e))?; + + let resp: Response = resp_value.dyn_into() + .map_err(|e| format!("Response cast failed: {:?}", e))?; + + let text = JsFuture::from(resp.text() + .map_err(|e| format!("Text extraction failed: {:?}", e))?) + .await + .map_err(|e| format!("Text promise failed: {:?}", e))?; + + let text_string = text.as_string() + .ok_or("Response text is not a string")?; + + if resp.ok() { + serde_json::from_str::(&text_string) + .map_err(|e| format!("JSON parsing failed: {}", e)) + } else { + // Try to parse error response + if let Ok(error_response) = serde_json::from_str::(&text_string) { + Err(error_response.error) + } else { + Err(format!("Request failed with status {}", resp.status())) + } + } + } + + // Helper method for GET requests with Authorization header + async fn get_with_auth( + &self, + endpoint: &str, + token: &str, + ) -> Result { + let window = web_sys::window().ok_or("No global window exists")?; + + let opts = RequestInit::new(); + opts.set_method("GET"); + opts.set_mode(RequestMode::Cors); + + let url = format!("{}{}", self.base_url, endpoint); + let request = Request::new_with_str_and_init(&url, &opts) + .map_err(|e| format!("Request creation failed: {:?}", e))?; + + request.headers().set("Authorization", &format!("Bearer {}", token)) + .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; + + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| format!("Network request failed: {:?}", e))?; + + let resp: Response = resp_value.dyn_into() + .map_err(|e| format!("Response cast failed: {:?}", e))?; + + let text = JsFuture::from(resp.text() + .map_err(|e| format!("Text extraction failed: {:?}", e))?) + .await + .map_err(|e| format!("Text promise failed: {:?}", e))?; + + let text_string = text.as_string() + .ok_or("Response text is not a string")?; + + if resp.ok() { + serde_json::from_str::(&text_string) + .map_err(|e| format!("JSON parsing failed: {}", e)) + } else { + // Try to parse error response + if let Ok(error_response) = serde_json::from_str::(&text_string) { + Err(error_response.error) + } else { + Err(format!("Request failed with status {}", resp.status())) + } } } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 0380f46..efe09de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,5 @@ mod app; -mod config; -mod calendar; mod auth; mod components;