This commit is contained in:
Mohamad 2025-05-06 12:21:01 +02:00
parent fe5184e18c
commit 1b012b3923
10 changed files with 5476 additions and 219 deletions

5
Cargo.lock generated
View File

@ -875,6 +875,7 @@ dependencies = [
"sentry", "sentry",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
"tracing", "tracing",
"tracing-actix-web", "tracing-actix-web",
"tracing-appender", "tracing-appender",
@ -3122,9 +3123,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.42.0" version = "1.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",

View File

@ -37,3 +37,4 @@ tracing-actix-web = "0.7"
tracing-log = "0.2" tracing-log = "0.2"
tracing-appender = "0.2" tracing-appender = "0.2"
tracing-bunyan-formatter = "0.3" tracing-bunyan-formatter = "0.3"
tokio = "1.45.0"

View File

@ -39,6 +39,9 @@ The application can be configured using environment variables or a configuration
- `SENTRY_DSN`: Sentry DSN for error tracking - `SENTRY_DSN`: Sentry DSN for error tracking
- `JWT_SECRET`: JWT secret key - `JWT_SECRET`: JWT secret key
- `JWT_EXPIRATION`: JWT expiration time in seconds - `JWT_EXPIRATION`: JWT expiration time in seconds
- `CAPTCHA_ENABLED`: Enable CAPTCHA verification for public form submissions (`true` or `false`, default: `false`)
- `CAPTCHA_SECRET_KEY`: The secret key provided by your CAPTCHA service (e.g., hCaptcha, reCAPTCHA)
- `CAPTCHA_VERIFICATION_URL`: The verification endpoint URL for your CAPTCHA service (e.g., `https://hcaptcha.com/siteverify`)
## Development ## Development
@ -144,6 +147,17 @@ tail -f logs/app.log
- Passwords are hashed using bcrypt - Passwords are hashed using bcrypt
- SQLite database is protected with proper file permissions - SQLite database is protected with proper file permissions
### Form Submission Security
The public form submission endpoint (`/api/forms/{form_id}/submissions`) includes several security measures:
- **Global Rate Limiting:** The overall number of requests to the API is limited.
- **Per-Form, Per-IP Rate Limiting:** Limits the number of submissions one IP address can make to a specific form within a time window (e.g., 5 submissions per minute). Configurable in code.
- **CAPTCHA Verification:** If enabled via environment variables (`CAPTCHA_ENABLED=true`), requires a valid CAPTCHA token (e.g., from hCaptcha, reCAPTCHA, Turnstile) to be sent in the `captcha_token` field of the submission payload. The backend verifies this token with the configured provider.
- **Payload Size Limit:** The maximum size of the submission payload is limited (e.g., 1MB) to prevent DoS attacks. Configurable in code.
- **Input Validation:** Submission data is validated against the specific form's field definitions (type, required, length, pattern, etc.).
- **Notification Throttling:** Limits the rate at which notifications (Email, Ntfy) are sent per form to prevent spamming channels (e.g., max 1 per minute). Configurable in code.
## License ## License
MIT MIT

View File

@ -67,7 +67,7 @@
<!-- Login Section --> <!-- Login Section -->
<div id="login-section" class="content-card"> <div id="login-section" class="content-card">
<h2 class="section-title">Login</h2> <h2 class="section-title">Login</h2>
<form id="login-form"> <form post="" id="login-form">
<div class="form-group"> <div class="form-group">
<label for="username">Username:</label> <label for="username">Username:</label>
<input type="text" id="username" required /> <input type="text" id="username" required />

4594
repomix-output.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,14 +5,17 @@ use actix_web::{
dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest, dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest,
HttpRequest, HttpRequest,
}; };
use chrono::Utc;
use futures::future::{ready, Ready}; use futures::future::{ready, Ready};
use log; // Use the log crate use log; // Use the log crate
use rusqlite::params;
use rusqlite::Connection; use rusqlite::Connection;
use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely) use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely)
// Represents an authenticated user via token // Represents an authenticated user via token
pub struct Auth { pub struct Auth {
pub user_id: String, pub user_id: String,
pub role: String,
} }
impl FromRequest for Auth { impl FromRequest for Auth {
@ -62,23 +65,30 @@ impl FromRequest for Auth {
} }
}; };
// Validate the token against the database (now includes expiration check) // Get user_id and role from token
match super::db::validate_token(&conn_guard, token) { let user_result = conn_guard
// Token is valid and not expired, return Ok with Auth struct .query_row(
Ok(Some(user_id)) => { "SELECT u.id, u.role FROM users u WHERE u.token = ?1 AND u.token_expires_at > ?2",
log::debug!("Token validated successfully for user_id: {}", user_id); params![token, Utc::now().to_rfc3339()],
ready(Ok(Auth { user_id })) |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional();
match user_result {
Ok(Some((user_id, role))) => {
log::debug!(
"Token validated successfully for user_id: {} with role: {}",
user_id,
role
);
ready(Ok(Auth { user_id, role }))
} }
// Token is invalid, not found, or expired
Ok(None) => { Ok(None) => {
log::warn!("Invalid or expired token received"); // Avoid logging token log::warn!("Invalid or expired token received");
ready(Err(ErrorUnauthorized("Invalid or expired token"))) ready(Err(ErrorUnauthorized("Invalid or expired token")))
} }
// Database error during token validation
Err(e) => { Err(e) => {
log::error!("Database error during token validation: {:?}", 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"))) ready(Err(ErrorUnauthorized("Token validation failed")))
} }
} }
@ -99,3 +109,11 @@ impl FromRequest for Auth {
} }
} }
} }
// Helper function to check if a user has admin role
pub fn require_admin(auth: &Auth) -> Result<(), ActixWebError> {
if auth.role != "admin" {
return Err(ErrorUnauthorized("Admin access required"));
}
Ok(())
}

153
src/db.rs
View File

