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

View File

@ -36,4 +36,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-actix-web = "0.7"
tracing-log = "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
- `JWT_SECRET`: JWT secret key
- `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
@ -144,6 +147,17 @@ tail -f logs/app.log
- Passwords are hashed using bcrypt
- 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
MIT

View File

@ -67,7 +67,7 @@
<!-- Login Section -->
<div id="login-section" class="content-card">
<h2 class="section-title">Login</h2>
<form id="login-form">
<form post="" id="login-form">
<div class="form-group">
<label for="username">Username:</label>
<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,
HttpRequest,
};
use chrono::Utc;
use futures::future::{ready, Ready};
use log; // Use the log crate
use rusqlite::params;
use rusqlite::Connection;
use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely)
// Represents an authenticated user via token
pub struct Auth {
pub user_id: String,
pub role: String,
}
impl FromRequest for Auth {
@ -62,23 +65,30 @@ impl FromRequest for Auth {
}
};
// 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 }))
// Get user_id and role from token
let user_result = conn_guard
.query_row(
"SELECT u.id, u.role FROM users u WHERE u.token = ?1 AND u.token_expires_at > ?2",
params![token, Utc::now().to_rfc3339()],
|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) => {
log::warn!("Invalid or expired token received"); // Avoid logging token
log::warn!("Invalid or expired token received");
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")))
}
}
@ -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,
username TEXT NOT NULL UNIQUE,
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_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,
name TEXT NOT NULL,
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_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)
add_user_if_not_exists(conn, &initial_admin_username, &initial_admin_password)
.context("Failed during initial admin user setup")?;
add_user_if_not_exists(
conn,
&initial_admin_username,
&initial_admin_password,
Some("admin"),
)
.context("Failed during initial admin user setup")?;
Ok(())
}
@ -113,6 +122,7 @@ pub fn add_user_if_not_exists(
conn: &Connection,
username: &str,
password: &str,
role: Option<&str>, // Optional role parameter
) -> AnyhowResult<bool> {
// Check if user already exists
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")?;
// Insert the new user (token and expiry are initially NULL)
log::info!("Creating new user '{}' with ID: {}", username, user_id);
// Use provided role or default to "user"
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(
"INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)",
params![user_id, username, hashed_password],
"INSERT INTO users (id, username, password, role) VALUES (?1, ?2, ?3, ?4)",
params![user_id, username, hashed_password, role],
)
.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
pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Option<models::Form>> {
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")?;
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 name: String = row.get(1)?;
let fields_str: String = row.get(2)?;
let notify_email: Option<String> = row.get(3)?;
let notify_ntfy_topic: Option<String> = row.get(4)?; // Get the new field
let created_at: chrono::DateTime<chrono::Utc> = row.get(5)?;
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)?;
// Parse the fields JSON string
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),
name,
fields,
owner_id,
notify_email,
notify_ntfy_topic, // Include the new field
notify_ntfy_topic,
created_at,
})
})
@ -314,19 +334,21 @@ impl models::Form {
let fields_json = serde_json::to_string(&self.fields)?;
conn.execute(
"INSERT INTO forms (id, name, fields, notify_email, notify_ntfy_topic, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
"INSERT INTO forms (id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
fields = excluded.fields,
owner_id = excluded.owner_id,
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![
id,
self.name,
fields_json,
self.owner_id,
self.notify_email,
self.notify_ntfy_topic, // Add the new field to params
self.notify_ntfy_topic,
self.created_at
],
)?;
@ -336,7 +358,6 @@ impl models::Form {
pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult<Self> {
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(())
}
}
// 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::models::{Form, LoginCredentials, LoginResponse, Submission};
use crate::models::{
Form, LoginCredentials, LoginResponse, Submission, User, UserRegistration, UserUpdate,
};
use crate::AppState;
use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
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 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 ---
/// Validates submission data against the form field definitions with enhanced checks.
@ -354,12 +373,167 @@ pub async fn logout(
// POST /forms/{form_id}/submissions
pub async fn submit_form(
req: HttpRequest, // Add HttpRequest to access connection info
app_state: web::Data<AppState>,
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 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);
actix_web::error::ErrorInternalServerError("Database error")
})?;
@ -367,9 +541,9 @@ pub async fn submit_form(
// Get form definition
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) =
validate_submission_against_definition(&submission_payload, &form.fields)
validate_submission_against_definition(&payload_value, &form.fields)
{
return Ok(HttpResponse::BadRequest().json(validation_errors));
}
@ -378,7 +552,7 @@ pub async fn submit_form(
let submission = Submission {
id: Uuid::new_v4().to_string(),
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(),
};
@ -388,42 +562,96 @@ pub async fn submit_form(
actix_web::error::ErrorInternalServerError("Failed to save submission")
})?;
// Send notifications 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()
);
// --- Notification Throttling & Sending ---
const NOTIFICATION_THROTTLE_DURATION: Duration = Duration::from_secs(60);
let mut should_send_notification = true; // Assume we should send initially
if let Err(e) = app_state
.notification_service
.send_email(&notify_email, &email_subject, &email_body)
.await
{
log::warn!("Failed to send email notification: {}", e);
// Check if notifications are configured for this form at all
let notifications_configured = form.notify_email.is_some()
|| form
.notify_ntfy_topic
.as_ref()
.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 {
// Use field presence as a flag
if !topic_flag.is_empty() {
// Check if the flag string is non-empty
let ntfy_title = format!("New submission for: {}", form.name);
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_message,
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!({
"message": "Submission received",
@ -434,172 +662,189 @@ pub async fn submit_form(
// POST /forms
pub async fn create_form(
app_state: web::Data<AppState>,
_auth: Auth, // Authentication check via Auth extractor
payload: web::Json<serde_json::Value>,
auth: Auth,
form_data: web::Json<Form>,
) -> 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 name = payload["name"]
.as_str()
.ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))?
.to_string();
let fields = payload["fields"].clone();
if !fields.is_array() {
return Err(actix_web::error::ErrorBadRequest(
"'fields' must be a JSON array",
));
}
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")
})?;
let db_conn_arc = app_state.db.clone();
web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
form.save(&conn)
})
.await
.map_err(|e| {
log::error!("web::block error while creating form: {:?}", e);
actix_web::error::ErrorInternalServerError("Failed to create form")
})?
.map_err(anyhow_to_actix_error)?;
Ok(HttpResponse::Created().json(form))
}
// GET /forms
pub async fn get_forms(
app_state: web::Data<AppState>,
auth: Auth, // Requires authentication
) -> ActixResult<impl Responder> {
log::info!("User {} requesting list of forms", auth.user_id);
pub async fn get_forms(app_state: web::Data<AppState>, auth: Auth) -> ActixResult<impl Responder> {
let db_conn_arc = app_state.db.clone();
let user_id = auth.user_id.clone();
let is_admin = auth.role == "admin";
let conn = app_state.db.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
let forms = web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
let mut stmt = conn
.prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms")
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
let mut stmt = if is_admin {
// Admins can see all forms
conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms")?
} else {
// 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
.query_map([], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let fields_str: String = row.get(2)?;
let notify_email: Option<String> = row.get(3)?;
let notify_ntfy_topic: Option<String> = row.get(4)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(5)?;
let forms_iter = if is_admin {
stmt.query_map([], |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)?;
// Parse the 'fields' JSON string
let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| {
log::error!(
"DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.",
id,
e
);
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
let fields = serde_json::from_str(&fields_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
Ok(Form {
id: Some(id),
name,
fields,
notify_email,
notify_ntfy_topic,
created_at,
})
})
.map_err(|e| {
log::error!("Failed to execute query: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
Ok(Form {
id: Some(id),
name,
fields,
owner_id,
notify_email,
notify_ntfy_topic,
created_at,
})
})?
} else {
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 forms: Vec<Form> = forms_iter
.filter_map(|result| match result {
Ok(form) => Some(form),
Err(e) => {
log::warn!("Skipping a form row due to a processing error: {}", e);
None
}
})
.collect();
let fields = serde_json::from_str(&fields_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
Ok(Form {
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))
}
// GET /forms/{form_id}/submissions
pub async fn get_submissions(
app_state: web::Data<AppState>,
auth: Auth, // Requires authentication
path: web::Path<String>, // Extracts form_id from the path
auth: Auth,
form_id: web::Path<String>,
) -> 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_arc = app_state.db.clone();
let form_id_str = form_id.into_inner();
let user_id = auth.user_id.clone();
let is_admin = auth.role == "admin";
let conn = app_state.db.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
// First check if the user has access to this form
let can_access = web::block(move || {
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
// Check if the form exists
let _form = Form::get_by_id(&conn, &form_id).map_err(|e| {
if e.to_string().contains("not found") {
actix_web::error::ErrorNotFound("Form not found")
} else {
actix_web::error::ErrorInternalServerError("Database error")
if is_admin {
// Admins can access all forms
return Ok(true);
}
})?;
// Get submissions
let mut stmt = conn
.prepare(
"SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC",
)
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
// Check if the form belongs to the user
let owner_id: Option<String> = conn
.query_row(
"SELECT owner_id FROM forms WHERE id = ?1",
params![form_id_str],
|row| row.get(0),
)
.optional()?;
let submissions_iter = stmt
.query_map(params![form_id], |row| {
match owner_id {
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 form_id: String = row.get(1)?;
let data_str: String = row.get(2)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(3)?;
let data: serde_json::Value = 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
);
let data = serde_json::from_str(&data_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
@ -613,28 +858,22 @@ pub async fn get_submissions(
data,
created_at,
})
})
.map_err(|e| {
log::error!("Failed to execute query: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
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
}
})
.collect();
let mut submissions = Vec::new();
for submission_result in submissions_iter {
submissions.push(submission_result?);
}
Ok::<_, anyhow::Error>(submissions)
})
.await
.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))
}
@ -749,3 +988,172 @@ pub async fn health_check() -> impl Responder {
"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::process;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// Added for throttling map
use std::collections::HashMap;
// Import modules
mod auth;
mod db;
@ -22,10 +25,54 @@ mod notifications;
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
pub struct AppState {
db: Arc<Mutex<rusqlite::Connection>>,
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]
@ -143,16 +190,38 @@ async fn main() -> IoResult<()> {
});
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 {
db: Arc::new(Mutex::new(db_connection)),
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);
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 rate_limiter = RateLimiter::new(limiter_data.clone());
@ -190,18 +259,23 @@ async fn main() -> IoResult<()> {
.max_age(3600)
};
// Configure JSON payload limits (e.g., 1MB)
let json_config = web::JsonConfig::default().limit(1024 * 1024);
App::new()
.wrap(cors)
.wrap(Logger::default())
.wrap(tracing_actix_web::TracingLogger::default())
.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(
web::scope("/api")
// Health check endpoint
.route("/health", web::get().to(handlers::health_check))
// Public routes
.route("/login", web::post().to(handlers::login))
.route("/register", web::post().to(handlers::register))
.route(
"/forms/{form_id}/submissions",
web::post().to(handlers::submit_form),
@ -221,7 +295,12 @@ async fn main() -> IoResult<()> {
.route(
"/forms/{form_id}/notifications",
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(
fs::Files::new("/", "./frontend/")

View File

@ -29,6 +29,7 @@ pub struct Form {
/// }
/// ```
pub fields: serde_json::Value,
pub owner_id: String,
pub notify_email: Option<String>,
pub notify_ntfy_topic: Option<String>,
pub created_at: DateTime<Utc>,
@ -74,3 +75,27 @@ pub struct NotificationSettingsPayload {
pub notify_email: 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>,
}