Compare commits
	
		
			2 Commits
		
	
	
		
			08c333dcba
			...
			42091492d5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 42091492d5 | ||
|   | 25bf194d19 | 
| @@ -9,5 +9,9 @@ CALDAV_CALENDAR_PATH=/calendars/your-username/personal/ | |||||||
| # Optional: Task/Todo collection path | # Optional: Task/Todo collection path | ||||||
| CALDAV_TASKS_PATH=/calendars/your-username/tasks/ | CALDAV_TASKS_PATH=/calendars/your-username/tasks/ | ||||||
|  |  | ||||||
|  | # Backend API Configuration | ||||||
|  | # Set this to point to your backend API server | ||||||
|  | BACKEND_API_URL=http://localhost:3000/api | ||||||
|  |  | ||||||
| # Development settings | # Development settings | ||||||
| RUST_LOG=info | RUST_LOG=info | ||||||
							
								
								
									
										13
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,8 +1,10 @@ | |||||||
| [package] | [package] | ||||||
| name = "yew-app" | name = "calendar-app" | ||||||
| version = "0.1.0" | version = "0.1.0" | ||||||
| edition = "2021" | edition = "2021" | ||||||
|  |  | ||||||
|  | # Frontend binary only | ||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| yew = { version = "0.21", features = ["csr"] } | yew = { version = "0.21", features = ["csr"] } | ||||||
| web-sys = "0.3" | web-sys = "0.3" | ||||||
| @@ -38,16 +40,9 @@ base64 = "0.21" | |||||||
| # XML/Regex parsing | # XML/Regex parsing | ||||||
| regex = "1.0" | regex = "1.0" | ||||||
|  |  | ||||||
| # Frontend authentication (backend removed for WASM compatibility) | # Yew routing and local storage (WASM only) | ||||||
| # sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate"] } |  | ||||||
| # bcrypt = "0.15" |  | ||||||
| # jsonwebtoken = "9.0" |  | ||||||
|  |  | ||||||
| # Yew routing and local storage |  | ||||||
| yew-router = "0.18" | yew-router = "0.18" | ||||||
| gloo-storage = "0.3" | gloo-storage = "0.3" | ||||||
| gloo-timers = "0.3" | gloo-timers = "0.3" | ||||||
| wasm-bindgen-futures = "0.4" | wasm-bindgen-futures = "0.4" | ||||||
|  |  | ||||||
| [dev-dependencies] |  | ||||||
| tokio = { version = "1.0", features = ["macros", "rt"] } |  | ||||||
| @@ -2,8 +2,12 @@ | |||||||
| target = "index.html" | target = "index.html" | ||||||
| dist = "dist" | dist = "dist" | ||||||
|  |  | ||||||
|  | [env] | ||||||
|  | BACKEND_API_URL = "http://localhost:3000/api" | ||||||
|  |  | ||||||
| [watch] | [watch] | ||||||
| watch = ["src", "Cargo.toml"] | watch = ["src", "Cargo.toml"] | ||||||
|  | ignore = ["backend/"] | ||||||
|  |  | ||||||
| [serve] | [serve] | ||||||
| address = "127.0.0.1" | address = "127.0.0.1" | ||||||
|   | |||||||
							
								
								
									
										29
									
								
								backend/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								backend/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -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"] } | ||||||
							
								
								
									
										230
									
								
								backend/src/auth.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								backend/src/auth.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AuthResponse, ApiError> { | ||||||
|  |         // 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<AuthResponse, ApiError> { | ||||||
|  |         // 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<UserInfo, ApiError> { | ||||||
|  |         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<User, ApiError> { | ||||||
|  |         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<User, ApiError> { | ||||||
|  |         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<User, ApiError> { | ||||||
|  |         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<bool, ApiError> { | ||||||
|  |         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<String, ApiError> { | ||||||
|  |         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<Claims, ApiError> { | ||||||
|  |         let token_data = decode::<Claims>( | ||||||
|  |             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) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										57
									
								
								backend/src/handlers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								backend/src/handlers.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Arc<AppState>>, | ||||||
|  |     Json(request): Json<RegisterRequest>, | ||||||
|  | ) -> Result<Json<AuthResponse>, ApiError> { | ||||||
|  |     let response = state.auth_service.register(request).await?; | ||||||
|  |     Ok(Json(response)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn login( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     Json(request): Json<LoginRequest>, | ||||||
|  | ) -> Result<Json<AuthResponse>, ApiError> { | ||||||
|  |     let response = state.auth_service.login(request).await?; | ||||||
|  |     Ok(Json(response)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn verify_token( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> Result<Json<serde_json::Value>, 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 | ||||||
|  |     }))) | ||||||
|  | } | ||||||
							
								
								
									
										85
									
								
								backend/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								backend/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<dyn std::error::Error>> { | ||||||
|  |     // 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<serde_json::Value> { | ||||||
|  |     Json(serde_json::json!({ | ||||||
|  |         "status": "healthy", | ||||||
|  |         "service": "calendar-backend", | ||||||
|  |         "version": "0.1.0" | ||||||
|  |     })) | ||||||
|  | } | ||||||
							
								
								
									
										7
									
								
								backend/src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								backend/src/main.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | // Backend main entry point | ||||||
|  | use calendar_backend::*; | ||||||
|  |  | ||||||
|  | #[tokio::main] | ||||||
|  | async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||||
|  |     run_server().await | ||||||
|  | } | ||||||
							
								
								
									
										90
									
								
								backend/src/models.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								backend/src/models.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Utc>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 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 {} | ||||||
							
								
								
									
										
											BIN
										
									
								
								calendar.db
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								calendar.db
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										14
									
								
								migrations/001_create_users_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								migrations/001_create_users_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||||
							
								
								
									
										197
									
								
								src/auth.rs
									
									
									
									
									
								
							
							
						
						
									
										197
									
								
								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 serde::{Deserialize, Serialize}; | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use wasm_bindgen_futures::JsFuture; | ||||||
|  | use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||||
|  |  | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | #[derive(Debug, Clone, Serialize, Deserialize)] | ||||||
| pub struct User { | pub struct User { | ||||||
| @@ -34,90 +37,142 @@ pub struct AuthResponse { | |||||||
|     pub user: UserInfo, |     pub user: UserInfo, | ||||||
| } | } | ||||||
|  |  | ||||||
| // Simplified frontend-only auth service | #[derive(Debug, Deserialize)] | ||||||
| pub struct AuthService; | pub struct ApiErrorResponse { | ||||||
|  |     pub error: String, | ||||||
|  |     pub status: u16, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Frontend auth service - connects to backend API | ||||||
|  | pub struct AuthService { | ||||||
|  |     base_url: String, | ||||||
|  | } | ||||||
|  |  | ||||||
| impl AuthService { | impl AuthService { | ||||||
|     pub fn new() -> Self { |     pub fn new() -> Self { | ||||||
|         Self |         // Get backend URL from environment variable at compile time, fallback to localhost | ||||||
|  |         let base_url = option_env!("BACKEND_API_URL") | ||||||
|  |             .unwrap_or("http://localhost:3000/api") | ||||||
|  |             .to_string(); | ||||||
|  |          | ||||||
|  |         Self { base_url } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Mock authentication methods for development |  | ||||||
|     // In production, these would make HTTP requests to a backend API |  | ||||||
|      |  | ||||||
|     pub async fn register(&self, request: RegisterRequest) -> Result<AuthResponse, String> { |     pub async fn register(&self, request: RegisterRequest) -> Result<AuthResponse, String> { | ||||||
|         // Simulate API delay |         self.post_json("/auth/register", &request).await | ||||||
|         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, |  | ||||||
|             }, |  | ||||||
|         }) |  | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, String> { |     pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, String> { | ||||||
|         // Simulate API delay |         self.post_json("/auth/login", &request).await | ||||||
|         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()) |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     pub async fn verify_token(&self, token: &str) -> Result<UserInfo, String> { |     pub async fn verify_token(&self, token: &str) -> Result<UserInfo, String> { | ||||||
|         // Simulate API delay |         let response = self.get_with_auth("/auth/verify", token).await?; | ||||||
|         gloo_timers::future::TimeoutFuture::new(100).await; |         let json_value: serde_json::Value = response; | ||||||
|          |          | ||||||
|         // Mock token verification |         if let Some(user_obj) = json_value.get("user") { | ||||||
|         if token.starts_with("mock-jwt-token-") { |             serde_json::from_value(user_obj.clone()) | ||||||
|             let username = token.strip_prefix("mock-jwt-token-").unwrap_or("unknown"); |                 .map_err(|e| format!("Failed to parse user info: {}", e)) | ||||||
|             Ok(UserInfo { |  | ||||||
|                 id: format!("user-{}", username), |  | ||||||
|                 username: username.to_string(), |  | ||||||
|                 email: format!("{}@example.com", username), |  | ||||||
|             }) |  | ||||||
|         } else { |         } else { | ||||||
|             Err("Invalid token".to_string()) |             Err("Invalid response format".to_string()) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Helper method for POST requests with JSON body | ||||||
|  |     async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>( | ||||||
|  |         &self, | ||||||
|  |         endpoint: &str, | ||||||
|  |         body: &T, | ||||||
|  |     ) -> Result<R, String> { | ||||||
|  |         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::<R>(&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::<ApiErrorResponse>(&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<serde_json::Value, String> { | ||||||
|  |         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::<serde_json::Value>(&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::<ApiErrorResponse>(&text_string) { | ||||||
|  |                 Err(error_response.error) | ||||||
|  |             } else { | ||||||
|  |                 Err(format!("Request failed with status {}", resp.status())) | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,7 +1,5 @@ | |||||||
|  |  | ||||||
| mod app; | mod app; | ||||||
| mod config; |  | ||||||
| mod calendar; |  | ||||||
| mod auth; | mod auth; | ||||||
| mod components; | mod components; | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user