@ -24,8 +24,10 @@ pub fn init_db(database_url: &str) -> AnyhowResult<Connection> {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, -- Stores bcrypt hashed password password TEXT NOT NULL, -- Stores bcrypt hashed password
role TEXT NOT NULL DEFAULT 'user', -- 'admin' or 'user'
token TEXT UNIQUE, -- Stores the current session token (UUID) token TEXT UNIQUE, -- Stores the current session token (UUID)
token_expires_at DATETIME -- Timestamp when the token expires token_expires_at DATETIME, -- Timestamp when the token expires
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)", )",
[], [],
) )
@ -37,9 +39,11 @@ pub fn init_db(database_url: &str) -> AnyhowResult<Connection> {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
fields TEXT NOT NULL, -- Stores JSON definition of form fields fields TEXT NOT NULL, -- Stores JSON definition of form fields
owner_id TEXT NOT NULL, -- Reference to the user who created the form
notify_email TEXT, -- Optional email address for notifications notify_email TEXT, -- Optional email address for notifications
notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
)", )",
[], [],
) )
@ -103,8 +107,13 @@ fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> {
// Check password complexity? (Optional enhancement) // Check password complexity? (Optional enhancement)
add_user_if_not_exists(conn, &initial_admin_username, &initial_admin_password) add_user_if_not_exists(
.context("Failed during initial admin user setup")?; conn,
&initial_admin_username,
&initial_admin_password,
Some("admin"),
)
.context("Failed during initial admin user setup")?;
Ok(()) Ok(())
} }
@ -113,6 +122,7 @@ pub fn add_user_if_not_exists(
conn: &Connection, conn: &Connection,
username: &str, username: &str,
password: &str, password: &str,
role: Option<&str>, // Optional role parameter
) -> AnyhowResult<bool> { ) -> AnyhowResult<bool> {
// Check if user already exists // Check if user already exists
let user_exists: bool = conn let user_exists: bool = conn
@ -142,11 +152,19 @@ pub fn add_user_if_not_exists(
); );
let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?; let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?;
// Insert the new user (token and expiry are initially NULL) // Use provided role or default to "user"
log::info!("Creating new user '{}' with ID: {}", username, user_id); let role = role.unwrap_or("user");
// Insert the new user
log::info!(
"Creating new user '{}' with ID: {} and role: {}",
username,
user_id,
role
);
conn.execute( conn.execute(
"INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)", "INSERT INTO users (id, username, password, role) VALUES (?1, ?2, ?3, ?4)",
params![user_id, username, hashed_password], params![user_id, username, hashed_password, role],
) )
.context(format!("Failed to insert user '{}'", username))?; .context(format!("Failed to insert user '{}'", username))?;
@ -268,7 +286,7 @@ pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> Anyh
// Fetch a specific form definition by its ID // Fetch a specific form definition by its ID
pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Option<models::Form>> { pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Option<models::Form>> {
let mut stmt = conn let mut stmt = conn
.prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1") .prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1")
.context("Failed to prepare query for fetching form")?; .context("Failed to prepare query for fetching form")?;
let result = stmt let result = stmt
@ -276,9 +294,10 @@ pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Opt
let id: String = row.get(0)?; let id: String = row.get(0)?;
let name: String = row.get(1)?; let name: String = row.get(1)?;
let fields_str: String = row.get(2)?; let fields_str: String = row.get(2)?;
let notify_email: Option<String> = row.get(3)?; let owner_id: String = row.get(3)?;
let notify_ntfy_topic: Option<String> = row.get(4)?; // Get the new field let notify_email: Option<String> = row.get(4)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(5)?; let notify_ntfy_topic: Option<String> = row.get(5)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(6)?;
// Parse the fields JSON string // Parse the fields JSON string
let fields = serde_json::from_str(&fields_str).map_err(|e| { let fields = serde_json::from_str(&fields_str).map_err(|e| {
@ -293,8 +312,9 @@ pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Opt
id: Some(id), id: Some(id),
name, name,
fields, fields,
owner_id,
notify_email, notify_email,
notify_ntfy_topic, // Include the new field notify_ntfy_topic,
created_at, created_at,
}) })
}) })
@ -314,19 +334,21 @@ impl models::Form {
let fields_json = serde_json::to_string(&self.fields)?; let fields_json = serde_json::to_string(&self.fields)?;
conn.execute( conn.execute(
"INSERT INTO forms (id, name, fields, notify_email, notify_ntfy_topic, created_at) "INSERT INTO forms (id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
fields = excluded.fields, fields = excluded.fields,
owner_id = excluded.owner_id,
notify_email = excluded.notify_email, notify_email = excluded.notify_email,
notify_ntfy_topic = excluded.notify_ntfy_topic", // Update the new field on conflict notify_ntfy_topic = excluded.notify_ntfy_topic",
params![ params![
id, id,
self.name, self.name,
fields_json, fields_json,
self.owner_id,
self.notify_email, self.notify_email,
self.notify_ntfy_topic, // Add the new field to params self.notify_ntfy_topic,
self.created_at self.created_at
], ],
)?; )?;
@ -336,7 +358,6 @@ impl models::Form {
pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult<Self> { pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult<Self> {
get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id)) get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id))
// Added ID to error
} }
} }
@ -354,3 +375,99 @@ impl models::Submission {
Ok(()) Ok(())
} }
} }
// Get user by ID
pub fn get_user_by_id(conn: &Connection, user_id: &str) -> AnyhowResult<Option<models::User>> {
let mut stmt =
conn.prepare("SELECT id, username, role, created_at FROM users WHERE id = ?1")?;
let result = stmt
.query_row(params![user_id], |row| {
Ok(models::User {
id: row.get(0)?,
username: row.get(1)?,
password: None, // Never return password
role: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
Ok(result)
}
// Get user by username
pub fn get_user_by_username(
conn: &Connection,
username: &str,
) -> AnyhowResult<Option<models::User>> {
let mut stmt =
conn.prepare("SELECT id, username, role, created_at FROM users WHERE username = ?1")?;
let result = stmt
.query_row(params![username], |row| {
Ok(models::User {
id: row.get(0)?,
username: row.get(1)?,
password: None, // Never return password
role: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
Ok(result)
}
// List all users (for admin use)
pub fn list_users(conn: &Connection) -> AnyhowResult<Vec<models::User>> {
let mut stmt = conn.prepare("SELECT id, username, role, created_at FROM users")?;
let users_iter = stmt.query_map([], |row| {
Ok(models::User {
id: row.get(0)?,
username: row.get(1)?,
password: None, // Never return password
role: row.get(2)?,
created_at: row.get(3)?,
})
})?;
let mut users = Vec::new();
for user_result in users_iter {
users.push(user_result?);
}
Ok(users)
}
// Update user
pub fn update_user(
conn: &Connection,
user_id: &str,
update: &models::UserUpdate,
) -> AnyhowResult<()> {
if let Some(username) = &update.username {
conn.execute(
"UPDATE users SET username = ?1 WHERE id = ?2",
params![username, user_id],
)?;
}
if let Some(password) = &update.password {
let hashed_password = hash(password, DEFAULT_COST)?;
conn.execute(
"UPDATE users SET password = ?1 WHERE id = ?2",
params![hashed_password, user_id],
)?;
}
Ok(())
}
// Delete user
pub fn delete_user(conn: &Connection, user_id: &str) -> AnyhowResult<bool> {
let rows_affected = conn.execute("DELETE FROM users WHERE id = ?1", params![user_id])?;
Ok(rows_affected > 0)
}

View File

@ -1,5 +1,7 @@
use crate::auth::Auth; use crate::auth::Auth;
use crate::models::{Form, LoginCredentials, LoginResponse, Submission}; use crate::models::{
Form, LoginCredentials, LoginResponse, Submission, User, UserRegistration, UserUpdate,
};
use crate::AppState; use crate::AppState;
use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult}; use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
use chrono; // Only import the module since we use it qualified use chrono; // Only import the module since we use it qualified
@ -11,6 +13,23 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use uuid::Uuid; use uuid::Uuid;
// Added imports for CAPTCHA verification
use actix_web::HttpRequest;
use reqwest;
use serde::Deserialize;
// Added for throttling
use std::time::{Duration, Instant};
// --- Struct for CAPTCHA Verification Response ---
#[derive(Deserialize, Debug)]
struct CaptchaVerificationResponse {
success: bool,
// Providers might include other fields like challenge_ts, hostname, error-codes
#[serde(rename = "error-codes")]
error_codes: Option<Vec<String>>,
}
// --- Helper Function for Validation --- // --- Helper Function for Validation ---
/// Validates submission data against the form field definitions with enhanced checks. /// Validates submission data against the form field definitions with enhanced checks.
@ -354,12 +373,167 @@ pub async fn logout(
// POST /forms/{form_id}/submissions // POST /forms/{form_id}/submissions
pub async fn submit_form( pub async fn submit_form(
req: HttpRequest, // Add HttpRequest to access connection info
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
path: web::Path<String>, // Extracts form_id from path path: web::Path<String>, // Extracts form_id from path
submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload
) -> ActixResult<impl Responder> { ) -> ActixResult<impl Responder> {
let form_id = path.into_inner(); let form_id = path.into_inner();
let conn = app_state.db.lock().map_err(|e| { // Use .get_ref() to borrow AppState without consuming web::Data
let app_state_ref = app_state.get_ref();
let captcha_config = &app_state_ref.captcha_config;
// --- Per-Form Per-IP Rate Limiting ---
const RATE_LIMIT_DURATION: Duration = Duration::from_secs(60); // 1 minute window
const RATE_LIMIT_MAX_ATTEMPTS: u32 = 5; // Max 5 attempts per window
let client_ip_opt = req
.connection_info()
.realip_remote_addr()
.map(|s| s.to_string());
if let Some(client_ip) = client_ip_opt {
let mut attempts_map = app_state_ref.form_submission_attempts.lock().map_err(|e| {
log::error!("Failed to acquire rate limit lock: {}", e);
actix_web::error::ErrorInternalServerError("Internal error (rate limit state)")
})?;
let now = Instant::now();
let form_attempts = attempts_map.entry(form_id.clone()).or_default();
let (last_attempt, count) = form_attempts.entry(client_ip.clone()).or_insert((now, 0));
if now.duration_since(*last_attempt) > RATE_LIMIT_DURATION {
// Reset count if window expired
*last_attempt = now;
*count = 1;
} else {
// Increment count within the window
*count += 1;
}
log::debug!(
"Rate limit check for form '{}', IP '{}': attempt count = {}, last attempt = {:?}",
form_id,
client_ip,
*count,
last_attempt
);
if *count > RATE_LIMIT_MAX_ATTEMPTS {
log::warn!(
"Rate limit exceeded for form '{}', IP '{}'. Count: {}. Blocking request.",
form_id,
client_ip,
*count
);
// Consider clearing the entry after a longer block duration if needed
return Ok(HttpResponse::TooManyRequests().json(json!({
"error": "rate_limit_exceeded",
"message": "Too many submission attempts. Please try again later."
})));
}
} else {
// Cannot rate limit if IP address is unknown
log::warn!("Could not determine client IP for rate limiting.");
}
// --- End Rate Limiting ---
let payload_value = submission_payload.into_inner(); // Get the owned JsonValue
// --- CAPTCHA Verification ---
if captcha_config.enabled {
let captcha_token = payload_value.get("captcha_token").and_then(|v| v.as_str());
match captcha_token {
Some(token) if !token.is_empty() => {
// Get client IP address
let client_ip = req
.connection_info()
.realip_remote_addr()
.map(|s| s.to_string());
// Note: Ensure Actix is configured correctly behind a proxy if needed
// using .forwarded_for() or similar mechanisms if realip_remote_addr() isn't sufficient.
log::debug!(
"Verifying CAPTCHA token for IP: {:?}",
client_ip.as_deref().unwrap_or("Unknown")
);
let mut params = HashMap::new();
params.insert("secret", captcha_config.secret_key.as_str());
params.insert("response", token);
if let Some(ip) = client_ip.as_deref() {
params.insert("remoteip", ip);
}
// Consider creating the client once and storing it in AppState for reuse
let client = reqwest::Client::new();
let res = client
.post(&captcha_config.verification_url)
.form(&params)
.send()
.await;
match res {
Ok(response) => {
if response.status().is_success() {
match response.json::<CaptchaVerificationResponse>().await {
Ok(verification_response) => {
if verification_response.success {
log::info!("CAPTCHA verification successful.");
} else {
log::warn!(
"CAPTCHA verification failed: {:?}",
verification_response.error_codes
);
return Ok(HttpResponse::BadRequest().json(json!({
"error": "captcha_verification_failed",
"message": "Invalid CAPTCHA token."
})));
}
}
Err(e) => {
log::error!(
"Failed to parse CAPTCHA verification response: {}",
e
);
return Ok(HttpResponse::InternalServerError().json(json!({
"error": "captcha_provider_error",
"message": "Failed to process CAPTCHA provider response."
})));
}
}
} else {
log::error!(
"CAPTCHA provider request failed with status: {}",
response.status()
);
return Ok(HttpResponse::InternalServerError().json(json!({
"error": "captcha_provider_error",
"message": "Could not reach CAPTCHA provider."
})));
}
}
Err(e) => {
log::error!("Failed to send CAPTCHA verification request: {}", e);
return Ok(HttpResponse::InternalServerError().json(json!({
"error": "captcha_provider_error",
"message": "Failed to send request to CAPTCHA provider."
})));
}
}
}
_ => {
log::warn!("CAPTCHA enabled, but no valid token provided in submission.");
return Ok(HttpResponse::BadRequest().json(json!({ "error": "captcha_token_missing", "message": "CAPTCHA token is required."})));
}
}
}
// --- End CAPTCHA Verification ---
// Lock DB connection AFTER CAPTCHA check
// Use app_state_ref here as well
let conn = app_state_ref.db.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e); log::error!("Failed to acquire database lock: {}", e);
actix_web::error::ErrorInternalServerError("Database error") actix_web::error::ErrorInternalServerError("Database error")
})?; })?;
@ -367,9 +541,9 @@ pub async fn submit_form(
// Get form definition // Get form definition
let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?; let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?;
// Validate submission against form definition // Validate submission against form definition (using the owned payload_value)
if let Err(validation_errors) = if let Err(validation_errors) =
validate_submission_against_definition(&submission_payload, &form.fields) validate_submission_against_definition(&payload_value, &form.fields)
{ {
return Ok(HttpResponse::BadRequest().json(validation_errors)); return Ok(HttpResponse::BadRequest().json(validation_errors));
} }
@ -378,7 +552,7 @@ pub async fn submit_form(
let submission = Submission { let submission = Submission {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
form_id: form_id.clone(), form_id: form_id.clone(),
data: submission_payload.into_inner(), data: payload_value, // Store the full validated payload (including captcha_token if sent)
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
}; };
@ -388,42 +562,96 @@ pub async fn submit_form(
actix_web::error::ErrorInternalServerError("Failed to save submission") actix_web::error::ErrorInternalServerError("Failed to save submission")
})?; })?;
// Send notifications if configured // --- Notification Throttling & Sending ---
if let Some(notify_email) = form.notify_email { const NOTIFICATION_THROTTLE_DURATION: Duration = Duration::from_secs(60);
let email_subject = format!("New submission for form: {}", form.name); let mut should_send_notification = true; // Assume we should send initially
let email_body = format!(
"A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}",
form.name,
submission.id,
submission.created_at,
serde_json::to_string_pretty(&submission.data).unwrap_or_default()
);
if let Err(e) = app_state // Check if notifications are configured for this form at all
.notification_service let notifications_configured = form.notify_email.is_some()
.send_email(&notify_email, &email_subject, &email_body) || form
.await .notify_ntfy_topic
{ .as_ref()
log::warn!("Failed to send email notification: {}", e); .map_or(false, |s| !s.is_empty());
if notifications_configured {
let mut last_times = app_state_ref.last_notification_times.lock().map_err(|e| {
log::error!("Failed to acquire notification throttle lock: {}", e);
actix_web::error::ErrorInternalServerError("Internal error (notification state)")
})?;
let now = Instant::now();
if let Some(last_time) = last_times.get(&form_id) {
if now.duration_since(*last_time) < NOTIFICATION_THROTTLE_DURATION {
log::info!(
"Notification throttled for form_id: {}. Last sent {:?} ago.",
form_id,
now.duration_since(*last_time)
);
should_send_notification = false;
}
} }
// Also send ntfy notification if configured (sends to the global topic) // If not throttled, update the timestamp *before* attempting to send
if should_send_notification {
log::debug!("Updating last notification time for form_id: {}", form_id);
last_times.insert(form_id.clone(), now);
}
} else {
should_send_notification = false; // Don't attempt if not configured
}
// Send notifications only if not throttled and configured
if should_send_notification {
log::info!("Attempting to send notifications for form_id: {}", form_id);
// Send Email if configured
if let Some(notify_email) = &form.notify_email {
let email_subject = format!("New submission for form: {}", form.name);
let email_body = format!(
"A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}",
form.name,
submission.id,
submission.created_at,
serde_json::to_string_pretty(&submission.data).unwrap_or_default()
);
// Use a clone of notification_service if it needs to move into async block
let notification_service_clone = app_state_ref.notification_service.clone();
let notify_email_clone = notify_email.clone();
let email_subject_clone = email_subject.clone();
let email_body_clone = email_body.clone();
// Spawn email sending as a background task so it doesn't block the response
tokio::spawn(async move {
if let Err(e) = notification_service_clone
.send_email(&notify_email_clone, &email_subject_clone, &email_body_clone)
.await
{
log::warn!(
"Failed to send email notification in background task: {}",
e
);
}
});
}
// Send ntfy if configured
if let Some(topic_flag) = &form.notify_ntfy_topic { if let Some(topic_flag) = &form.notify_ntfy_topic {
// Use field presence as a flag
if !topic_flag.is_empty() { if !topic_flag.is_empty() {
// Check if the flag string is non-empty
let ntfy_title = format!("New submission for: {}", form.name); let ntfy_title = format!("New submission for: {}", form.name);
let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id); let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id);
if let Err(e) = app_state.notification_service.send_ntfy( // Ntfy send is synchronous in the current implementation, can block
// Consider spawning if it becomes slow
if let Err(e) = app_state_ref.notification_service.send_ntfy(
&ntfy_title, &ntfy_title,
&ntfy_message, &ntfy_message,
Some(3), // Medium priority Some(3), // Medium priority
) { ) {
log::warn!("Failed to send ntfy notification (global topic): {}", e); log::warn!("Failed to send ntfy notification: {}", e);
// Don't return error to client, just log
} }
} }
} }
} } // End if should_send_notification
// --- End Notification Throttling & Sending ---
Ok(HttpResponse::Created().json(json!({ Ok(HttpResponse::Created().json(json!({
"message": "Submission received", "message": "Submission received",
@ -434,172 +662,189 @@ pub async fn submit_form(
// POST /forms // POST /forms
pub async fn create_form( pub async fn create_form(
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
_auth: Auth, // Authentication check via Auth extractor auth: Auth,
payload: web::Json<serde_json::Value>, form_data: web::Json<Form>,
) -> ActixResult<impl Responder> { ) -> ActixResult<impl Responder> {
let payload = payload.into_inner(); let mut form = form_data.into_inner();
form.owner_id = auth.user_id.clone(); // Set the owner_id to the authenticated user's ID
// Extract form data from payload let db_conn_arc = app_state.db.clone();
let name = payload["name"] web::block(move || {
.as_str() let conn = db_conn_arc
.ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))? .lock()
.to_string(); .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
form.save(&conn)
let fields = payload["fields"].clone(); })
if !fields.is_array() { .await
return Err(actix_web::error::ErrorBadRequest( .map_err(|e| {
"'fields' must be a JSON array", log::error!("web::block error while creating form: {:?}", e);
)); actix_web::error::ErrorInternalServerError("Failed to create form")
} })?
.map_err(anyhow_to_actix_error)?;
let notify_email = payload["notify_email"].as_str().map(|s| s.to_string());
let notify_ntfy_topic = payload["notify_ntfy_topic"].as_str().map(|s| s.to_string());
// Create new form
let form = Form {
id: None, // Will be generated during save
name,
fields,
notify_email,
notify_ntfy_topic,
created_at: chrono::Utc::now(),
};
// Save the form
let conn = app_state.db.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
form.save(&conn).map_err(|e| {
log::error!("Failed to save form: {}", e);
actix_web::error::ErrorInternalServerError("Failed to save form")
})?;
Ok(HttpResponse::Created().json(form)) Ok(HttpResponse::Created().json(form))
} }
// GET /forms // GET /forms
pub async fn get_forms( pub async fn get_forms(app_state: web::Data<AppState>, auth: Auth) -> ActixResult<impl Responder> {
app_state: web::Data<AppState>, let db_conn_arc = app_state.db.clone();
auth: Auth, // Requires authentication let user_id = auth.user_id.clone();
) -> ActixResult<impl Responder> { let is_admin = auth.role == "admin";
log::info!("User {} requesting list of forms", auth.user_id);
let conn = app_state.db.lock().map_err(|e| { let forms = web::block(move || {
log::error!("Failed to acquire database lock: {}", e); let conn = db_conn_arc
actix_web::error::ErrorInternalServerError("Database error") .lock()
})?; .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
let mut stmt = conn let mut stmt = if is_admin {
.prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms") // Admins can see all forms
.map_err(|e| { conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms")?
log::error!("Failed to prepare statement: {}", e); } else {
actix_web::error::ErrorInternalServerError("Database error") // Regular users can only see their own forms
})?; conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms WHERE owner_id = ?1")?
};
let forms_iter = stmt let forms_iter = if is_admin {
.query_map([], |row| { stmt.query_map([], |row| {
let id: String = row.get(0)?; let id: String = row.get(0)?;
let name: String = row.get(1)?; let name: String = row.get(1)?;
let fields_str: String = row.get(2)?; let fields_str: String = row.get(2)?;
let notify_email: Option<String> = row.get(3)?; let owner_id: String = row.get(3)?;
let notify_ntfy_topic: Option<String> = row.get(4)?; let notify_email: Option<String> = row.get(4)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(5)?; let notify_ntfy_topic: Option<String> = row.get(5)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(6)?;
// Parse the 'fields' JSON string let fields = serde_json::from_str(&fields_str).map_err(|e| {
let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| { rusqlite::Error::FromSqlConversionFailure(
log::error!( 2,
"DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.", rusqlite::types::Type::Text,
id, Box::new(e),
e )
); })?;
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
Ok(Form { Ok(Form {
id: Some(id), id: Some(id),
name, name,
fields, fields,
notify_email, owner_id,
notify_ntfy_topic, notify_email,
created_at, notify_ntfy_topic,
}) created_at,
}) })
.map_err(|e| { })?
log::error!("Failed to execute query: {}", e); } else {
actix_web::error::ErrorInternalServerError("Database error") stmt.query_map(params![user_id], |row| {
})?; let id: String = row.get(0)?;
let name: String = row.get(1)?;
let fields_str: String = row.get(2)?;
let owner_id: String = row.get(3)?;
let notify_email: Option<String> = row.get(4)?;
let notify_ntfy_topic: Option<String> = row.get(5)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(6)?;
// Collect results, filtering out rows that failed parsing let fields = serde_json::from_str(&fields_str).map_err(|e| {
let forms: Vec<Form> = forms_iter rusqlite::Error::FromSqlConversionFailure(
.filter_map(|result| match result { 2,
Ok(form) => Some(form), rusqlite::types::Type::Text,
Err(e) => { Box::new(e),
log::warn!("Skipping a form row due to a processing error: {}", e); )
None })?;
}
}) Ok(Form {
.collect(); id: Some(id),
name,
fields,
owner_id,
notify_email,
notify_ntfy_topic,
created_at,
})
})?
};
let mut forms = Vec::new();
for form_result in forms_iter {
forms.push(form_result?);
}
Ok::<_, anyhow::Error>(forms)
})
.await
.map_err(|e| {
log::error!("web::block error while fetching forms: {:?}", e);
actix_web::error::ErrorInternalServerError("Failed to fetch forms")
})?
.map_err(anyhow_to_actix_error)?;
log::debug!("Returning {} forms for user {}", forms.len(), auth.user_id);
Ok(HttpResponse::Ok().json(forms)) Ok(HttpResponse::Ok().json(forms))
} }
// GET /forms/{form_id}/submissions // GET /forms/{form_id}/submissions
pub async fn get_submissions( pub async fn get_submissions(
app_state: web::Data<AppState>, app_state: web::Data<AppState>,
auth: Auth, // Requires authentication auth: Auth,
path: web::Path<String>, // Extracts form_id from the path form_id: web::Path<String>,
) -> ActixResult<impl Responder> { ) -> ActixResult<impl Responder> {
let form_id = path.into_inner(); let db_conn_arc = app_state.db.clone();
log::info!( let form_id_str = form_id.into_inner();
"User {} requesting submissions for form_id: {}", let user_id = auth.user_id.clone();
auth.user_id, let is_admin = auth.role == "admin";
form_id
);
let conn = app_state.db.lock().map_err(|e| { // First check if the user has access to this form
log::error!("Failed to acquire database lock: {}", e); let can_access = web::block(move || {
actix_web::error::ErrorInternalServerError("Database error") let conn = db_conn_arc
})?; .lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
// Check if the form exists if is_admin {
let _form = Form::get_by_id(&conn, &form_id).map_err(|e| { // Admins can access all forms
if e.to_string().contains("not found") { return Ok(true);
actix_web::error::ErrorNotFound("Form not found")
} else {
actix_web::error::ErrorInternalServerError("Database error")
} }
})?;
// Get submissions // Check if the form belongs to the user
let mut stmt = conn let owner_id: Option<String> = conn
.prepare( .query_row(
"SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", "SELECT owner_id FROM forms WHERE id = ?1",
) params![form_id_str],
.map_err(|e| { |row| row.get(0),
log::error!("Failed to prepare statement: {}", e); )
actix_web::error::ErrorInternalServerError("Database error") .optional()?;
})?;
let submissions_iter = stmt match owner_id {
.query_map(params![form_id], |row| { Some(owner_id) => Ok(owner_id == user_id),
None => Ok(false), // Form doesn't exist
}
})
.await
.map_err(|e| {
log::error!("web::block error while checking form access: {:?}", e);
actix_web::error::ErrorInternalServerError("Failed to check form access")
})?
.map_err(anyhow_to_actix_error)?;
if !can_access {
return Err(actix_web::error::ErrorForbidden("Access denied"));
}
// Now fetch the submissions
let db_conn_arc = app_state.db.clone();
let form_id_str = form_id.into_inner();
let submissions = web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
let mut stmt = conn
.prepare("SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1")?;
let submissions_iter = stmt.query_map(params![form_id_str], |row| {
let id: String = row.get(0)?; let id: String = row.get(0)?;
let form_id: String = row.get(1)?; let form_id: String = row.get(1)?;
let data_str: String = row.get(2)?; let data_str: String = row.get(2)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(3)?; let created_at: chrono::DateTime<chrono::Utc> = row.get(3)?;
let data: serde_json::Value = serde_json::from_str(&data_str).map_err(|e| { let data = serde_json::from_str(&data_str).map_err(|e| {
log::error!(
"DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.",
id,
e
);
rusqlite::Error::FromSqlConversionFailure( rusqlite::Error::FromSqlConversionFailure(
2, 2,
rusqlite::types::Type::Text, rusqlite::types::Type::Text,
@ -613,28 +858,22 @@ pub async fn get_submissions(
data, data,
created_at, created_at,
}) })
})
.map_err(|e| {
log::error!("Failed to execute query: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?; })?;
let submissions: Vec<Submission> = submissions_iter let mut submissions = Vec::new();
.filter_map(|result| match result { for submission_result in submissions_iter {
Ok(submission) => Some(submission), submissions.push(submission_result?);
Err(e) => { }
log::warn!("Skipping a submission row due to processing error: {}", e);
None Ok::<_, anyhow::Error>(submissions)
} })
}) .await
.collect(); .map_err(|e| {
log::error!("web::block error while fetching submissions: {:?}", e);
actix_web::error::ErrorInternalServerError("Failed to fetch submissions")
})?
.map_err(anyhow_to_actix_error)?;
log::debug!(
"Returning {} submissions for form {} requested by user {}",
submissions.len(),
form_id,
auth.user_id
);
Ok(HttpResponse::Ok().json(submissions)) Ok(HttpResponse::Ok().json(submissions))
} }
@ -749,3 +988,172 @@ pub async fn health_check() -> impl Responder {
"timestamp": chrono::Utc::now().to_rfc3339() "timestamp": chrono::Utc::now().to_rfc3339()
})) }))
} }
// POST /register
pub async fn register(
app_state: web::Data<AppState>,
registration: web::Json<models::UserRegistration>,
) -> ActixResult<impl Responder> {
let db_conn_arc = app_state.db.clone();
let username = registration.username.clone();
let password = registration.password.clone();
// Register user in a blocking operation
let result = web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during registration"))?;
// Check if username already exists
if let Some(_) = crate::db::get_user_by_username(&conn, &username)? {
return Err(anyhow::anyhow!("Username already exists"));
}
// Add new user with default role "user"
crate::db::add_user_if_not_exists(&conn, &username, &password, None)
})
.await
.map_err(|e| {
log::error!("web::block error during registration: {:?}", e);
actix_web::error::ErrorInternalServerError("Registration process failed")
})?
.map_err(|e| {
if e.to_string().contains("already exists") {
actix_web::error::ErrorConflict(e.to_string())
} else {
actix_web::error::ErrorInternalServerError(e.to_string())
}
})?;
Ok(HttpResponse::Created().json(json!({
"message": "User registered successfully"
})))
}
// GET /users (admin only)
pub async fn list_users(app_state: web::Data<AppState>, auth: Auth) -> ActixResult<impl Responder> {
// Check admin role
crate::auth::require_admin(&auth)?;
let db_conn_arc = app_state.db.clone();
let users = web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
crate::db::list_users(&conn)
})
.await
.map_err(|e| {
log::error!("web::block error while listing users: {:?}", e);
actix_web::error::ErrorInternalServerError("Failed to list users")
})?
.map_err(anyhow_to_actix_error)?;
Ok(HttpResponse::Ok().json(users))
}
// GET /users/{user_id} (admin or self)
pub async fn get_user(
app_state: web::Data<AppState>,
auth: Auth,
user_id: web::Path<String>,
) -> ActixResult<impl Responder> {
// Allow if admin or if user is requesting their own data
if auth.role != "admin" && auth.user_id != user_id.as_str() {
return Err(actix_web::error::ErrorForbidden("Access denied"));
}
let db_conn_arc = app_state.db.clone();
let user_id_str = user_id.into_inner();
let user = web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
crate::db::get_user_by_id(&conn, &user_id_str)
})
.await
.map_err(|e| {
log::error!("web::block error while fetching user: {:?}", e);
actix_web::error::ErrorInternalServerError("Failed to fetch user")
})?
.map_err(anyhow_to_actix_error)?;
match user {
Some(user) => Ok(HttpResponse::Ok().json(user)),
None => Ok(HttpResponse::NotFound().json(json!({
"message": "User not found"
}))),
}
}
// PUT /users/{user_id} (admin or self)
pub async fn update_user(
app_state: web::Data<AppState>,
auth: Auth,
user_id: web::Path<String>,
update: web::Json<models::UserUpdate>,
) -> ActixResult<impl Responder> {
// Allow if admin or if user is updating their own data
if auth.role != "admin" && auth.user_id != user_id.as_str() {
return Err(actix_web::error::ErrorForbidden("Access denied"));
}
let db_conn_arc = app_state.db.clone();
let user_id_str = user_id.into_inner();
let update_data = update.into_inner();
web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
crate::db::update_user(&conn, &user_id_str, &update_data)
})
.await
.map_err(|e| {
log::error!("web::block error while updating user: {:?}", e);
actix_web::error::ErrorInternalServerError("Failed to update user")
})?
.map_err(anyhow_to_actix_error)?;
Ok(HttpResponse::Ok().json(json!({
"message": "User updated successfully"
})))
}
// DELETE /users/{user_id} (admin only)
pub async fn delete_user(
app_state: web::Data<AppState>,
auth: Auth,
user_id: web::Path<String>,
) -> ActixResult<impl Responder> {
// Only admins can delete users
crate::auth::require_admin(&auth)?;
let db_conn_arc = app_state.db.clone();
let user_id_str = user_id.into_inner();
let deleted = web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
crate::db::delete_user(&conn, &user_id_str)
})
.await
.map_err(|e| {
log::error!("web::block error while deleting user: {:?}", e);
actix_web::error::ErrorInternalServerError("Failed to delete user")
})?
.map_err(anyhow_to_actix_error)?;
if deleted {
Ok(HttpResponse::Ok().json(json!({
"message": "User deleted successfully"
})))
} else {
Ok(HttpResponse::NotFound().json(json!({
"message": "User not found"
})))
}
}

