0605
This commit is contained in:
parent
fe5184e18c
commit
1b012b3923
5
Cargo.lock
generated
5
Cargo.lock
generated
@ -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",
|
||||
|
@ -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"
|
||||
|
14
README.md
14
README.md
@ -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
|
||||
|
@ -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
4594
repomix-output.xml
Normal file
File diff suppressed because it is too large
Load Diff
40
src/auth.rs
40
src/auth.rs
@ -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
153
src/db.rs
@ -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)
|
||||
}
|
||||
|
770
src/handlers.rs
770
src/handlers.rs
@ -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(¶ms)
|
||||
.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(¬ify_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(¬ify_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"
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
89
src/main.rs
89
src/main.rs
@ -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/")
|
||||
|
@ -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>,
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user