0605
This commit is contained in:
parent
fe5184e18c
commit
1b012b3923
5
Cargo.lock
generated
5
Cargo.lock
generated
@ -875,6 +875,7 @@ dependencies = [
|
|||||||
"sentry",
|
"sentry",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-actix-web",
|
"tracing-actix-web",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
@ -3122,9 +3123,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.42.0"
|
version = "1.45.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551"
|
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
@ -36,4 +36,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
tracing-actix-web = "0.7"
|
tracing-actix-web = "0.7"
|
||||||
tracing-log = "0.2"
|
tracing-log = "0.2"
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
tracing-bunyan-formatter = "0.3"
|
tracing-bunyan-formatter = "0.3"
|
||||||
|
tokio = "1.45.0"
|
||||||
|
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
|
- `SENTRY_DSN`: Sentry DSN for error tracking
|
||||||
- `JWT_SECRET`: JWT secret key
|
- `JWT_SECRET`: JWT secret key
|
||||||
- `JWT_EXPIRATION`: JWT expiration time in seconds
|
- `JWT_EXPIRATION`: JWT expiration time in seconds
|
||||||
|
- `CAPTCHA_ENABLED`: Enable CAPTCHA verification for public form submissions (`true` or `false`, default: `false`)
|
||||||
|
- `CAPTCHA_SECRET_KEY`: The secret key provided by your CAPTCHA service (e.g., hCaptcha, reCAPTCHA)
|
||||||
|
- `CAPTCHA_VERIFICATION_URL`: The verification endpoint URL for your CAPTCHA service (e.g., `https://hcaptcha.com/siteverify`)
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@ -144,6 +147,17 @@ tail -f logs/app.log
|
|||||||
- Passwords are hashed using bcrypt
|
- Passwords are hashed using bcrypt
|
||||||
- SQLite database is protected with proper file permissions
|
- SQLite database is protected with proper file permissions
|
||||||
|
|
||||||
|
### Form Submission Security
|
||||||
|
|
||||||
|
The public form submission endpoint (`/api/forms/{form_id}/submissions`) includes several security measures:
|
||||||
|
|
||||||
|
- **Global Rate Limiting:** The overall number of requests to the API is limited.
|
||||||
|
- **Per-Form, Per-IP Rate Limiting:** Limits the number of submissions one IP address can make to a specific form within a time window (e.g., 5 submissions per minute). Configurable in code.
|
||||||
|
- **CAPTCHA Verification:** If enabled via environment variables (`CAPTCHA_ENABLED=true`), requires a valid CAPTCHA token (e.g., from hCaptcha, reCAPTCHA, Turnstile) to be sent in the `captcha_token` field of the submission payload. The backend verifies this token with the configured provider.
|
||||||
|
- **Payload Size Limit:** The maximum size of the submission payload is limited (e.g., 1MB) to prevent DoS attacks. Configurable in code.
|
||||||
|
- **Input Validation:** Submission data is validated against the specific form's field definitions (type, required, length, pattern, etc.).
|
||||||
|
- **Notification Throttling:** Limits the rate at which notifications (Email, Ntfy) are sent per form to prevent spamming channels (e.g., max 1 per minute). Configurable in code.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
@ -67,7 +67,7 @@
|
|||||||
<!-- Login Section -->
|
<!-- Login Section -->
|
||||||
<div id="login-section" class="content-card">
|
<div id="login-section" class="content-card">
|
||||||
<h2 class="section-title">Login</h2>
|
<h2 class="section-title">Login</h2>
|
||||||
<form id="login-form">
|
<form post="" id="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Username:</label>
|
<label for="username">Username:</label>
|
||||||
<input type="text" id="username" required />
|
<input type="text" id="username" required />
|
||||||
|
4594
repomix-output.xml
Normal file
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,
|
dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
};
|
};
|
||||||
|
use chrono::Utc;
|
||||||
use futures::future::{ready, Ready};
|
use futures::future::{ready, Ready};
|
||||||
use log; // Use the log crate
|
use log; // Use the log crate
|
||||||
|
use rusqlite::params;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely)
|
use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely)
|
||||||
|
|
||||||
// Represents an authenticated user via token
|
// Represents an authenticated user via token
|
||||||
pub struct Auth {
|
pub struct Auth {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRequest for Auth {
|
impl FromRequest for Auth {
|
||||||
@ -62,23 +65,30 @@ impl FromRequest for Auth {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate the token against the database (now includes expiration check)
|
// Get user_id and role from token
|
||||||
match super::db::validate_token(&conn_guard, token) {
|
let user_result = conn_guard
|
||||||
// Token is valid and not expired, return Ok with Auth struct
|
.query_row(
|
||||||
Ok(Some(user_id)) => {
|
"SELECT u.id, u.role FROM users u WHERE u.token = ?1 AND u.token_expires_at > ?2",
|
||||||
log::debug!("Token validated successfully for user_id: {}", user_id);
|
params![token, Utc::now().to_rfc3339()],
|
||||||
ready(Ok(Auth { user_id }))
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||||
|
)
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
match user_result {
|
||||||
|
Ok(Some((user_id, role))) => {
|
||||||
|
log::debug!(
|
||||||
|
"Token validated successfully for user_id: {} with role: {}",
|
||||||
|
user_id,
|
||||||
|
role
|
||||||
|
);
|
||||||
|
ready(Ok(Auth { user_id, role }))
|
||||||
}
|
}
|
||||||
// Token is invalid, not found, or expired
|
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
log::warn!("Invalid or expired token received"); // Avoid logging token
|
log::warn!("Invalid or expired token received");
|
||||||
ready(Err(ErrorUnauthorized("Invalid or expired token")))
|
ready(Err(ErrorUnauthorized("Invalid or expired token")))
|
||||||
}
|
}
|
||||||
// Database error during token validation
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Database error during token validation: {:?}", e);
|
log::error!("Database error during token validation: {:?}", e);
|
||||||
// Return Unauthorized to avoid leaking internal error details
|
|
||||||
// Consider mapping specific DB errors if needed, but Unauthorized is generally safe
|
|
||||||
ready(Err(ErrorUnauthorized("Token validation failed")))
|
ready(Err(ErrorUnauthorized("Token validation failed")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -99,3 +109,11 @@ impl FromRequest for Auth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to check if a user has admin role
|
||||||
|
pub fn require_admin(auth: &Auth) -> Result<(), ActixWebError> {
|
||||||
|
if auth.role != "admin" {
|
||||||
|
return Err(ErrorUnauthorized("Admin access required"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
153
src/db.rs
153
src/db.rs
@ -24,8 +24,10 @@ pub fn init_db(database_url: &str) -> AnyhowResult<Connection> {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password TEXT NOT NULL, -- Stores bcrypt hashed password
|
password TEXT NOT NULL, -- Stores bcrypt hashed password
|
||||||
|
role TEXT NOT NULL DEFAULT 'user', -- 'admin' or 'user'
|
||||||
token TEXT UNIQUE, -- Stores the current session token (UUID)
|
token TEXT UNIQUE, -- Stores the current session token (UUID)
|
||||||
token_expires_at DATETIME -- Timestamp when the token expires
|
token_expires_at DATETIME, -- Timestamp when the token expires
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@ -37,9 +39,11 @@ pub fn init_db(database_url: &str) -> AnyhowResult<Connection> {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
fields TEXT NOT NULL, -- Stores JSON definition of form fields
|
fields TEXT NOT NULL, -- Stores JSON definition of form fields
|
||||||
|
owner_id TEXT NOT NULL, -- Reference to the user who created the form
|
||||||
notify_email TEXT, -- Optional email address for notifications
|
notify_email TEXT, -- Optional email address for notifications
|
||||||
notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications
|
notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@ -103,8 +107,13 @@ fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> {
|
|||||||
|
|
||||||
// Check password complexity? (Optional enhancement)
|
// Check password complexity? (Optional enhancement)
|
||||||
|
|
||||||
add_user_if_not_exists(conn, &initial_admin_username, &initial_admin_password)
|
add_user_if_not_exists(
|
||||||
.context("Failed during initial admin user setup")?;
|
conn,
|
||||||
|
&initial_admin_username,
|
||||||
|
&initial_admin_password,
|
||||||
|
Some("admin"),
|
||||||
|
)
|
||||||
|
.context("Failed during initial admin user setup")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,6 +122,7 @@ pub fn add_user_if_not_exists(
|
|||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
|
role: Option<&str>, // Optional role parameter
|
||||||
) -> AnyhowResult<bool> {
|
) -> AnyhowResult<bool> {
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
let user_exists: bool = conn
|
let user_exists: bool = conn
|
||||||
@ -142,11 +152,19 @@ pub fn add_user_if_not_exists(
|
|||||||
);
|
);
|
||||||
let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?;
|
let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?;
|
||||||
|
|
||||||
// Insert the new user (token and expiry are initially NULL)
|
// Use provided role or default to "user"
|
||||||
log::info!("Creating new user '{}' with ID: {}", username, user_id);
|
let role = role.unwrap_or("user");
|
||||||
|
|
||||||
|
// Insert the new user
|
||||||
|
log::info!(
|
||||||
|
"Creating new user '{}' with ID: {} and role: {}",
|
||||||
|
username,
|
||||||
|
user_id,
|
||||||
|
role
|
||||||
|
);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)",
|
"INSERT INTO users (id, username, password, role) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![user_id, username, hashed_password],
|
params![user_id, username, hashed_password, role],
|
||||||
)
|
)
|
||||||
.context(format!("Failed to insert user '{}'", username))?;
|
.context(format!("Failed to insert user '{}'", username))?;
|
||||||
|
|
||||||
@ -268,7 +286,7 @@ pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> Anyh
|
|||||||
// Fetch a specific form definition by its ID
|
// Fetch a specific form definition by its ID
|
||||||
pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Option<models::Form>> {
|
pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Option<models::Form>> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1")
|
.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1")
|
||||||
.context("Failed to prepare query for fetching form")?;
|
.context("Failed to prepare query for fetching form")?;
|
||||||
|
|
||||||
let result = stmt
|
let result = stmt
|
||||||
@ -276,9 +294,10 @@ pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Opt
|
|||||||
let id: String = row.get(0)?;
|
let id: String = row.get(0)?;
|
||||||
let name: String = row.get(1)?;
|
let name: String = row.get(1)?;
|
||||||
let fields_str: String = row.get(2)?;
|
let fields_str: String = row.get(2)?;
|
||||||
let notify_email: Option<String> = row.get(3)?;
|
let owner_id: String = row.get(3)?;
|
||||||
let notify_ntfy_topic: Option<String> = row.get(4)?; // Get the new field
|
let notify_email: Option<String> = row.get(4)?;
|
||||||
let created_at: chrono::DateTime<chrono::Utc> = row.get(5)?;
|
let notify_ntfy_topic: Option<String> = row.get(5)?;
|
||||||
|
let created_at: chrono::DateTime<chrono::Utc> = row.get(6)?;
|
||||||
|
|
||||||
// Parse the fields JSON string
|
// Parse the fields JSON string
|
||||||
let fields = serde_json::from_str(&fields_str).map_err(|e| {
|
let fields = serde_json::from_str(&fields_str).map_err(|e| {
|
||||||
@ -293,8 +312,9 @@ pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Opt
|
|||||||
id: Some(id),
|
id: Some(id),
|
||||||
name,
|
name,
|
||||||
fields,
|
fields,
|
||||||
|
owner_id,
|
||||||
notify_email,
|
notify_email,
|
||||||
notify_ntfy_topic, // Include the new field
|
notify_ntfy_topic,
|
||||||
created_at,
|
created_at,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -314,19 +334,21 @@ impl models::Form {
|
|||||||
let fields_json = serde_json::to_string(&self.fields)?;
|
let fields_json = serde_json::to_string(&self.fields)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO forms (id, name, fields, notify_email, notify_ntfy_topic, created_at)
|
"INSERT INTO forms (id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
fields = excluded.fields,
|
fields = excluded.fields,
|
||||||
|
owner_id = excluded.owner_id,
|
||||||
notify_email = excluded.notify_email,
|
notify_email = excluded.notify_email,
|
||||||
notify_ntfy_topic = excluded.notify_ntfy_topic", // Update the new field on conflict
|
notify_ntfy_topic = excluded.notify_ntfy_topic",
|
||||||
params![
|
params![
|
||||||
id,
|
id,
|
||||||
self.name,
|
self.name,
|
||||||
fields_json,
|
fields_json,
|
||||||
|
self.owner_id,
|
||||||
self.notify_email,
|
self.notify_email,
|
||||||
self.notify_ntfy_topic, // Add the new field to params
|
self.notify_ntfy_topic,
|
||||||
self.created_at
|
self.created_at
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@ -336,7 +358,6 @@ impl models::Form {
|
|||||||
|
|
||||||
pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult<Self> {
|
pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult<Self> {
|
||||||
get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id))
|
get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id))
|
||||||
// Added ID to error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,3 +375,99 @@ impl models::Submission {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get user by ID
|
||||||
|
pub fn get_user_by_id(conn: &Connection, user_id: &str) -> AnyhowResult<Option<models::User>> {
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT id, username, role, created_at FROM users WHERE id = ?1")?;
|
||||||
|
|
||||||
|
let result = stmt
|
||||||
|
.query_row(params![user_id], |row| {
|
||||||
|
Ok(models::User {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
password: None, // Never return password
|
||||||
|
role: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by username
|
||||||
|
pub fn get_user_by_username(
|
||||||
|
conn: &Connection,
|
||||||
|
username: &str,
|
||||||
|
) -> AnyhowResult<Option<models::User>> {
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT id, username, role, created_at FROM users WHERE username = ?1")?;
|
||||||
|
|
||||||
|
let result = stmt
|
||||||
|
.query_row(params![username], |row| {
|
||||||
|
Ok(models::User {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
password: None, // Never return password
|
||||||
|
role: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all users (for admin use)
|
||||||
|
pub fn list_users(conn: &Connection) -> AnyhowResult<Vec<models::User>> {
|
||||||
|
let mut stmt = conn.prepare("SELECT id, username, role, created_at FROM users")?;
|
||||||
|
|
||||||
|
let users_iter = stmt.query_map([], |row| {
|
||||||
|
Ok(models::User {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
password: None, // Never return password
|
||||||
|
role: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut users = Vec::new();
|
||||||
|
for user_result in users_iter {
|
||||||
|
users.push(user_result?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
pub fn update_user(
|
||||||
|
conn: &Connection,
|
||||||
|
user_id: &str,
|
||||||
|
update: &models::UserUpdate,
|
||||||
|
) -> AnyhowResult<()> {
|
||||||
|
if let Some(username) = &update.username {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET username = ?1 WHERE id = ?2",
|
||||||
|
params![username, user_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(password) = &update.password {
|
||||||
|
let hashed_password = hash(password, DEFAULT_COST)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password = ?1 WHERE id = ?2",
|
||||||
|
params![hashed_password, user_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
pub fn delete_user(conn: &Connection, user_id: &str) -> AnyhowResult<bool> {
|
||||||
|
let rows_affected = conn.execute("DELETE FROM users WHERE id = ?1", params![user_id])?;
|
||||||
|
|
||||||
|
Ok(rows_affected > 0)
|
||||||
|
}
|
||||||
|
770
src/handlers.rs
770
src/handlers.rs
@ -1,5 +1,7 @@
|
|||||||
use crate::auth::Auth;
|
use crate::auth::Auth;
|
||||||
use crate::models::{Form, LoginCredentials, LoginResponse, Submission};
|
use crate::models::{
|
||||||
|
Form, LoginCredentials, LoginResponse, Submission, User, UserRegistration, UserUpdate,
|
||||||
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
|
use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
|
||||||
use chrono; // Only import the module since we use it qualified
|
use chrono; // Only import the module since we use it qualified
|
||||||
@ -11,6 +13,23 @@ use std::collections::HashMap;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Added imports for CAPTCHA verification
|
||||||
|
use actix_web::HttpRequest;
|
||||||
|
use reqwest;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
// Added for throttling
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
// --- Struct for CAPTCHA Verification Response ---
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct CaptchaVerificationResponse {
|
||||||
|
success: bool,
|
||||||
|
// Providers might include other fields like challenge_ts, hostname, error-codes
|
||||||
|
#[serde(rename = "error-codes")]
|
||||||
|
error_codes: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helper Function for Validation ---
|
// --- Helper Function for Validation ---
|
||||||
|
|
||||||
/// Validates submission data against the form field definitions with enhanced checks.
|
/// Validates submission data against the form field definitions with enhanced checks.
|
||||||
@ -354,12 +373,167 @@ pub async fn logout(
|
|||||||
|
|
||||||
// POST /forms/{form_id}/submissions
|
// POST /forms/{form_id}/submissions
|
||||||
pub async fn submit_form(
|
pub async fn submit_form(
|
||||||
|
req: HttpRequest, // Add HttpRequest to access connection info
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
path: web::Path<String>, // Extracts form_id from path
|
path: web::Path<String>, // Extracts form_id from path
|
||||||
submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload
|
submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
let form_id = path.into_inner();
|
let form_id = path.into_inner();
|
||||||
let conn = app_state.db.lock().map_err(|e| {
|
// Use .get_ref() to borrow AppState without consuming web::Data
|
||||||
|
let app_state_ref = app_state.get_ref();
|
||||||
|
let captcha_config = &app_state_ref.captcha_config;
|
||||||
|
|
||||||
|
// --- Per-Form Per-IP Rate Limiting ---
|
||||||
|
const RATE_LIMIT_DURATION: Duration = Duration::from_secs(60); // 1 minute window
|
||||||
|
const RATE_LIMIT_MAX_ATTEMPTS: u32 = 5; // Max 5 attempts per window
|
||||||
|
|
||||||
|
let client_ip_opt = req
|
||||||
|
.connection_info()
|
||||||
|
.realip_remote_addr()
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
if let Some(client_ip) = client_ip_opt {
|
||||||
|
let mut attempts_map = app_state_ref.form_submission_attempts.lock().map_err(|e| {
|
||||||
|
log::error!("Failed to acquire rate limit lock: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Internal error (rate limit state)")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let form_attempts = attempts_map.entry(form_id.clone()).or_default();
|
||||||
|
let (last_attempt, count) = form_attempts.entry(client_ip.clone()).or_insert((now, 0));
|
||||||
|
|
||||||
|
if now.duration_since(*last_attempt) > RATE_LIMIT_DURATION {
|
||||||
|
// Reset count if window expired
|
||||||
|
*last_attempt = now;
|
||||||
|
*count = 1;
|
||||||
|
} else {
|
||||||
|
// Increment count within the window
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"Rate limit check for form '{}', IP '{}': attempt count = {}, last attempt = {:?}",
|
||||||
|
form_id,
|
||||||
|
client_ip,
|
||||||
|
*count,
|
||||||
|
last_attempt
|
||||||
|
);
|
||||||
|
|
||||||
|
if *count > RATE_LIMIT_MAX_ATTEMPTS {
|
||||||
|
log::warn!(
|
||||||
|
"Rate limit exceeded for form '{}', IP '{}'. Count: {}. Blocking request.",
|
||||||
|
form_id,
|
||||||
|
client_ip,
|
||||||
|
*count
|
||||||
|
);
|
||||||
|
// Consider clearing the entry after a longer block duration if needed
|
||||||
|
return Ok(HttpResponse::TooManyRequests().json(json!({
|
||||||
|
"error": "rate_limit_exceeded",
|
||||||
|
"message": "Too many submission attempts. Please try again later."
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cannot rate limit if IP address is unknown
|
||||||
|
log::warn!("Could not determine client IP for rate limiting.");
|
||||||
|
}
|
||||||
|
// --- End Rate Limiting ---
|
||||||
|
|
||||||
|
let payload_value = submission_payload.into_inner(); // Get the owned JsonValue
|
||||||
|
|
||||||
|
// --- CAPTCHA Verification ---
|
||||||
|
if captcha_config.enabled {
|
||||||
|
let captcha_token = payload_value.get("captcha_token").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
match captcha_token {
|
||||||
|
Some(token) if !token.is_empty() => {
|
||||||
|
// Get client IP address
|
||||||
|
let client_ip = req
|
||||||
|
.connection_info()
|
||||||
|
.realip_remote_addr()
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
// Note: Ensure Actix is configured correctly behind a proxy if needed
|
||||||
|
// using .forwarded_for() or similar mechanisms if realip_remote_addr() isn't sufficient.
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"Verifying CAPTCHA token for IP: {:?}",
|
||||||
|
client_ip.as_deref().unwrap_or("Unknown")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
params.insert("secret", captcha_config.secret_key.as_str());
|
||||||
|
params.insert("response", token);
|
||||||
|
if let Some(ip) = client_ip.as_deref() {
|
||||||
|
params.insert("remoteip", ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consider creating the client once and storing it in AppState for reuse
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let res = client
|
||||||
|
.post(&captcha_config.verification_url)
|
||||||
|
.form(¶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);
|
log::error!("Failed to acquire database lock: {}", e);
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
})?;
|
})?;
|
||||||
@ -367,9 +541,9 @@ pub async fn submit_form(
|
|||||||
// Get form definition
|
// Get form definition
|
||||||
let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?;
|
let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?;
|
||||||
|
|
||||||
// Validate submission against form definition
|
// Validate submission against form definition (using the owned payload_value)
|
||||||
if let Err(validation_errors) =
|
if let Err(validation_errors) =
|
||||||
validate_submission_against_definition(&submission_payload, &form.fields)
|
validate_submission_against_definition(&payload_value, &form.fields)
|
||||||
{
|
{
|
||||||
return Ok(HttpResponse::BadRequest().json(validation_errors));
|
return Ok(HttpResponse::BadRequest().json(validation_errors));
|
||||||
}
|
}
|
||||||
@ -378,7 +552,7 @@ pub async fn submit_form(
|
|||||||
let submission = Submission {
|
let submission = Submission {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
form_id: form_id.clone(),
|
form_id: form_id.clone(),
|
||||||
data: submission_payload.into_inner(),
|
data: payload_value, // Store the full validated payload (including captcha_token if sent)
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -388,42 +562,96 @@ pub async fn submit_form(
|
|||||||
actix_web::error::ErrorInternalServerError("Failed to save submission")
|
actix_web::error::ErrorInternalServerError("Failed to save submission")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Send notifications if configured
|
// --- Notification Throttling & Sending ---
|
||||||
if let Some(notify_email) = form.notify_email {
|
const NOTIFICATION_THROTTLE_DURATION: Duration = Duration::from_secs(60);
|
||||||
let email_subject = format!("New submission for form: {}", form.name);
|
let mut should_send_notification = true; // Assume we should send initially
|
||||||
let email_body = format!(
|
|
||||||
"A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}",
|
|
||||||
form.name,
|
|
||||||
submission.id,
|
|
||||||
submission.created_at,
|
|
||||||
serde_json::to_string_pretty(&submission.data).unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = app_state
|
// Check if notifications are configured for this form at all
|
||||||
.notification_service
|
let notifications_configured = form.notify_email.is_some()
|
||||||
.send_email(¬ify_email, &email_subject, &email_body)
|
|| form
|
||||||
.await
|
.notify_ntfy_topic
|
||||||
{
|
.as_ref()
|
||||||
log::warn!("Failed to send email notification: {}", e);
|
.map_or(false, |s| !s.is_empty());
|
||||||
|
|
||||||
|
if notifications_configured {
|
||||||
|
let mut last_times = app_state_ref.last_notification_times.lock().map_err(|e| {
|
||||||
|
log::error!("Failed to acquire notification throttle lock: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Internal error (notification state)")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Some(last_time) = last_times.get(&form_id) {
|
||||||
|
if now.duration_since(*last_time) < NOTIFICATION_THROTTLE_DURATION {
|
||||||
|
log::info!(
|
||||||
|
"Notification throttled for form_id: {}. Last sent {:?} ago.",
|
||||||
|
form_id,
|
||||||
|
now.duration_since(*last_time)
|
||||||
|
);
|
||||||
|
should_send_notification = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also send ntfy notification if configured (sends to the global topic)
|
// If not throttled, update the timestamp *before* attempting to send
|
||||||
|
if should_send_notification {
|
||||||
|
log::debug!("Updating last notification time for form_id: {}", form_id);
|
||||||
|
last_times.insert(form_id.clone(), now);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
should_send_notification = false; // Don't attempt if not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notifications only if not throttled and configured
|
||||||
|
if should_send_notification {
|
||||||
|
log::info!("Attempting to send notifications for form_id: {}", form_id);
|
||||||
|
// Send Email if configured
|
||||||
|
if let Some(notify_email) = &form.notify_email {
|
||||||
|
let email_subject = format!("New submission for form: {}", form.name);
|
||||||
|
let email_body = format!(
|
||||||
|
"A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}",
|
||||||
|
form.name,
|
||||||
|
submission.id,
|
||||||
|
submission.created_at,
|
||||||
|
serde_json::to_string_pretty(&submission.data).unwrap_or_default()
|
||||||
|
);
|
||||||
|
// Use a clone of notification_service if it needs to move into async block
|
||||||
|
let notification_service_clone = app_state_ref.notification_service.clone();
|
||||||
|
let notify_email_clone = notify_email.clone();
|
||||||
|
let email_subject_clone = email_subject.clone();
|
||||||
|
let email_body_clone = email_body.clone();
|
||||||
|
|
||||||
|
// Spawn email sending as a background task so it doesn't block the response
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = notification_service_clone
|
||||||
|
.send_email(¬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 {
|
if let Some(topic_flag) = &form.notify_ntfy_topic {
|
||||||
// Use field presence as a flag
|
|
||||||
if !topic_flag.is_empty() {
|
if !topic_flag.is_empty() {
|
||||||
// Check if the flag string is non-empty
|
|
||||||
let ntfy_title = format!("New submission for: {}", form.name);
|
let ntfy_title = format!("New submission for: {}", form.name);
|
||||||
let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id);
|
let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id);
|
||||||
if let Err(e) = app_state.notification_service.send_ntfy(
|
// Ntfy send is synchronous in the current implementation, can block
|
||||||
|
// Consider spawning if it becomes slow
|
||||||
|
if let Err(e) = app_state_ref.notification_service.send_ntfy(
|
||||||
&ntfy_title,
|
&ntfy_title,
|
||||||
&ntfy_message,
|
&ntfy_message,
|
||||||
Some(3), // Medium priority
|
Some(3), // Medium priority
|
||||||
) {
|
) {
|
||||||
log::warn!("Failed to send ntfy notification (global topic): {}", e);
|
log::warn!("Failed to send ntfy notification: {}", e);
|
||||||
|
// Don't return error to client, just log
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} // End if should_send_notification
|
||||||
|
// --- End Notification Throttling & Sending ---
|
||||||
|
|
||||||
Ok(HttpResponse::Created().json(json!({
|
Ok(HttpResponse::Created().json(json!({
|
||||||
"message": "Submission received",
|
"message": "Submission received",
|
||||||
@ -434,172 +662,189 @@ pub async fn submit_form(
|
|||||||
// POST /forms
|
// POST /forms
|
||||||
pub async fn create_form(
|
pub async fn create_form(
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
_auth: Auth, // Authentication check via Auth extractor
|
auth: Auth,
|
||||||
payload: web::Json<serde_json::Value>,
|
form_data: web::Json<Form>,
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
let payload = payload.into_inner();
|
let mut form = form_data.into_inner();
|
||||||
|
form.owner_id = auth.user_id.clone(); // Set the owner_id to the authenticated user's ID
|
||||||
|
|
||||||
// Extract form data from payload
|
let db_conn_arc = app_state.db.clone();
|
||||||
let name = payload["name"]
|
web::block(move || {
|
||||||
.as_str()
|
let conn = db_conn_arc
|
||||||
.ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))?
|
.lock()
|
||||||
.to_string();
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
|
||||||
|
form.save(&conn)
|
||||||
let fields = payload["fields"].clone();
|
})
|
||||||
if !fields.is_array() {
|
.await
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
.map_err(|e| {
|
||||||
"'fields' must be a JSON array",
|
log::error!("web::block error while creating form: {:?}", e);
|
||||||
));
|
actix_web::error::ErrorInternalServerError("Failed to create form")
|
||||||
}
|
})?
|
||||||
|
.map_err(anyhow_to_actix_error)?;
|
||||||
let notify_email = payload["notify_email"].as_str().map(|s| s.to_string());
|
|
||||||
let notify_ntfy_topic = payload["notify_ntfy_topic"].as_str().map(|s| s.to_string());
|
|
||||||
|
|
||||||
// Create new form
|
|
||||||
let form = Form {
|
|
||||||
id: None, // Will be generated during save
|
|
||||||
name,
|
|
||||||
fields,
|
|
||||||
notify_email,
|
|
||||||
notify_ntfy_topic,
|
|
||||||
created_at: chrono::Utc::now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save the form
|
|
||||||
let conn = app_state.db.lock().map_err(|e| {
|
|
||||||
log::error!("Failed to acquire database lock: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
form.save(&conn).map_err(|e| {
|
|
||||||
log::error!("Failed to save form: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Failed to save form")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Created().json(form))
|
Ok(HttpResponse::Created().json(form))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /forms
|
// GET /forms
|
||||||
pub async fn get_forms(
|
pub async fn get_forms(app_state: web::Data<AppState>, auth: Auth) -> ActixResult<impl Responder> {
|
||||||
app_state: web::Data<AppState>,
|
let db_conn_arc = app_state.db.clone();
|
||||||
auth: Auth, // Requires authentication
|
let user_id = auth.user_id.clone();
|
||||||
) -> ActixResult<impl Responder> {
|
let is_admin = auth.role == "admin";
|
||||||
log::info!("User {} requesting list of forms", auth.user_id);
|
|
||||||
|
|
||||||
let conn = app_state.db.lock().map_err(|e| {
|
let forms = web::block(move || {
|
||||||
log::error!("Failed to acquire database lock: {}", e);
|
let conn = db_conn_arc
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
.lock()
|
||||||
})?;
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
|
||||||
|
|
||||||
let mut stmt = conn
|
let mut stmt = if is_admin {
|
||||||
.prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms")
|
// Admins can see all forms
|
||||||
.map_err(|e| {
|
conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms")?
|
||||||
log::error!("Failed to prepare statement: {}", e);
|
} else {
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
// Regular users can only see their own forms
|
||||||
})?;
|
conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms WHERE owner_id = ?1")?
|
||||||
|
};
|
||||||
|
|
||||||
let forms_iter = stmt
|
let forms_iter = if is_admin {
|
||||||
.query_map([], |row| {
|
stmt.query_map([], |row| {
|
||||||
let id: String = row.get(0)?;
|
let id: String = row.get(0)?;
|
||||||
let name: String = row.get(1)?;
|
let name: String = row.get(1)?;
|
||||||
let fields_str: String = row.get(2)?;
|
let fields_str: String = row.get(2)?;
|
||||||
let notify_email: Option<String> = row.get(3)?;
|
let owner_id: String = row.get(3)?;
|
||||||
let notify_ntfy_topic: Option<String> = row.get(4)?;
|
let notify_email: Option<String> = row.get(4)?;
|
||||||
let created_at: chrono::DateTime<chrono::Utc> = row.get(5)?;
|
let notify_ntfy_topic: Option<String> = row.get(5)?;
|
||||||
|
let created_at: chrono::DateTime<chrono::Utc> = row.get(6)?;
|
||||||
|
|
||||||
// Parse the 'fields' JSON string
|
let fields = serde_json::from_str(&fields_str).map_err(|e| {
|
||||||
let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| {
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
log::error!(
|
2,
|
||||||
"DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.",
|
rusqlite::types::Type::Text,
|
||||||
id,
|
Box::new(e),
|
||||||
e
|
)
|
||||||
);
|
})?;
|
||||||
rusqlite::Error::FromSqlConversionFailure(
|
|
||||||
2,
|
|
||||||
rusqlite::types::Type::Text,
|
|
||||||
Box::new(e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Form {
|
Ok(Form {
|
||||||
id: Some(id),
|
id: Some(id),
|
||||||
name,
|
name,
|
||||||
fields,
|
fields,
|
||||||
notify_email,
|
owner_id,
|
||||||
notify_ntfy_topic,
|
notify_email,
|
||||||
created_at,
|
notify_ntfy_topic,
|
||||||
})
|
created_at,
|
||||||
})
|
})
|
||||||
.map_err(|e| {
|
})?
|
||||||
log::error!("Failed to execute query: {}", e);
|
} else {
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
stmt.query_map(params![user_id], |row| {
|
||||||
})?;
|
let id: String = row.get(0)?;
|
||||||
|
let name: String = row.get(1)?;
|
||||||
|
let fields_str: String = row.get(2)?;
|
||||||
|
let owner_id: String = row.get(3)?;
|
||||||
|
let notify_email: Option<String> = row.get(4)?;
|
||||||
|
let notify_ntfy_topic: Option<String> = row.get(5)?;
|
||||||
|
let created_at: chrono::DateTime<chrono::Utc> = row.get(6)?;
|
||||||
|
|
||||||
// Collect results, filtering out rows that failed parsing
|
let fields = serde_json::from_str(&fields_str).map_err(|e| {
|
||||||
let forms: Vec<Form> = forms_iter
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
.filter_map(|result| match result {
|
2,
|
||||||
Ok(form) => Some(form),
|
rusqlite::types::Type::Text,
|
||||||
Err(e) => {
|
Box::new(e),
|
||||||
log::warn!("Skipping a form row due to a processing error: {}", e);
|
)
|
||||||
None
|
})?;
|
||||||
}
|
|
||||||
})
|
Ok(Form {
|
||||||
.collect();
|
id: Some(id),
|
||||||
|
name,
|
||||||
|
fields,
|
||||||
|
owner_id,
|
||||||
|
notify_email,
|
||||||
|
notify_ntfy_topic,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut forms = Vec::new();
|
||||||
|
for form_result in forms_iter {
|
||||||
|
forms.push(form_result?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<_, anyhow::Error>(forms)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("web::block error while fetching forms: {:?}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Failed to fetch forms")
|
||||||
|
})?
|
||||||
|
.map_err(anyhow_to_actix_error)?;
|
||||||
|
|
||||||
log::debug!("Returning {} forms for user {}", forms.len(), auth.user_id);
|
|
||||||
Ok(HttpResponse::Ok().json(forms))
|
Ok(HttpResponse::Ok().json(forms))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /forms/{form_id}/submissions
|
// GET /forms/{form_id}/submissions
|
||||||
pub async fn get_submissions(
|
pub async fn get_submissions(
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
auth: Auth, // Requires authentication
|
auth: Auth,
|
||||||
path: web::Path<String>, // Extracts form_id from the path
|
form_id: web::Path<String>,
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
let form_id = path.into_inner();
|
let db_conn_arc = app_state.db.clone();
|
||||||
log::info!(
|
let form_id_str = form_id.into_inner();
|
||||||
"User {} requesting submissions for form_id: {}",
|
let user_id = auth.user_id.clone();
|
||||||
auth.user_id,
|
let is_admin = auth.role == "admin";
|
||||||
form_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let conn = app_state.db.lock().map_err(|e| {
|
// First check if the user has access to this form
|
||||||
log::error!("Failed to acquire database lock: {}", e);
|
let can_access = web::block(move || {
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
let conn = db_conn_arc
|
||||||
})?;
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
|
||||||
|
|
||||||
// Check if the form exists
|
if is_admin {
|
||||||
let _form = Form::get_by_id(&conn, &form_id).map_err(|e| {
|
// Admins can access all forms
|
||||||
if e.to_string().contains("not found") {
|
return Ok(true);
|
||||||
actix_web::error::ErrorNotFound("Form not found")
|
|
||||||
} else {
|
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
|
||||||
}
|
}
|
||||||
})?;
|
|
||||||
|
|
||||||
// Get submissions
|
// Check if the form belongs to the user
|
||||||
let mut stmt = conn
|
let owner_id: Option<String> = conn
|
||||||
.prepare(
|
.query_row(
|
||||||
"SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC",
|
"SELECT owner_id FROM forms WHERE id = ?1",
|
||||||
)
|
params![form_id_str],
|
||||||
.map_err(|e| {
|
|row| row.get(0),
|
||||||
log::error!("Failed to prepare statement: {}", e);
|
)
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
.optional()?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let submissions_iter = stmt
|
match owner_id {
|
||||||
.query_map(params![form_id], |row| {
|
Some(owner_id) => Ok(owner_id == user_id),
|
||||||
|
None => Ok(false), // Form doesn't exist
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("web::block error while checking form access: {:?}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Failed to check form access")
|
||||||
|
})?
|
||||||
|
.map_err(anyhow_to_actix_error)?;
|
||||||
|
|
||||||
|
if !can_access {
|
||||||
|
return Err(actix_web::error::ErrorForbidden("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now fetch the submissions
|
||||||
|
let db_conn_arc = app_state.db.clone();
|
||||||
|
let form_id_str = form_id.into_inner();
|
||||||
|
|
||||||
|
let submissions = web::block(move || {
|
||||||
|
let conn = db_conn_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1")?;
|
||||||
|
|
||||||
|
let submissions_iter = stmt.query_map(params![form_id_str], |row| {
|
||||||
let id: String = row.get(0)?;
|
let id: String = row.get(0)?;
|
||||||
let form_id: String = row.get(1)?;
|
let form_id: String = row.get(1)?;
|
||||||
let data_str: String = row.get(2)?;
|
let data_str: String = row.get(2)?;
|
||||||
let created_at: chrono::DateTime<chrono::Utc> = row.get(3)?;
|
let created_at: chrono::DateTime<chrono::Utc> = row.get(3)?;
|
||||||
|
|
||||||
let data: serde_json::Value = serde_json::from_str(&data_str).map_err(|e| {
|
let data = serde_json::from_str(&data_str).map_err(|e| {
|
||||||
log::error!(
|
|
||||||
"DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.",
|
|
||||||
id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
rusqlite::Error::FromSqlConversionFailure(
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
2,
|
2,
|
||||||
rusqlite::types::Type::Text,
|
rusqlite::types::Type::Text,
|
||||||
@ -613,28 +858,22 @@ pub async fn get_submissions(
|
|||||||
data,
|
data,
|
||||||
created_at,
|
created_at,
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.map_err(|e| {
|
|
||||||
log::error!("Failed to execute query: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Database error")
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let submissions: Vec<Submission> = submissions_iter
|
let mut submissions = Vec::new();
|
||||||
.filter_map(|result| match result {
|
for submission_result in submissions_iter {
|
||||||
Ok(submission) => Some(submission),
|
submissions.push(submission_result?);
|
||||||
Err(e) => {
|
}
|
||||||
log::warn!("Skipping a submission row due to processing error: {}", e);
|
|
||||||
None
|
Ok::<_, anyhow::Error>(submissions)
|
||||||
}
|
})
|
||||||
})
|
.await
|
||||||
.collect();
|
.map_err(|e| {
|
||||||
|
log::error!("web::block error while fetching submissions: {:?}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Failed to fetch submissions")
|
||||||
|
})?
|
||||||
|
.map_err(anyhow_to_actix_error)?;
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Returning {} submissions for form {} requested by user {}",
|
|
||||||
submissions.len(),
|
|
||||||
form_id,
|
|
||||||
auth.user_id
|
|
||||||
);
|
|
||||||
Ok(HttpResponse::Ok().json(submissions))
|
Ok(HttpResponse::Ok().json(submissions))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -749,3 +988,172 @@ pub async fn health_check() -> impl Responder {
|
|||||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /register
|
||||||
|
pub async fn register(
|
||||||
|
app_state: web::Data<AppState>,
|
||||||
|
registration: web::Json<models::UserRegistration>,
|
||||||
|
) -> ActixResult<impl Responder> {
|
||||||
|
let db_conn_arc = app_state.db.clone();
|
||||||
|
let username = registration.username.clone();
|
||||||
|
let password = registration.password.clone();
|
||||||
|
|
||||||
|
// Register user in a blocking operation
|
||||||
|
let result = web::block(move || {
|
||||||
|
let conn = db_conn_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during registration"))?;
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
if let Some(_) = crate::db::get_user_by_username(&conn, &username)? {
|
||||||
|
return Err(anyhow::anyhow!("Username already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new user with default role "user"
|
||||||
|
crate::db::add_user_if_not_exists(&conn, &username, &password, None)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("web::block error during registration: {:?}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Registration process failed")
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("already exists") {
|
||||||
|
actix_web::error::ErrorConflict(e.to_string())
|
||||||
|
} else {
|
||||||
|
actix_web::error::ErrorInternalServerError(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Created().json(json!({
|
||||||
|
"message": "User registered successfully"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /users (admin only)
|
||||||
|
pub async fn list_users(app_state: web::Data<AppState>, auth: Auth) -> ActixResult<impl Responder> {
|
||||||
|
// Check admin role
|
||||||
|
crate::auth::require_admin(&auth)?;
|
||||||
|
|
||||||
|
let db_conn_arc = app_state.db.clone();
|
||||||
|
|
||||||
|
let users = web::block(move || {
|
||||||
|
let conn = db_conn_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
|
||||||
|
crate::db::list_users(&conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("web::block error while listing users: {:?}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Failed to list users")
|
||||||
|
})?
|
||||||
|
.map_err(anyhow_to_actix_error)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /users/{user_id} (admin or self)
|
||||||
|
pub async fn get_user(
|
||||||
|
app_state: web::Data<AppState>,
|
||||||
|
auth: Auth,
|
||||||
|
user_id: web::Path<String>,
|
||||||
|
) -> ActixResult<impl Responder> {
|
||||||
|
// Allow if admin or if user is requesting their own data
|
||||||
|
if auth.role != "admin" && auth.user_id != user_id.as_str() {
|
||||||
|
return Err(actix_web::error::ErrorForbidden("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let db_conn_arc = app_state.db.clone();
|
||||||
|
let user_id_str = user_id.into_inner();
|
||||||
|
|
||||||
|
let user = web::block(move || {
|
||||||
|
let conn = db_conn_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
|
||||||
|
crate::db::get_user_by_id(&conn, &user_id_str)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("web::block error while fetching user: {:?}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Failed to fetch user")
|
||||||
|
})?
|
||||||
|
.map_err(anyhow_to_actix_error)?;
|
||||||
|
|
||||||
|
match user {
|
||||||
|
Some(user) => Ok(HttpResponse::Ok().json(user)),
|
||||||
|
None => Ok(HttpResponse::NotFound().json(json!({
|
||||||
|
"message": "User not found"
|
||||||
|
}))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /users/{user_id} (admin or self)
|
||||||
|
pub async fn update_user(
|
||||||
|
app_state: web::Data<AppState>,
|
||||||
|
auth: Auth,
|
||||||
|
user_id: web::Path<String>,
|
||||||
|
update: web::Json<models::UserUpdate>,
|
||||||
|
) -> ActixResult<impl Responder> {
|
||||||
|
// Allow if admin or if user is updating their own data
|
||||||
|
if auth.role != "admin" && auth.user_id != user_id.as_str() {
|
||||||
|
return Err(actix_web::error::ErrorForbidden("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let db_conn_arc = app_state.db.clone();
|
||||||
|
let user_id_str = user_id.into_inner();
|
||||||
|
let update_data = update.into_inner();
|
||||||
|
|
||||||
|
web::block(move || {
|
||||||
|
let conn = db_conn_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
|
||||||
|
crate::db::update_user(&conn, &user_id_str, &update_data)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("web::block error while updating user: {:?}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Failed to update user")
|
||||||
|
})?
|
||||||
|
.map_err(anyhow_to_actix_error)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(json!({
|
||||||
|
"message": "User updated successfully"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /users/{user_id} (admin only)
|
||||||
|
pub async fn delete_user(
|
||||||
|
app_state: web::Data<AppState>,
|
||||||
|
auth: Auth,
|
||||||
|
user_id: web::Path<String>,
|
||||||
|
) -> ActixResult<impl Responder> {
|
||||||
|
// Only admins can delete users
|
||||||
|
crate::auth::require_admin(&auth)?;
|
||||||
|
|
||||||
|
let db_conn_arc = app_state.db.clone();
|
||||||
|
let user_id_str = user_id.into_inner();
|
||||||
|
|
||||||
|
let deleted = web::block(move || {
|
||||||
|
let conn = db_conn_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?;
|
||||||
|
crate::db::delete_user(&conn, &user_id_str)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("web::block error while deleting user: {:?}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Failed to delete user")
|
||||||
|
})?
|
||||||
|
.map_err(anyhow_to_actix_error)?;
|
||||||
|
|
||||||
|
if deleted {
|
||||||
|
Ok(HttpResponse::Ok().json(json!({
|
||||||
|
"message": "User deleted successfully"
|
||||||
|
})))
|
||||||
|
} else {
|
||||||
|
Ok(HttpResponse::NotFound().json(json!({
|
||||||
|
"message": "User not found"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
89
src/main.rs
89
src/main.rs
@ -9,10 +9,13 @@ use std::env;
|
|||||||
use std::io::Result as IoResult;
|
use std::io::Result as IoResult;
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
// Added for throttling map
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
// Import modules
|
// Import modules
|
||||||
mod auth;
|
mod auth;
|
||||||
mod db;
|
mod db;
|
||||||
@ -22,10 +25,54 @@ mod notifications;
|
|||||||
|
|
||||||
use notifications::{NotificationConfig, NotificationService};
|
use notifications::{NotificationConfig, NotificationService};
|
||||||
|
|
||||||
|
// --- CAPTCHA Configuration ---
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CaptchaConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub secret_key: String,
|
||||||
|
pub verification_url: String, // e.g., "https://hcaptcha.com/siteverify"
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptchaConfig {
|
||||||
|
// Function to load from environment variables
|
||||||
|
pub fn from_env() -> Result<Self, std::env::VarError> {
|
||||||
|
// Return VarError for simplicity
|
||||||
|
let enabled = std::env::var("CAPTCHA_ENABLED")
|
||||||
|
.map(|v| v.parse().unwrap_or(false))
|
||||||
|
.unwrap_or(false); // Default to false if not set or parse error
|
||||||
|
|
||||||
|
// Use Ok variant of Result for keys, default to empty if not found
|
||||||
|
let secret_key = std::env::var("CAPTCHA_SECRET_KEY").unwrap_or_default();
|
||||||
|
let verification_url = std::env::var("CAPTCHA_VERIFICATION_URL").unwrap_or_default();
|
||||||
|
|
||||||
|
// Basic validation: if enabled, secret key and URL must be present
|
||||||
|
if enabled && (secret_key.is_empty() || verification_url.is_empty()) {
|
||||||
|
warn!("CAPTCHA_ENABLED is true, but CAPTCHA_SECRET_KEY or CAPTCHA_VERIFICATION_URL is missing. CAPTCHA will be effectively disabled.");
|
||||||
|
Ok(Self {
|
||||||
|
enabled: false, // Force disable if config is incomplete
|
||||||
|
secret_key,
|
||||||
|
verification_url,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(Self {
|
||||||
|
enabled,
|
||||||
|
secret_key,
|
||||||
|
verification_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- End CAPTCHA Configuration ---
|
||||||
|
|
||||||
// Application state that will be shared across all routes
|
// Application state that will be shared across all routes
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
db: Arc<Mutex<rusqlite::Connection>>,
|
db: Arc<Mutex<rusqlite::Connection>>,
|
||||||
notification_service: Arc<NotificationService>,
|
notification_service: Arc<NotificationService>,
|
||||||
|
captcha_config: CaptchaConfig,
|
||||||
|
// Map form_id to the Instant of the last notification attempt for that form
|
||||||
|
last_notification_times: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
// Map form_id -> ip_address -> (last_attempt_time, count) for rate limiting
|
||||||
|
form_submission_attempts: Arc<Mutex<HashMap<String, HashMap<String, (Instant, u32)>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
@ -143,16 +190,38 @@ async fn main() -> IoResult<()> {
|
|||||||
});
|
});
|
||||||
let notification_service = Arc::new(NotificationService::new(notification_config));
|
let notification_service = Arc::new(NotificationService::new(notification_config));
|
||||||
|
|
||||||
// Create AppState with both database and notification service
|
// Load CAPTCHA Configuration
|
||||||
|
let captcha_config = CaptchaConfig::from_env().unwrap_or_else(|e| {
|
||||||
|
warn!(
|
||||||
|
"Failed to load CAPTCHA configuration: {}. CAPTCHA will be disabled.",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
// Ensure default is truly disabled
|
||||||
|
CaptchaConfig {
|
||||||
|
enabled: false,
|
||||||
|
secret_key: String::new(),
|
||||||
|
verification_url: String::new(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if captcha_config.enabled {
|
||||||
|
info!("CAPTCHA verification is ENABLED.");
|
||||||
|
} else {
|
||||||
|
info!("CAPTCHA verification is DISABLED (or required env vars missing).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create AppState with all services
|
||||||
let app_state = web::Data::new(AppState {
|
let app_state = web::Data::new(AppState {
|
||||||
db: Arc::new(Mutex::new(db_connection)),
|
db: Arc::new(Mutex::new(db_connection)),
|
||||||
notification_service: notification_service.clone(),
|
notification_service: notification_service.clone(),
|
||||||
|
captcha_config: captcha_config.clone(),
|
||||||
|
last_notification_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
form_submission_attempts: Arc::new(Mutex::new(HashMap::new())), // Initialize rate limit map
|
||||||
});
|
});
|
||||||
|
|
||||||
info!("Starting server at http://{}", bind_address);
|
info!("Starting server at http://{}", bind_address);
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
let app_state = app_state.clone();
|
let app_state = app_state.clone(); // This now includes captcha_config
|
||||||
let allowed_origins = allowed_origins_list.clone();
|
let allowed_origins = allowed_origins_list.clone();
|
||||||
let rate_limiter = RateLimiter::new(limiter_data.clone());
|
let rate_limiter = RateLimiter::new(limiter_data.clone());
|
||||||
|
|
||||||
@ -190,18 +259,23 @@ async fn main() -> IoResult<()> {
|
|||||||
.max_age(3600)
|
.max_age(3600)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Configure JSON payload limits (e.g., 1MB)
|
||||||
|
let json_config = web::JsonConfig::default().limit(1024 * 1024);
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
.wrap(tracing_actix_web::TracingLogger::default())
|
.wrap(tracing_actix_web::TracingLogger::default())
|
||||||
.wrap(rate_limiter)
|
.wrap(rate_limiter)
|
||||||
.app_data(app_state)
|
.app_data(app_state) // Share app state (db, notifications, captcha)
|
||||||
|
.app_data(json_config) // Add JSON payload configuration
|
||||||
.service(
|
.service(
|
||||||
web::scope("/api")
|
web::scope("/api")
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
.route("/health", web::get().to(handlers::health_check))
|
.route("/health", web::get().to(handlers::health_check))
|
||||||
// Public routes
|
// Public routes
|
||||||
.route("/login", web::post().to(handlers::login))
|
.route("/login", web::post().to(handlers::login))
|
||||||
|
.route("/register", web::post().to(handlers::register))
|
||||||
.route(
|
.route(
|
||||||
"/forms/{form_id}/submissions",
|
"/forms/{form_id}/submissions",
|
||||||
web::post().to(handlers::submit_form),
|
web::post().to(handlers::submit_form),
|
||||||
@ -221,7 +295,12 @@ async fn main() -> IoResult<()> {
|
|||||||
.route(
|
.route(
|
||||||
"/forms/{form_id}/notifications",
|
"/forms/{form_id}/notifications",
|
||||||
web::put().to(handlers::update_notification_settings),
|
web::put().to(handlers::update_notification_settings),
|
||||||
),
|
)
|
||||||
|
// User management routes
|
||||||
|
.route("/users", web::get().to(handlers::list_users))
|
||||||
|
.route("/users/{user_id}", web::get().to(handlers::get_user))
|
||||||
|
.route("/users/{user_id}", web::put().to(handlers::update_user))
|
||||||
|
.route("/users/{user_id}", web::delete().to(handlers::delete_user)),
|
||||||
)
|
)
|
||||||
.service(
|
.service(
|
||||||
fs::Files::new("/", "./frontend/")
|
fs::Files::new("/", "./frontend/")
|
||||||
|
@ -29,6 +29,7 @@ pub struct Form {
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
pub fields: serde_json::Value,
|
pub fields: serde_json::Value,
|
||||||
|
pub owner_id: String,
|
||||||
pub notify_email: Option<String>,
|
pub notify_email: Option<String>,
|
||||||
pub notify_ntfy_topic: Option<String>,
|
pub notify_ntfy_topic: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
@ -74,3 +75,27 @@ pub struct NotificationSettingsPayload {
|
|||||||
pub notify_email: Option<String>,
|
pub notify_email: Option<String>,
|
||||||
pub notify_ntfy_topic: Option<String>,
|
pub notify_ntfy_topic: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
#[serde(skip_serializing)] // Never send password in responses
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub role: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for user registration
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserRegistration {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for user profile updates
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserUpdate {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user