This file is a merged representation of the entire codebase, combined into a single document by Repomix. This section contains a summary of this file. This file contains a packed representation of the entire repository's contents. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. The content is organized as follows: 1. This summary section 2. Repository information 3. Directory structure 4. Repository files, each consisting of: - File path as an attribute - Full contents of the file - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. - When processing this file, use the file path to distinguish between different files in the repository. - Be aware that this file may contain sensitive information. Handle it with the same level of security as you would the original repository. - Some files may have been excluded based on .gitignore rules and Repomix's configuration - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files - Files matching patterns in .gitignore are excluded - Files matching default ignore patterns are excluded - Files are sorted by Git change count (files with more changes are at the bottom) .gitignore Cargo.toml src/auth.rs src/db.rs src/handlers.rs src/main.rs src/models.rs This section contains the contents of the repository's files. /target [package] name = "formies_be" version = "0.1.0" edition = "2021" [dependencies] actix-web = "4.0" rusqlite = { version = "0.29", features = ["bundled", "chrono"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" uuid = { version = "1.0", features = ["v4"] } actix-files = "0.6" actix-cors = "0.6" env_logger = "0.10" log = "0.4" futures = "0.3" bcrypt = "0.13" anyhow = "1.0" dotenv = "0.15.0" chrono = { version = "0.4", features = ["serde"] } regex = "1" url = "2" // src/auth.rs use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; // Specific error types use actix_web::{ dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest, HttpRequest, Result as ActixResult, }; use chrono::{Duration, Utc}; // Import chrono for time checks use futures::future::{ready, Ready}; use log; // Use the log crate use rusqlite::Connection; use std::sync::{Arc, Mutex}; // Represents an authenticated user via token pub struct Auth { pub user_id: String, } impl FromRequest for Auth { // Use actix_web::Error for consistency in error handling within Actix type Error = ActixWebError; // Use Ready from futures 0.3 type Future = Ready>; fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { // Extract database connection pool from application data // Replace .expect() with proper error handling let db_data_result = req.app_data::>>>(); let db_data = match db_data_result { Some(data) => data, None => { log::error!("Database connection missing in application data configuration."); return ready(Err(ErrorInternalServerError( "Internal server error (app configuration)", ))); } }; // Extract Authorization header let auth_header = req.headers().get(AUTHORIZATION); if let Some(auth_header_value) = auth_header { // Convert header value to string if let Ok(auth_str) = auth_header_value.to_str() { // Check if it starts with "Bearer " if auth_str.starts_with("Bearer ") { // Extract the token part let token = &auth_str[7..]; // Lock the mutex to get access to the connection // Handle potential mutex poisoning explicitly let conn_guard = match db_data.lock() { Ok(guard) => guard, Err(poisoned) => { log::error!("Database mutex poisoned: {}", poisoned); // Return internal server error if mutex is poisoned return ready(Err(ErrorInternalServerError( "Internal server error (database lock)", ))); } }; // Validate the token against the database (now includes expiration check) match super::db::validate_token(&conn_guard, token) { // Token is valid and not expired, return Ok with Auth struct Ok(Some(user_id)) => { log::debug!("Token validated successfully for user_id: {}", user_id); ready(Ok(Auth { user_id })) } // Token is invalid, not found, or expired Ok(None) => { log::warn!("Invalid or expired token received"); // Avoid logging token ready(Err(ErrorUnauthorized("Invalid or expired token"))) } // Database error during token validation Err(e) => { log::error!("Database error during token validation: {:?}", e); // Return Unauthorized to avoid leaking internal error details // Consider mapping specific DB errors if needed, but Unauthorized is generally safe ready(Err(ErrorUnauthorized("Token validation failed"))) } } } else { // Header present but not "Bearer " format log::warn!("Invalid Authorization header format (not Bearer)"); ready(Err(ErrorUnauthorized("Invalid token format"))) } } else { // Header value contains invalid characters log::warn!("Authorization header contains invalid characters"); ready(Err(ErrorUnauthorized("Invalid token value"))) } } else { // Authorization header is missing log::warn!("Missing Authorization header"); ready(Err(ErrorUnauthorized("Missing authorization token"))) } } } // src/db.rs use anyhow::{anyhow, Context, Result as AnyhowResult}; use bcrypt::{hash, verify, DEFAULT_COST}; use chrono::{Duration as ChronoDuration, Utc}; // Use Utc for timestamps use log; // Use the log crate use rusqlite::{ params, types::Value as RusqliteValue, Connection, OptionalExtension, Result as RusqliteResult, }; use std::env; use uuid::Uuid; use crate::models; // Configurable token lifetime (e.g., from environment variable or default) const TOKEN_LIFETIME_HOURS: i64 = 24; // Default to 24 hours // Initialize the database connection and create tables if they don't exist pub fn init_db(database_url: &str) -> AnyhowResult { log::info!("Attempting to open or create database at: {}", database_url); let conn = Connection::open(database_url) .context(format!("Failed to open the database at {}", database_url))?; log::debug!("Creating 'users' table if not exists..."); conn.execute( "CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, -- Stores bcrypt hashed password token TEXT UNIQUE, -- Stores the current session token (UUID) token_expires_at DATETIME -- Timestamp when the token expires )", [], ) .context("Failed to create 'users' table")?; log::debug!("Creating 'forms' table if not exists..."); // Storing complex form definitions as JSON blobs in TEXT columns is pragmatic // but sacrifices DB-level type safety and query capabilities. Ensure robust // application-level validation and consider backup strategies carefully. conn.execute( "CREATE TABLE IF NOT EXISTS forms ( id TEXT PRIMARY KEY, name TEXT NOT NULL, fields TEXT NOT NULL, -- Stores JSON definition of form fields created_at DATETIME DEFAULT CURRENT_TIMESTAMP )", [], ) .context("Failed to create 'forms' table")?; log::debug!("Creating 'submissions' table if not exists..."); // Storing submission data as JSON blobs has similar tradeoffs as form fields. conn.execute( "CREATE TABLE IF NOT EXISTS submissions ( id TEXT PRIMARY KEY, form_id TEXT NOT NULL, data TEXT NOT NULL, -- Stores JSON submission data created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (form_id) REFERENCES forms (id) ON DELETE CASCADE )", [], ) .context("Failed to create 'submissions' table")?; // Setup the initial admin user if it doesn't exist, using environment variables setup_initial_admin(&conn).context("Failed to setup initial admin user")?; log::info!("Database initialization complete."); Ok(conn) } // Sets up the initial admin user from *required* environment variables if it doesn't exist fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> { // CRITICAL SECURITY CHANGE: Remove default credentials. Require env vars. let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME") .map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_USERNAME environment variable is not set."))?; let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD") .map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_PASSWORD environment variable is not set."))?; if initial_admin_username.is_empty() || initial_admin_password.is_empty() { return Err(anyhow!( "FATAL: INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD must not be empty." )); } // Check password complexity? (Optional enhancement) add_user_if_not_exists(conn, &initial_admin_username, &initial_admin_password) .context("Failed during initial admin user setup")?; Ok(()) } // Adds a user with a hashed password if the username doesn't exist pub fn add_user_if_not_exists( conn: &Connection, username: &str, password: &str, ) -> AnyhowResult { // Check if user already exists let user_exists: bool = conn .query_row( "SELECT EXISTS(SELECT 1 FROM users WHERE username = ?1)", params![username], |row| row.get::<_, i32>(0), ) .context(format!("Failed to check existence of user '{}'", username))? == 1; if user_exists { log::debug!("User '{}' already exists, skipping creation.", username); return Ok(false); // User already exists, nothing added } // Generate a UUID for the new user let user_id = Uuid::new_v4().to_string(); // Hash the password using bcrypt // Ensure the cost factor is appropriate for your security needs and hardware. // Higher cost means slower hashing and verification, but better resistance to brute-force. log::debug!( "Hashing password for user '{}' with cost {}", username, DEFAULT_COST ); let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?; // Insert the new user (token and expiry are initially NULL) log::info!("Creating new user '{}' with ID: {}", username, user_id); conn.execute( "INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)", params![user_id, username, hashed_password], ) .context(format!("Failed to insert user '{}'", username))?; Ok(true) // User was added } // Validate a session token and return the associated user ID if valid and not expired pub fn validate_token(conn: &Connection, token: &str) -> AnyhowResult> { log::debug!("Validating received token (existence and expiration)..."); let mut stmt = conn.prepare( // Select user ID only if token matches AND it hasn't expired "SELECT id FROM users WHERE token = ?1 AND token_expires_at IS NOT NULL AND token_expires_at > ?2" ).context("Failed to prepare query for validating token")?; let now_ts = Utc::now().to_rfc3339(); // Use ISO 8601 / RFC 3339 format compatible with SQLite DATETIME let user_id_option: Option = stmt .query_row(params![token, now_ts], |row| row.get(0)) .optional() // Makes it return Option instead of erroring on no rows .context("Failed to execute query for validating token")?; if user_id_option.is_some() { log::debug!("Token validation successful."); } else { // This covers token not found OR token expired log::debug!("Token validation failed (token not found or expired)."); } Ok(user_id_option) } // Invalidate a user's token (e.g., on logout) by setting it to NULL and clearing expiration pub fn invalidate_token(conn: &Connection, user_id: &str) -> AnyhowResult<()> { log::debug!("Invalidating token for user_id {}", user_id); conn.execute( "UPDATE users SET token = NULL, token_expires_at = NULL WHERE id = ?1", params![user_id], ) .context(format!( "Failed to invalidate token for user_id {}", user_id ))?; Ok(()) } // Authenticate a user by username and password, returning user ID and hash if successful pub fn authenticate_user( conn: &Connection, username: &str, password: &str, ) -> AnyhowResult> { log::debug!("Attempting to authenticate user: {}", username); let mut stmt = conn .prepare("SELECT id, password FROM users WHERE username = ?1") .context("Failed to prepare query for authenticating user")?; let result = stmt .query_row(params![username], |row| { Ok(models::UserAuthData { id: row.get(0)?, hashed_password: row.get(1)?, }) }) .optional() .context(format!( "Failed to execute query to fetch auth data for user '{}'", username ))?; match result { Some(user_data) => { // Verify the provided password against the stored hash let is_valid = verify(password, &user_data.hashed_password) .context("Failed to verify password hash")?; if is_valid { log::info!("Authentication successful for user: {}", username); Ok(Some(user_data)) // Return user ID and hash } else { log::warn!( "Authentication failed for user '{}' (invalid password)", username ); Ok(None) // Invalid password } } None => { log::warn!( "Authentication failed for user '{}' (user not found)", username ); Ok(None) // User not found } } } // Generate and save a new session token (with expiration) for a user pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> AnyhowResult { let new_token = Uuid::new_v4().to_string(); // Calculate expiration time let expires_at = Utc::now() + ChronoDuration::hours(TOKEN_LIFETIME_HOURS); let expires_at_ts = expires_at.to_rfc3339(); // Store as string log::debug!( "Generating new token for user_id {} expiring at {}", user_id, expires_at_ts ); conn.execute( "UPDATE users SET token = ?1, token_expires_at = ?2 WHERE id = ?3", params![new_token, expires_at_ts, user_id], ) .context(format!("Failed to update token for user_id {}", user_id))?; Ok(new_token) } // Fetch a specific form definition by its ID pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult> { let mut stmt = conn .prepare("SELECT id, name, fields FROM forms WHERE id = ?1") .context("Failed to prepare statement for getting form definition")?; let form_option = stmt .query_row(params![form_id], |row| { let id: String = row.get(0)?; let name: String = row.get(1)?; let fields_str: String = row.get(2)?; // Ensure fields can be parsed as valid JSON Value let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| { // Log clearly that this is a data integrity issue log::error!( "Database integrity error: Failed to parse 'fields' JSON for form_id {}: {}. Content: '{}'", id, e, fields_str // Log content if not too large/sensitive ); rusqlite::Error::FromSqlConversionFailure( 2, rusqlite::types::Type::Text, Box::new(e), ) })?; // **Basic check**: Ensure fields is an array (common pattern for form definitions) if !fields.is_array() { log::error!( "Database integrity error: 'fields' column for form_id {} is not a JSON array.", id ); return Err(rusqlite::Error::FromSqlConversionFailure( 2, rusqlite::types::Type::Text, "Form fields definition is not a valid JSON array".into(), )); } Ok(models::Form { id: Some(id), name, fields, }) }) .optional() // Handle case where form_id doesn't exist .context(format!( "Failed to execute query for form definition with id {}", form_id ))?; Ok(form_option) } // src/handlers.rs use crate::auth::Auth; use crate::models::{Form, LoginCredentials, LoginResponse, Submission}; use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult}; use anyhow::Context; // Import anyhow::Context for error chaining use log; use regex::Regex; // For pattern validation use rusqlite::{params, Connection}; use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity use std::collections::HashMap; use std::error::Error as StdError; use std::sync::{Arc, Mutex}; use uuid::Uuid; // --- Helper Function for Database Access --- // Gets a database connection from the request data, handling lock errors consistently. fn get_db_conn( db: &web::Data>>, ) -> Result, ActixWebError> { db.lock().map_err(|poisoned| { log::error!("Database mutex poisoned: {}", poisoned); actix_web::error::ErrorInternalServerError("Internal database error (mutex lock)") }) } // --- Helper Function for Validation --- /// Validates submission data against the form field definitions with enhanced checks. /// /// Expected field definition properties: /// - `name`: string (required) /// - `type`: string (e.g., "string", "number", "boolean", "email", "url", "object", "array") (required) /// - `required`: boolean (optional, default: false) /// - `maxLength`: number (for "string" type) /// - `minLength`: number (for "string" type) /// - `min`: number (for "number" type) /// - `max`: number (for "number" type) /// - `pattern`: string (regex for "string", "email", "url" types) /// /// Returns `Ok(())` if valid, or `Err(JsonValue)` containing validation errors. fn validate_submission_against_definition( submission_data: &JsonValue, form_definition_fields: &JsonValue, ) -> Result<(), JsonValue> { let mut errors: HashMap = HashMap::new(); // Ensure 'fields' in the definition is a JSON array let field_definitions = match form_definition_fields.as_array() { Some(defs) => defs, None => { log::error!( "Form definition 'fields' is not a JSON array. Def: {:?}", form_definition_fields ); errors.insert( "_internal".to_string(), "Invalid form definition format (not an array)".to_string(), ); return Err(json!({ "validation_errors": errors })); } }; // Ensure the submission data is a JSON object let data_map = match submission_data.as_object() { Some(map) => map, None => { errors.insert( "_submission".to_string(), "Submission data must be a JSON object".to_string(), ); return Err(json!({ "validation_errors": errors })); } }; // Build a map of valid field names to their definitions from the definition for quick lookup let defined_field_names: HashMap> = field_definitions .iter() .filter_map(|val| val.as_object()) .filter_map(|def| { def.get("name") .and_then(JsonValue::as_str) .map(|name| (name.to_string(), def)) }) .collect(); // 1. Check for submitted fields that are NOT in the definition for submitted_key in data_map.keys() { if !defined_field_names.contains_key(submitted_key) { errors.insert( submitted_key.clone(), "Unexpected field submitted".to_string(), ); } } // Exit early if unexpected fields were found if !errors.is_empty() { log::warn!("Submission validation failed: Unexpected fields submitted."); return Err(json!({ "validation_errors": errors })); } // 2. Iterate through each field definition and validate corresponding submitted data for (field_name, field_def) in &defined_field_names { // Extract properties using helper functions for clarity let field_type = field_def .get("type") .and_then(JsonValue::as_str) .unwrap_or("string"); // Default to "string" if type is missing or not a string let is_required = field_def .get("required") .and_then(JsonValue::as_bool) .unwrap_or(false); // Default to false if required is missing or not a boolean let min_length = field_def.get("minLength").and_then(JsonValue::as_u64); let max_length = field_def.get("maxLength").and_then(JsonValue::as_u64); let min_value = field_def.get("min").and_then(JsonValue::as_f64); // Use f64 for flexibility let max_value = field_def.get("max").and_then(JsonValue::as_f64); let pattern = field_def.get("pattern").and_then(JsonValue::as_str); match data_map.get(field_name) { Some(submitted_value) if !submitted_value.is_null() => { // Field is present and not null, perform type and constraint checks let mut type_error = None; let mut constraint_errors = vec![]; match field_type { "string" | "email" | "url" => { if let Some(s) = submitted_value.as_str() { if let Some(min) = min_length { if (s.chars().count() as u64) < min { // Use chars().count() for UTF-8 correctness constraint_errors .push(format!("Must be at least {} characters long", min)); } } if let Some(max) = max_length { if (s.chars().count() as u64) > max { constraint_errors.push(format!( "Must be no more than {} characters long", max )); } } if let Some(pat) = pattern { // Consider caching compiled Regex if performance is critical // and patterns are reused frequently across requests. match Regex::new(pat) { Ok(re) => { if !re.is_match(s) { constraint_errors.push(format!("Does not match required pattern")); } } Err(e) => log::warn!("Invalid regex pattern '{}' in form definition for field '{}': {}", pat, field_name, e), // Log regex compilation error } } // Specific checks for email/url if field_type == "email" { // Basic email regex (adjust for stricter needs or use a validation crate) // This regex is very basic and allows many technically invalid addresses. // Consider crates like `validator` for more robust validation. let email_regex = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); // Safe unwrap for known valid regex if !email_regex.is_match(s) { constraint_errors .push("Must be a valid email address".to_string()); } } if field_type == "url" { // Basic URL check (consider `url` crate for robustness) if url::Url::parse(s).is_err() { constraint_errors.push("Must be a valid URL".to_string()); } } } else { type_error = Some(format!("Expected a string for '{}'", field_name)); } } "number" => { // Use as_f64 for flexibility (handles integers and floats) if let Some(num) = submitted_value.as_f64() { if let Some(min) = min_value { if num < min { constraint_errors.push(format!("Must be at least {}", min)); } } if let Some(max) = max_value { if num > max { constraint_errors.push(format!("Must be no more than {}", max)); } } } else { type_error = Some(format!("Expected a number for '{}'", field_name)); } } "boolean" => { if !submitted_value.is_boolean() { type_error = Some(format!( "Expected a boolean (true/false) for '{}'", field_name )); } } "object" => { if !submitted_value.is_object() { type_error = Some(format!("Expected a JSON object for '{}'", field_name)); } // TODO: Could add deeper validation for object structure here if needed based on definition } "array" => { if !submitted_value.is_array() { type_error = Some(format!("Expected a JSON array for '{}'", field_name)); } // TODO: Could add validation for array elements here if needed based on definition } _ => { // Log unsupported types during development/debugging if necessary log::trace!( "Unsupported field type '{}' encountered during validation for field '{}'. Treating as valid.", field_type, field_name ); // Assume valid if type is not specifically handled or unknown } } // Record errors found for this field if let Some(err) = type_error { errors.insert(field_name.clone(), err); } else if !constraint_errors.is_empty() { // Combine multiple constraint errors if necessary errors.insert(field_name.clone(), constraint_errors.join("; ")); } } // End check for present and non-null value Some(_) => { // Value is present but explicitly null (e.g., "fieldName": null) if is_required { errors.insert( field_name.clone(), "This field is required and cannot be null".to_string(), ); } // Otherwise, null is considered a valid (empty) value for non-required fields } None => { // Field is missing entirely from the submission object if is_required { errors.insert(field_name.clone(), "This field is required".to_string()); } // Missing is valid for non-required fields } } // End match data_map.get(field_name) } // End loop through field definitions // Check if any errors were collected if errors.is_empty() { Ok(()) // Validation passed } else { log::info!( "Submission validation failed with {} error(s).", // Log only the count for brevity errors.len() ); // Return a JSON object containing the specific validation errors Err(json!({ "validation_errors": errors })) } } // Helper function to convert anyhow::Error to actix_web::Error fn anyhow_to_actix_error(e: anyhow::Error) -> ActixWebError { actix_web::error::ErrorInternalServerError(e.to_string()) } // --- Public Handlers --- // POST /login pub async fn login( db: web::Data>>, creds: web::Json, ) -> ActixResult { let db_conn = db.clone(); // Clone Arc for use in web::block let username = creds.username.clone(); let password = creds.password.clone(); // Wrap the blocking database operations in web::block let auth_result = web::block(move || { let conn = db_conn .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?; crate::db::authenticate_user(&conn, &username, &password) }) .await .map_err(|e| { log::error!("web::block error during authentication: {:?}", e); actix_web::error::ErrorInternalServerError("Authentication process failed (blocking error)") })? .map_err(anyhow_to_actix_error)?; match auth_result { Some(user_data) => { let db_conn_token = db.clone(); // Clone Arc again for token generation let user_id = user_data.id.clone(); // Generate and store a new token within web::block let token = web::block(move || { let conn = db_conn_token .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?; crate::db::generate_and_set_token_for_user(&conn, &user_id) }) .await .map_err(|e| { log::error!("web::block error during token generation: {:?}", e); actix_web::error::ErrorInternalServerError( "Failed to complete login (token generation blocking error)", ) })? .map_err(anyhow_to_actix_error)?; log::info!("Login successful for user_id: {}", user_data.id); Ok(HttpResponse::Ok().json(LoginResponse { token })) } None => { log::warn!("Login failed for username: {}", creds.username); // Return 401 Unauthorized for failed login attempts Err(actix_web::error::ErrorUnauthorized( "Invalid username or password", )) } } } // POST /logout pub async fn logout( db: web::Data>>, auth: Auth, // Requires authentication (extracts user_id from token) ) -> ActixResult { log::info!("User {} requesting logout", auth.user_id); let db_conn = db.clone(); let user_id = auth.user_id.clone(); // Invalidate the token in the database within web::block web::block(move || { let conn = db_conn .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?; crate::db::invalidate_token(&conn, &user_id) }) .await .map_err(|e| { let user_id = auth.user_id.clone(); // Clone user_id again after the move log::error!( "web::block error during logout for user {}: {:?}", user_id, e ); actix_web::error::ErrorInternalServerError("Logout failed (blocking error)") })? .map_err(anyhow_to_actix_error)?; log::info!("User {} logged out successfully", auth.user_id); Ok(HttpResponse::Ok().json(json!({ "message": "Logged out successfully" }))) } // POST /forms/{form_id}/submissions pub async fn submit_form( db: web::Data>>, path: web::Path, // Extracts form_id from path submission_payload: web::Json, // Expect arbitrary JSON payload ) -> ActixResult { let form_id = path.into_inner(); let submission_data = submission_payload.into_inner(); // Get the JSON data // --- Stage 1: Fetch form definition (Read-only, can use shared lock) --- let form_definition = { // Acquire lock temporarily for the read operation let conn = get_db_conn(&db)?; match crate::db::get_form_definition(&conn, &form_id) { Ok(Some(form)) => form, Ok(None) => { log::warn!("Submission attempt for non-existent form_id: {}", form_id); return Err(actix_web::error::ErrorNotFound("Form not found")); } Err(e) => { log::error!("Failed to fetch form definition for {}: {:?}", form_id, e); return Err(actix_web::error::ErrorInternalServerError( "Could not retrieve form information", )); } } // Lock is released here when 'conn' goes out of scope }; // --- Stage 2: Validate submission against definition (CPU-bound, no DB lock needed) --- if let Err(validation_errors) = validate_submission_against_definition(&submission_data, &form_definition.fields) { log::warn!( "Submission validation failed for form_id {}. Errors: {:?}", // Log actual errors if needed (might be verbose) form_id, validation_errors ); // Return 400 Bad Request with validation error details return Ok(HttpResponse::BadRequest().json(validation_errors)); } // --- Stage 3: Serialize validated data and Insert submission (Write operation, use web::block) --- let submission_json = match serde_json::to_string(&submission_data) { Ok(json_string) => json_string, Err(e) => { log::error!( "Failed to serialize validated submission data for form {}: {}", form_id, e ); return Err(actix_web::error::ErrorInternalServerError( "Failed to process submission data internally", )); } }; let db_conn_write = db.clone(); // Clone Arc for the blocking operation let form_id_clone = form_id.clone(); // Clone for closure let submission_id = Uuid::new_v4().to_string(); // Generate unique ID for the submission let submission_id_clone = submission_id.clone(); // Clone for closure web::block(move || { let conn = db_conn_write.lock().map_err(|_| { anyhow::anyhow!("Database mutex poisoned during submission insert lock") })?; conn.execute( "INSERT INTO submissions (id, form_id, data) VALUES (?1, ?2, ?3)", params![submission_id_clone, form_id_clone, submission_json], ) .context(format!( "Failed to insert submission for form {}", form_id_clone )) .map_err(anyhow::Error::from) }) .await .map_err(|e| { log::error!( "web::block error during submission insertion for form {}: {:?}", form_id, e ); actix_web::error::ErrorInternalServerError("Failed to save submission (blocking error)") })? .map_err(anyhow_to_actix_error)?; log::info!( "Successfully inserted submission {} for form_id {}", submission_id, form_id ); // Return 200 OK with the new submission ID Ok(HttpResponse::Ok().json(json!({ "submission_id": submission_id }))) } // --- Protected Handlers (Require Auth) --- // POST /forms pub async fn create_form( db: web::Data>>, auth: Auth, // Authentication check via Auth extractor form_payload: web::Json
, ) -> ActixResult { log::info!( "User {} attempting to create form: {}", auth.user_id, form_payload.name ); let mut form = form_payload.into_inner(); // Generate a new UUID for the form if not provided (or overwrite if provided) let form_id = form.id.unwrap_or_else(|| Uuid::new_v4().to_string()); form.id = Some(form_id.clone()); // Ensure the form object has the ID // Basic structural validation: Ensure 'fields' is a JSON array before serialization/saving if !form.fields.is_array() { log::error!( "User {} attempted to create form '{}' ('{}') where 'fields' is not a JSON array.", auth.user_id, form.name, form_id ); return Err(actix_web::error::ErrorBadRequest( "Form 'fields' must be a valid JSON array.", )); } // TODO: Add deeper validation of the 'fields' structure itself if needed // e.g., check if each element in 'fields' is an object with 'name' and 'type'. // Serialize the fields part to JSON string for DB storage let fields_json = match serde_json::to_string(&form.fields) { Ok(json_str) => json_str, Err(e) => { log::error!( "Failed to serialize form fields for form '{}' ('{}') by user {}: {}", form.name, form_id, auth.user_id, e ); return Err(actix_web::error::ErrorInternalServerError( "Failed to process form fields internally", )); } }; // Clone data needed for the blocking database operation let db_conn = db.clone(); // let form_id = form_id; // Already have it from above let form_name = form.name.clone(); let user_id = auth.user_id.clone(); // For logging inside block if needed // Insert the form using web::block for the blocking DB write web::block(move || { let conn = db_conn .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during form creation lock"))?; conn.execute( // Consider adding user_id to the forms table if forms are user-specific "INSERT INTO forms (id, name, fields) VALUES (?1, ?2, ?3)", params![form_id, form_name, fields_json], ) .context("Failed to insert new form into database") .map_err(anyhow::Error::from) }) .await .map_err(|e| { log::error!( "web::block error during form creation by user {}: {:?}", auth.user_id, e ); actix_web::error::ErrorInternalServerError("Failed to create form (blocking error)") })? .map_err(anyhow_to_actix_error)?; log::info!( "Successfully created form '{}' with id {} by user {}", form.name, form.id.as_ref().unwrap(), // Safe unwrap as we set it auth.user_id ); // Return 200 OK with the newly created form object (including its ID) Ok(HttpResponse::Ok().json(form)) } // GET /forms pub async fn get_forms( db: web::Data>>, auth: Auth, // Requires authentication ) -> ActixResult { log::info!("User {} requesting list of forms", auth.user_id); let db_conn = db.clone(); let user_id = auth.user_id.clone(); // Clone for logging context if needed inside block // Wrap DB query in web::block as it might be slow with many forms or complex parsing let forms_result = web::block(move || { let conn = db_conn .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_forms lock"))?; let mut stmt = conn .prepare("SELECT id, name, fields FROM forms") .context("Failed to prepare statement for getting forms")?; let forms_iter = stmt .query_map([], |row| { let id: String = row.get(0)?; let name: String = row.get(1)?; let fields_str: String = row.get(2)?; // Parse the 'fields' JSON string. If it fails, log the error and skip the row. let fields: serde_json::Value = match serde_json::from_str(&fields_str) { Ok(json_value) => json_value, Err(e) => { // Log the data integrity issue clearly log::error!( "DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.", id, e ); // Return a special error that `filter_map` below can catch, // without failing the entire query_map. // Using a specific rusqlite error type here is okay. return Err(rusqlite::Error::FromSqlConversionFailure( 2, // Column index rusqlite::types::Type::Text, Box::new(e) // Box the original error )); } }; Ok(Form { id: Some(id), name, fields }) }) .context("Failed to execute query map for getting forms")?; // Collect results, filtering out rows that failed parsing WITHIN the block let forms: Vec = forms_iter .filter_map(|result| match result { Ok(form) => Some(form), Err(e) => { // Error was already logged inside the query_map closure. // We just filter out the failed row here. log::warn!("Skipping a form row due to a processing error: {}", e); None // Skip this row } }) .collect(); Ok::<_, anyhow::Error>(forms) // Ensure block returns Result compatible with flattening }) .await .map_err(|e| { // Handle web::block error log::error!("web::block error during get_forms for user {}: {:?}", user_id, e); actix_web::error::ErrorInternalServerError("Failed to retrieve forms (blocking error)") })? .map_err(anyhow_to_actix_error)?; // Flatten Result, anyhow::Error>, BlockingError> log::debug!( "Returning {} forms for user {}", forms_result.len(), auth.user_id ); Ok(HttpResponse::Ok().json(forms_result)) } // GET /forms/{form_id}/submissions pub async fn get_submissions( db: web::Data>>, auth: Auth, // Requires authentication path: web::Path, // Extracts form_id from the path ) -> ActixResult { let form_id = path.into_inner(); log::info!( "User {} requesting submissions for form_id: {}", auth.user_id, form_id ); let db_conn = db.clone(); let form_id_clone = form_id.clone(); let user_id = auth.user_id.clone(); // Clone for logging context // Wrap DB queries (existence check + fetching submissions) in web::block let submissions_result = web::block(move || { let conn = db_conn .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_submissions lock"))?; // 1. Check if the form exists first let form_exists: bool = match conn.query_row( "SELECT EXISTS(SELECT 1 FROM forms WHERE id = ?1 LIMIT 1)", // Added LIMIT 1 for potential optimization params![form_id_clone], |row| row.get::<_, i32>(0), // sqlite returns 0 or 1 for EXISTS ) { Ok(count) => count == 1, Err(rusqlite::Error::QueryReturnedNoRows) => false, // Should not happen with EXISTS, but handle defensively Err(e) => return Err(anyhow::Error::from(e) // Propagate other DB errors .context(format!("Failed check existence of form {}", form_id_clone))), }; if !form_exists { // Use Ok(None) to signal "form not found" to the calling async context return Ok(None); } // 2. If form exists, fetch its submissions let mut stmt = conn.prepare( "SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", // Include created_at if needed ) .context(format!("Failed to prepare statement for getting submissions for form {}", form_id_clone))?; let submissions_iter = stmt .query_map(params![form_id_clone], |row| { let id: String = row.get(0)?; let form_id_db: String = row.get(1)?; let data_str: String = row.get(2)?; // let created_at: String = row.get(3)?; // Example: If you fetch created_at // Parse the 'data' JSON string, handling potential errors let data: serde_json::Value = match serde_json::from_str(&data_str) { Ok(json_value) => json_value, Err(e) => { log::error!( "DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.", id, e ); // Return specific error for filter_map return Err(rusqlite::Error::FromSqlConversionFailure( 2, rusqlite::types::Type::Text, Box::new(e) )); } }; Ok(Submission { id, form_id: form_id_db, data }) // Add created_at if fetched }) .context(format!("Failed to execute query map for getting submissions for form {}", form_id_clone))?; // Collect valid submissions, filtering out rows that failed parsing let submissions: Vec = submissions_iter .filter_map(|result| match result { Ok(submission) => Some(submission), Err(e) => { log::warn!("Skipping a submission row due to processing error: {}", e); None // Skip this row } }) .collect(); Ok(Some(submissions)) // Indicate success with the (potentially empty) list of submissions }) .await .map_err(|e| { // Handle web::block error (cancellation, panic) log::error!("web::block error during get_submissions for form {} by user {}: {:?}", form_id, user_id, e); actix_web::error::ErrorInternalServerError("Failed to retrieve submissions (blocking error)") })? .map_err(anyhow_to_actix_error)?; // Flatten Result>, anyhow::Error>, BlockingError> // Process the result obtained from the web::block match submissions_result { Some(submissions) => { // Form exists, return the found submissions (might be an empty list) log::debug!( "Returning {} submissions for form {} requested by user {}", submissions.len(), form_id, auth.user_id ); Ok(HttpResponse::Ok().json(submissions)) } None => { // Form was not found (signaled by Ok(None) from the block) log::warn!( "Attempt by user {} to get submissions for non-existent form_id: {}", auth.user_id, form_id ); Err(actix_web::error::ErrorNotFound("Form not found")) } } } // src/main.rs use actix_cors::Cors; use actix_files as fs; use actix_web::{http::header, middleware::Logger, web, App, HttpServer}; // Added Logger explicitly use dotenv::dotenv; use log; use std::env; use std::io::Result as IoResult; // Alias for clarity use std::process; use std::sync::{Arc, Mutex}; // Import modules mod auth; mod db; mod handlers; mod models; #[actix_web::main] async fn main() -> IoResult<()> { dotenv().ok(); // Load .env file // Initialize logger (using RUST_LOG env var) env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); // --- Configuration (Environment Variables) --- // CRITICAL: Database URL is required let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { log::warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'."); "form_data.db".to_string() }); // CRITICAL: Bind address is required let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| { log::warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'."); "127.0.0.1:8080".to_string() }); // CRITICAL: Initial admin credentials (checked in db::init_db) // let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME").expect("Missing INITIAL_ADMIN_USERNAME"); // let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD").expect("Missing INITIAL_ADMIN_PASSWORD"); // OPTIONAL: Allowed origin for CORS let allowed_origin = env::var("ALLOWED_ORIGIN").ok(); // Use ok() to make it optional log::info!(" --- Formies Backend Configuration ---"); log::info!("Required Environment Variables:"); log::info!(" - DATABASE_URL (Current: {})", database_url); log::info!(" - BIND_ADDRESS (Current: {})", bind_address); log::info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)"); log::info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)"); log::info!("Optional Environment Variables:"); if let Some(ref origin) = allowed_origin { log::info!(" - ALLOWED_ORIGIN (Set: {})", origin); } else { log::warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive, potentially blocking browser access. Set to your frontend URL (e.g., http://localhost:5173 or https://yourdomain.com)."); } log::info!(" - RUST_LOG (e.g., 'info,formies_be=debug')"); log::info!(" --- End Configuration ---"); // Initialize database connection let db_connection = match db::init_db(&database_url) { Ok(conn) => conn, Err(e) => { // Specific check for missing admin credentials error if e.to_string().contains("INITIAL_ADMIN_USERNAME") || e.to_string().contains("INITIAL_ADMIN_PASSWORD") { log::error!("FATAL: {}", e); log::error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables."); } else { log::error!( "FATAL: Failed to initialize database at {}: {:?}", database_url, e ); } process::exit(1); // Exit if DB initialization fails } }; // Wrap connection in Arc> for thread-safe sharing let db_data = web::Data::new(Arc::new(Mutex::new(db_connection))); log::info!("Starting server at http://{}", bind_address); HttpServer::new(move || { // Clone shared state for the closure let db_data_clone = db_data.clone(); let allowed_origin_clone = allowed_origin.clone(); // Configure CORS let cors = match allowed_origin_clone { Some(origin) => { log::info!("Configuring CORS for specific origin: {}", origin); Cors::default() .allowed_origin(&origin) // Allow only the specified origin .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) .allowed_headers(vec![ header::AUTHORIZATION, header::ACCEPT, header::CONTENT_TYPE, header::ORIGIN, // Add Origin header if needed header::ACCESS_CONTROL_REQUEST_METHOD, header::ACCESS_CONTROL_REQUEST_HEADERS, ]) .supports_credentials() .max_age(3600) } None => { // Default restrictive CORS: No origin allowed explicitly. // This will likely block browser requests unless the browser and server are on the same origin. log::warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set."); Cors::default() // No allowed_origin set .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) .allowed_headers(vec![ header::AUTHORIZATION, header::ACCEPT, header::CONTENT_TYPE, header::ORIGIN, header::ACCESS_CONTROL_REQUEST_METHOD, header::ACCESS_CONTROL_REQUEST_HEADERS, ]) .supports_credentials() .max_age(3600) // DO NOT use allow_any_origin() unless you fully understand the security implications. } }; App::new() .wrap(cors) // Apply CORS middleware .wrap(Logger::default()) // Add request logging (default format) .app_data(db_data_clone) // Share database connection pool // --- API Routes --- .service( web::scope("/api") // Group API routes under /api // --- Public Routes --- .route("/login", web::post().to(handlers::login)) .route( "/forms/{form_id}/submissions", web::post().to(handlers::submit_form), ) // --- Protected Routes (using Auth extractor) --- .route("/logout", web::post().to(handlers::logout)) // Added logout .route("/forms", web::post().to(handlers::create_form)) .route("/forms", web::get().to(handlers::get_forms)) .route( "/forms/{form_id}/submissions", web::get().to(handlers::get_submissions), ), ) // --- Static Files (Serve Frontend - Optional) --- // Assumes frontend build output is in ../frontend/dist // Register this LAST to avoid conflicts with API routes .service( fs::Files::new("/", "../frontend/dist/") .index_file("index.html") .use_last_modified(true) // Optional: Add a fallback to index.html for SPA routing .default_handler( fs::NamedFile::open("../frontend/dist/index.html").unwrap_or_else(|_| { log::error!("Fallback file not found: ../frontend/dist/index.html"); process::exit(1); // Exit if fallback file is missing }), // Handle error explicitly ), ) }) .bind(&bind_address)? .run() .await } // src/models.rs use serde::{Deserialize, Serialize}; // Consider adding chrono for DateTime types if needed in responses // use chrono::{DateTime, Utc}; // Represents the structure for defining a form #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Form { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, pub name: String, /// Stores the structure defining the form fields. /// Expected to be a JSON array of field definition objects. /// Example field definition object: /// ```json /// { /// "name": "email", // String, required: Unique identifier for the field /// "type": "email", // String, required: "string", "number", "boolean", "email", "url", "object", "array" /// "label": "Email Address", // String, optional: User-friendly label /// "required": true, // Boolean, optional (default: false): If the field must have a value /// "placeholder": "you@example.com", // String, optional: Placeholder text /// "minLength": 5, // Number, optional: Minimum length for strings /// "maxLength": 100, // Number, optional: Maximum length for strings /// "min": 0, // Number, optional: Minimum value for numbers /// "max": 100, // Number, optional: Maximum value for numbers /// "pattern": "^\\S+@\\S+\\.\\S+$" // String, optional: Regex pattern for strings (e.g., email, url types might use this implicitly or explicitly) /// // Add other properties like "options" for select/radio, etc. /// } /// ``` pub fields: serde_json::Value, // Optional: Add created_at if needed in API responses // pub created_at: Option>, } // Represents a single submission for a specific form #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Submission { pub id: String, pub form_id: String, /// Stores the data submitted by the user. /// Expected to be a JSON object where keys are field names (`name`) from the form definition's `fields` array. /// Example: `{ "email": "user@example.com", "age": 30 }` pub data: serde_json::Value, // Optional: Add created_at if needed in API responses // pub created_at: Option>, } // Used for the /login endpoint request body #[derive(Debug, Serialize, Deserialize)] pub struct LoginCredentials { pub username: String, pub password: String, } // Used for the /login endpoint response body #[derive(Debug, Serialize, Deserialize)] pub struct LoginResponse { pub token: String, // The session token (UUID) } // Used internally to represent a user fetched from the DB for authentication check // Not serialized, only used within db.rs and handlers.rs #[derive(Debug)] pub struct UserAuthData { pub id: String, pub hashed_password: String, // Note: Token and expiry are handled separately and not needed in this specific struct } // --- Custom Application Error (Optional but Recommended for Consistency) --- // Although not fully integrated in this pass to minimize changes, // this shows the structure for future improvement. // use actix_web::{ResponseError, http::StatusCode}; // use std::fmt; // #[derive(Debug)] // pub enum AppError { // DatabaseError(anyhow::Error), // ConfigError(String), // ValidationError(serde_json::Value), // Store the validation errors JSON // NotFound(String), // Unauthorized(String), // InternalError(String), // BlockingError(String), // } // impl fmt::Display for AppError { // fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // match self { // AppError::DatabaseError(e) => write!(f, "Database error: {}", e), // AppError::ConfigError(s) => write!(f, "Configuration error: {}", s), // AppError::ValidationError(_) => write!(f, "Validation failed"), // AppError::NotFound(s) => write!(f, "Not found: {}", s), // AppError::Unauthorized(s) => write!(f, "Unauthorized: {}", s), // AppError::InternalError(s) => write!(f, "Internal server error: {}", s), // AppError::BlockingError(s) => write!(f, "Blocking operation error: {}", s), // } // } // } // impl ResponseError for AppError { // fn status_code(&self) -> StatusCode { // match self { // AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, // AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR, // AppError::ValidationError(_) => StatusCode::BAD_REQUEST, // AppError::NotFound(_) => StatusCode::NOT_FOUND, // AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, // AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, // AppError::BlockingError(_) => StatusCode::INTERNAL_SERVER_ERROR, // } // } // fn error_response(&self) -> HttpResponse { // let status = self.status_code(); // let error_json = match self { // AppError::ValidationError(errors) => errors.clone(), // // Provide a generic error structure for others // _ => json!({ "error": status.canonical_reason().unwrap_or("Unknown Error"), "message": self.to_string() }), // }; // HttpResponse::build(status).json(error_json) // } // } // // Implement From traits to convert other errors into AppError easily // impl From for AppError { // fn from(err: anyhow::Error) -> Self { // // Basic conversion, could add more context analysis here // AppError::DatabaseError(err) // } // } // impl From for AppError { // fn from(err: actix_web::error::BlockingError) -> Self { // AppError::BlockingError(err.to_string()) // } //} // // Add From, From, etc. as needed