View File

@ -9,10 +9,13 @@ use std::env;
use std::io::Result as IoResult; use std::io::Result as IoResult;
use std::process; use std::process;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::{Duration, Instant};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// Added for throttling map
use std::collections::HashMap;
// Import modules // Import modules
mod auth; mod auth;
mod db; mod db;
@ -22,10 +25,54 @@ mod notifications;
use notifications::{NotificationConfig, NotificationService}; use notifications::{NotificationConfig, NotificationService};
// --- CAPTCHA Configuration ---
#[derive(Clone, Debug)]
pub struct CaptchaConfig {
pub enabled: bool,
pub secret_key: String,
pub verification_url: String, // e.g., "https://hcaptcha.com/siteverify"
}
impl CaptchaConfig {
// Function to load from environment variables
pub fn from_env() -> Result<Self, std::env::VarError> {
// Return VarError for simplicity
let enabled = std::env::var("CAPTCHA_ENABLED")
.map(|v| v.parse().unwrap_or(false))
.unwrap_or(false); // Default to false if not set or parse error
// Use Ok variant of Result for keys, default to empty if not found
let secret_key = std::env::var("CAPTCHA_SECRET_KEY").unwrap_or_default();
let verification_url = std::env::var("CAPTCHA_VERIFICATION_URL").unwrap_or_default();
// Basic validation: if enabled, secret key and URL must be present
if enabled && (secret_key.is_empty() || verification_url.is_empty()) {
warn!("CAPTCHA_ENABLED is true, but CAPTCHA_SECRET_KEY or CAPTCHA_VERIFICATION_URL is missing. CAPTCHA will be effectively disabled.");
Ok(Self {
enabled: false, // Force disable if config is incomplete
secret_key,
verification_url,
})
} else {
Ok(Self {
enabled,
secret_key,
verification_url,
})
}
}
}
// --- End CAPTCHA Configuration ---
// Application state that will be shared across all routes // Application state that will be shared across all routes
pub struct AppState { pub struct AppState {
db: Arc<Mutex<rusqlite::Connection>>, db: Arc<Mutex<rusqlite::Connection>>,
notification_service: Arc<NotificationService>, notification_service: Arc<NotificationService>,
captcha_config: CaptchaConfig,
// Map form_id to the Instant of the last notification attempt for that form
last_notification_times: Arc<Mutex<HashMap<String, Instant>>>,
// Map form_id -> ip_address -> (last_attempt_time, count) for rate limiting
form_submission_attempts: Arc<Mutex<HashMap<String, HashMap<String, (Instant, u32)>>>>,
} }
#[actix_web::main] #[actix_web::main]
@ -143,16 +190,38 @@ async fn main() -> IoResult<()> {
}); });
let notification_service = Arc::new(NotificationService::new(notification_config)); let notification_service = Arc::new(NotificationService::new(notification_config));
// Create AppState with both database and notification service // Load CAPTCHA Configuration
let captcha_config = CaptchaConfig::from_env().unwrap_or_else(|e| {
warn!(
"Failed to load CAPTCHA configuration: {}. CAPTCHA will be disabled.",
e
);
// Ensure default is truly disabled
CaptchaConfig {
enabled: false,
secret_key: String::new(),
verification_url: String::new(),
}
});
if captcha_config.enabled {
info!("CAPTCHA verification is ENABLED.");
} else {
info!("CAPTCHA verification is DISABLED (or required env vars missing).");
}
// Create AppState with all services
let app_state = web::Data::new(AppState { let app_state = web::Data::new(AppState {
db: Arc::new(Mutex::new(db_connection)), db: Arc::new(Mutex::new(db_connection)),
notification_service: notification_service.clone(), notification_service: notification_service.clone(),
captcha_config: captcha_config.clone(),
last_notification_times: Arc::new(Mutex::new(HashMap::new())),
form_submission_attempts: Arc::new(Mutex::new(HashMap::new())), // Initialize rate limit map
}); });
info!("Starting server at http://{}", bind_address); info!("Starting server at http://{}", bind_address);
HttpServer::new(move || { HttpServer::new(move || {
let app_state = app_state.clone(); let app_state = app_state.clone(); // This now includes captcha_config
let allowed_origins = allowed_origins_list.clone(); let allowed_origins = allowed_origins_list.clone();
let rate_limiter = RateLimiter::new(limiter_data.clone()); let rate_limiter = RateLimiter::new(limiter_data.clone());
@ -190,18 +259,23 @@ async fn main() -> IoResult<()> {
.max_age(3600) .max_age(3600)
}; };
// Configure JSON payload limits (e.g., 1MB)
let json_config = web::JsonConfig::default().limit(1024 * 1024);
App::new() App::new()
.wrap(cors) .wrap(cors)
.wrap(Logger::default()) .wrap(Logger::default())
.wrap(tracing_actix_web::TracingLogger::default()) .wrap(tracing_actix_web::TracingLogger::default())
.wrap(rate_limiter) .wrap(rate_limiter)
.app_data(app_state) .app_data(app_state) // Share app state (db, notifications, captcha)
.app_data(json_config) // Add JSON payload configuration
.service( .service(
web::scope("/api") web::scope("/api")
// Health check endpoint // Health check endpoint
.route("/health", web::get().to(handlers::health_check)) .route("/health", web::get().to(handlers::health_check))
// Public routes // Public routes
.route("/login", web::post().to(handlers::login)) .route("/login", web::post().to(handlers::login))
.route("/register", web::post().to(handlers::register))
.route( .route(
"/forms/{form_id}/submissions", "/forms/{form_id}/submissions",
web::post().to(handlers::submit_form), web::post().to(handlers::submit_form),
@ -221,7 +295,12 @@ async fn main() -> IoResult<()> {
.route( .route(
"/forms/{form_id}/notifications", "/forms/{form_id}/notifications",
web::put().to(handlers::update_notification_settings), web::put().to(handlers::update_notification_settings),
), )
// User management routes
.route("/users", web::get().to(handlers::list_users))
.route("/users/{user_id}", web::get().to(handlers::get_user))
.route("/users/{user_id}", web::put().to(handlers::update_user))
.route("/users/{user_id}", web::delete().to(handlers::delete_user)),
) )
.service( .service(
fs::Files::new("/", "./frontend/") fs::Files::new("/", "./frontend/")

View File

@ -29,6 +29,7 @@ pub struct Form {
/// } /// }
/// ``` /// ```
pub fields: serde_json::Value, pub fields: serde_json::Value,
pub owner_id: String,
pub notify_email: Option<String>, pub notify_email: Option<String>,
pub notify_ntfy_topic: Option<String>, pub notify_ntfy_topic: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
@ -74,3 +75,27 @@ pub struct NotificationSettingsPayload {
pub notify_email: Option<String>, pub notify_email: Option<String>,
pub notify_ntfy_topic: Option<String>, pub notify_ntfy_topic: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub username: String,
#[serde(skip_serializing)] // Never send password in responses
pub password: Option<String>,
pub role: String,
pub created_at: DateTime<Utc>,
}
// Used for user registration
#[derive(Debug, Serialize, Deserialize)]
pub struct UserRegistration {
pub username: String,
pub password: String,
}
// Used for user profile updates
#[derive(Debug, Serialize, Deserialize)]
pub struct UserUpdate {
pub username: Option<String>,
pub password: Option<String>,
}