1556 lines
63 KiB
XML
1556 lines
63 KiB
XML
This file is a merged representation of the entire codebase, combined into a single document by Repomix.
|
|
|
|
<file_summary>
|
|
This section contains a summary of this file.
|
|
|
|
<purpose>
|
|
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.
|
|
</purpose>
|
|
|
|
<file_format>
|
|
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
|
|
</file_format>
|
|
|
|
<usage_guidelines>
|
|
- 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.
|
|
</usage_guidelines>
|
|
|
|
<notes>
|
|
- 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)
|
|
</notes>
|
|
|
|
<additional_info>
|
|
|
|
</additional_info>
|
|
|
|
</file_summary>
|
|
|
|
<directory_structure>
|
|
.gitignore
|
|
Cargo.toml
|
|
src/auth.rs
|
|
src/db.rs
|
|
src/handlers.rs
|
|
src/main.rs
|
|
src/models.rs
|
|
</directory_structure>
|
|
|
|
<files>
|
|
This section contains the contents of the repository's files.
|
|
|
|
<file path=".gitignore">
|
|
/target
|
|
</file>
|
|
|
|
<file path="Cargo.toml">
|
|
[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"
|
|
</file>
|
|
|
|
<file path="src/auth.rs">
|
|
// 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<Result<Self, Self::Error>>;
|
|
|
|
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::<web::Data<Arc<Mutex<Connection>>>>();
|
|
|
|
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")))
|
|
}
|
|
}
|
|
}
|
|
</file>
|
|
|
|
<file path="src/db.rs">
|
|
// 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<Connection> {
|
|
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<bool> {
|
|
// 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<Option<String>> {
|
|
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<String> = 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<Option<models::UserAuthData>> {
|
|
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<String> {
|
|
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<Option<models::Form>> {
|
|
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)
|
|
}
|
|
</file>
|
|
|
|
<file path="src/handlers.rs">
|
|
// 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<Arc<Mutex<Connection>>>,
|
|
) -> Result<std::sync::MutexGuard<'_, Connection>, 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<String, String> = 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<String, &Map<String, JsonValue>> = 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<Arc<Mutex<Connection>>>,
|
|
creds: web::Json<LoginCredentials>,
|
|
) -> ActixResult<impl Responder> {
|
|
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<Arc<Mutex<Connection>>>,
|
|
auth: Auth, // Requires authentication (extracts user_id from token)
|
|
) -> ActixResult<impl Responder> {
|
|
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<Arc<Mutex<Connection>>>,
|
|
path: web::Path<String>, // Extracts form_id from path
|
|
submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload
|
|
) -> ActixResult<impl Responder> {
|
|
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<Arc<Mutex<Connection>>>,
|
|
auth: Auth, // Authentication check via Auth extractor
|
|
form_payload: web::Json<Form>,
|
|
) -> ActixResult<impl Responder> {
|
|
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<Arc<Mutex<Connection>>>,
|
|
auth: Auth, // Requires authentication
|
|
) -> ActixResult<impl Responder> {
|
|
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<Form> = 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<Result<Vec<Form>, 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<Arc<Mutex<Connection>>>,
|
|
auth: Auth, // Requires authentication
|
|
path: web::Path<String>, // Extracts form_id from the path
|
|
) -> ActixResult<impl Responder> {
|
|
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<Submission> = 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<Result<Option<Vec<Submission>>, 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"))
|
|
}
|
|
}
|
|
}
|
|
</file>
|
|
|
|
<file path="src/main.rs">
|
|
// 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<Mutex<>> 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
|
|
}
|
|
</file>
|
|
|
|
<file path="src/models.rs">
|
|
// 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<String>,
|
|
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<DateTime<Utc>>,
|
|
}
|
|
|
|
// 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<DateTime<Utc>>,
|
|
}
|
|
|
|
// 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<anyhow::Error> for AppError {
|
|
// fn from(err: anyhow::Error) -> Self {
|
|
// // Basic conversion, could add more context analysis here
|
|
// AppError::DatabaseError(err)
|
|
// }
|
|
// }
|
|
// impl From<actix_web::error::BlockingError> for AppError {
|
|
// fn from(err: actix_web::error::BlockingError) -> Self {
|
|
// AppError::BlockingError(err.to_string())
|
|
// }
|
|
//}
|
|
// // Add From<rusqlite::Error>, From<serde_json::Error>, etc. as needed
|
|
</file>
|
|
|
|
</files>
|