";
+ }
+ } catch (error) {
+ showStatus(
+ `Failed to load submissions for form ${formId}: ${error.message}`,
+ true
+ );
+ submissionsList.innerHTML = "
Error loading submissions.
";
+ submissionsSection.classList.add("hidden"); // Hide section on error
+ }
+ }
+
+ // --- Public Form Handling ---
+
+ if (loadPublicFormButton) {
+ loadPublicFormButton.addEventListener("click", async () => {
+ const formId = publicFormIdInput.value.trim();
+ if (!formId) {
+ showStatus("Please enter a Form ID.", true);
+ return;
+ }
+ showStatus("");
+ publicFormArea.classList.add("hidden");
+ publicForm.innerHTML = "Loading form..."; // Clear previous form
+
+ // NOTE: Fetching form definition is NOT directly possible with the current backend
+ // The backend only provides GET /forms (all, protected) and GET /forms/{id}/submissions (protected)
+ // It DOES NOT provide a public GET /forms/{id} endpoint to fetch the definition.
+ //
+ // **WORKAROUND:** We will *assume* the user knows the structure or we have it cached/predefined.
+ // For this example, we'll fetch *all* forms (if logged in) and find it, OR fail if not logged in.
+ // A *better* backend design would include a public GET /forms/{id} endpoint.
+
+ try {
+ // Attempt to get the form definition (requires login for this workaround)
+ if (!authToken) {
+ showStatus(
+ "Loading public forms requires login in this demo version.",
+ true
+ );
+ publicForm.innerHTML = ""; // Clear loading message
+ return;
+ }
+ const forms = await makeApiRequest("/forms", "GET", null, true);
+ const formDefinition = forms.find((f) => f.id === formId);
+
+ if (!formDefinition) {
+ throw new Error(`Form with ID ${formId} not found or access denied.`);
+ }
+
+ renderPublicForm(formDefinition);
+ publicFormArea.classList.remove("hidden");
+ } catch (error) {
+ showStatus(`Failed to load form ${formId}: ${error.message}`, true);
+ publicForm.innerHTML = ""; // Clear loading message
+ publicFormArea.classList.add("hidden");
+ }
+ });
+ }
+
+ function renderPublicForm(formDefinition) {
+ publicFormTitle.textContent = formDefinition.name;
+ publicForm.innerHTML = ""; // Clear previous fields
+ publicForm.dataset.formId = formDefinition.id; // Store form ID for submission
+
+ if (!formDefinition.fields || !Array.isArray(formDefinition.fields)) {
+ publicForm.innerHTML = "
Error: Form definition is invalid.
";
+ console.error("Invalid form fields definition:", formDefinition.fields);
+ return;
+ }
+
+ formDefinition.fields.forEach((field) => {
+ const div = document.createElement("div");
+ const label = document.createElement("label");
+ label.htmlFor = `field-${field.name}`;
+ label.textContent = field.label || field.name; // Use label, fallback to name
+ div.appendChild(label);
+
+ let input;
+ // Basic type handling - could be expanded
+ switch (field.type) {
+ case "textarea": // Allow explicit textarea type
+ case "string":
+ // Use textarea for string if maxLength suggests it might be long
+ if (field.maxLength && field.maxLength > 100) {
+ input = document.createElement("textarea");
+ input.rows = 4; // Default rows
+ } else {
+ input = document.createElement("input");
+ input.type = "text";
+ }
+ if (field.minLength) input.minLength = field.minLength;
+ if (field.maxLength) input.maxLength = field.maxLength;
+ break;
+ case "email":
+ input = document.createElement("input");
+ input.type = "email";
+ break;
+ case "url":
+ input = document.createElement("input");
+ input.type = "url";
+ break;
+ case "number":
+ input = document.createElement("input");
+ input.type = "number";
+ if (field.min !== undefined) input.min = field.min;
+ if (field.max !== undefined) input.max = field.max;
+ input.step = field.step || "any"; // Allow decimals by default
+ break;
+ case "boolean":
+ input = document.createElement("input");
+ input.type = "checkbox";
+ // Checkbox label handling is slightly different
+ label.insertBefore(input, label.firstChild); // Put checkbox before text
+ input.style.width = "auto"; // Override default width
+ input.style.marginRight = "10px";
+ break;
+ // Add cases for 'select', 'radio', 'date' etc. if needed
+ default:
+ input = document.createElement("input");
+ input.type = "text";
+ console.warn(
+ `Unsupported field type "${field.type}" for field "${field.name}". Rendering as text.`
+ );
+ }
+
+ if (input.type !== "checkbox") {
+ // Checkbox is already appended inside label
+ div.appendChild(input);
+ }
+ input.id = `field-${field.name}`;
+ input.name = field.name; // Crucial for form data collection
+ if (field.required) input.required = true;
+ if (field.placeholder) input.placeholder = field.placeholder;
+ if (field.pattern) input.pattern = field.pattern; // Add regex pattern validation
+
+ publicForm.appendChild(div);
+ });
+
+ const submitButton = document.createElement("button");
+ submitButton.type = "submit";
+ submitButton.textContent = "Submit Form";
+ publicForm.appendChild(submitButton);
+ }
+
+ publicForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ showStatus("");
+ const formId = e.target.dataset.formId;
+ if (!formId) {
+ showStatus("Error: Form ID is missing.", true);
+ return;
+ }
+
+ const formData = new FormData(e.target);
+ const submissionData = {};
+
+ // Convert FormData to a plain object, handling checkboxes correctly
+ for (const [key, value] of formData.entries()) {
+ const inputElement = e.target.elements[key];
+
+ // Handle Checkboxes (boolean)
+ if (inputElement && inputElement.type === "checkbox") {
+ // A checkbox value is only present in FormData if it's checked.
+ // We need to ensure we always send a boolean.
+ // Check if the element exists in the form (it might be unchecked)
+ submissionData[key] = inputElement.checked;
+ }
+ // Handle Number inputs (convert from string)
+ else if (inputElement && inputElement.type === "number") {
+ // Only convert if the value is not empty, otherwise send null or handle as needed
+ if (value !== "") {
+ submissionData[key] = parseFloat(value); // Or parseInt if only integers allowed
+ if (isNaN(submissionData[key])) {
+ // Handle potential parsing errors if input validation fails
+ console.warn(`Could not parse number for field ${key}: ${value}`);
+ submissionData[key] = null; // Or keep as string, or show error
+ }
+ } else {
+ submissionData[key] = null; // Or undefined, depending on backend expectation for empty numbers
+ }
+ }
+ // Handle potential multiple values for the same name (e.g., multi-select), though not rendered here
+ else if (submissionData.hasOwnProperty(key)) {
+ if (!Array.isArray(submissionData[key])) {
+ submissionData[key] = [submissionData[key]];
+ }
+ submissionData[key].push(value);
+ }
+ // Default: treat as string
+ else {
+ submissionData[key] = value;
+ }
+ }
+
+ // Ensure boolean fields that were *unchecked* are explicitly set to false
+ // FormData only includes checked checkboxes. Find all checkbox inputs in the form.
+ const checkboxes = e.target.querySelectorAll('input[type="checkbox"]');
+ checkboxes.forEach((cb) => {
+ if (!submissionData.hasOwnProperty(cb.name)) {
+ submissionData[cb.name] = false; // Set unchecked boxes to false
+ }
+ });
+
+ console.log("Submitting data:", submissionData); // Debugging
+
+ try {
+ // Public submission endpoint doesn't require auth
+ const result = await makeApiRequest(
+ `/forms/${formId}/submissions`,
+ "POST",
+ submissionData,
+ false
+ );
+ showStatus(
+ `Submission successful! Submission ID: ${result.submission_id}`
+ );
+ e.target.reset(); // Clear the form
+ // Optionally hide the form after successful submission
+ // publicFormArea.classList.add('hidden');
+ } catch (error) {
+ let errorMsg = `Submission failed: ${error.message}`;
+ // Handle validation errors specifically
+ if (error.validationErrors) {
+ errorMsg = "Submission failed due to validation errors:\n";
+ for (const [field, message] of Object.entries(error.validationErrors)) {
+ errorMsg += `- ${field}: ${message}\n`;
+ }
+ // Highlight invalid fields? (More complex UI update)
+ }
+ showStatus(errorMsg, true);
+ }
+ });
+
+ // --- Initial Setup ---
+ toggleSections(); // Set initial view based on stored token
+ if (authToken) {
+ loadFormsButton.click(); // Auto-load forms if logged in
+ }
+});
diff --git a/frontend/style.css b/frontend/style.css
new file mode 100644
index 0000000..33e22c2
--- /dev/null
+++ b/frontend/style.css
@@ -0,0 +1,411 @@
+/* --- Variables copied from FormCraft --- */
+:root {
+ --color-bg: #f7f7f7;
+ --color-surface: #ffffff;
+ --color-primary: #3a4750; /* Dark grayish blue */
+ --color-secondary: #d8d8d8; /* Light gray */
+ --color-accent: #b06f42; /* Warm wood/leather brown */
+ --color-text: #2d3436; /* Dark gray */
+ --color-text-light: #636e72; /* Medium gray */
+ --color-border: #e0e0e0; /* Light border gray */
+ --color-success: #2e7d32; /* Green */
+ --color-success-bg: #e8f5e9;
+ --color-error: #a94442; /* Red for errors */
+ --color-error-bg: #f2dede;
+ --color-danger: #e74c3c; /* Red for danger buttons */
+ --color-danger-hover: #c0392b;
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
+ --border-radius: 6px;
+}
+
+/* --- Global Reset & Body Styles --- */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
+}
+
+body {
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ line-height: 1.6;
+ min-height: 100vh;
+ display: flex; /* Helps with potential footer later */
+ flex-direction: column;
+}
+
+/* --- Container --- */
+.container {
+ max-width: 900px; /* Adjusted width for simpler content */
+ width: 100%;
+ margin: 0 auto;
+ padding: 32px 24px; /* Add padding like main content */
+}
+
+.page-container {
+ flex: 1; /* Make container take available space if using flex on body */
+}
+
+/* --- Typography --- */
+h1,
+h2,
+h3 {
+ color: var(--color-primary);
+ margin-bottom: 16px;
+ line-height: 1.3;
+}
+
+h1.page-title {
+ font-size: 1.75rem;
+ font-weight: 600;
+ margin-bottom: 24px;
+ text-align: center; /* Center main title */
+}
+
+h2.section-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ border-bottom: 1px solid var(--color-border);
+ padding-bottom: 8px;
+ margin-bottom: 20px;
+}
+
+h3.card-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--color-primary);
+ margin-bottom: 16px;
+}
+
+p {
+ margin-bottom: 16px;
+ color: var(--color-text-light);
+}
+p:last-child {
+ margin-bottom: 0;
+}
+
+hr.divider {
+ border: 0;
+ height: 1px;
+ background: var(--color-border);
+ margin: 32px 0;
+}
+
+/* --- Content Card / Section Styling --- */
+.content-card,
+.section {
+ background-color: var(--color-surface);
+ padding: 24px;
+ margin-bottom: 24px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+ box-shadow: var(--shadow-sm);
+}
+
+.admin-header p {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0;
+ color: var(--color-text);
+ font-weight: 500;
+}
+
+.admin-header span {
+ font-weight: 600;
+ color: var(--color-primary);
+}
+
+/* --- Forms --- */
+form .form-group {
+ margin-bottom: 16px;
+}
+/* For side-by-side input and button */
+form .inline-form-group {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start; /* Align items to top */
+}
+form .inline-form-group input {
+ flex-grow: 1; /* Allow input to take available space */
+ margin-bottom: 0; /* Remove bottom margin */
+}
+form .inline-form-group button {
+ flex-shrink: 0; /* Prevent button from shrinking */
+}
+
+label {
+ display: block;
+ margin-bottom: 6px;
+ font-weight: 500;
+ font-size: 0.9rem;
+ color: var(--color-text-light);
+}
+
+input[type="text"],
+input[type="password"],
+input[type="email"],
+input[type="url"],
+input[type="number"],
+textarea {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+ font-size: 0.95rem;
+ color: var(--color-text);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+input[type="text"]:focus,
+input[type="password"]:focus,
+input[type="email"]:focus,
+input[type="url"]:focus,
+input[type="number"]:focus,
+textarea:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 2px rgba(176, 111, 66, 0.2); /* Accent focus ring */
+}
+
+textarea {
+ min-height: 80px;
+ resize: vertical;
+}
+
+/* Styling for dynamically generated public form fields */
+#public-form div {
+ margin-bottom: 16px; /* Keep consistent spacing */
+}
+
+/* Specific styles for checkboxes */
+#public-form input[type="checkbox"] {
+ width: auto; /* Override 100% width */
+ margin-right: 10px;
+ vertical-align: middle; /* Align checkbox nicely with label text */
+ margin-bottom: 0; /* Remove bottom margin if label handles spacing */
+}
+#public-form input[type="checkbox"] + label, /* Style label differently if needed when next to checkbox */
+#public-form label input[type="checkbox"] /* Style if checkbox is inside label */ {
+ display: inline-flex; /* Or inline-block */
+ align-items: center;
+ margin-bottom: 0; /* Prevent double margin */
+ font-weight: normal; /* Checkboxes often have normal weight labels */
+ color: var(--color-text);
+}
+
+/* --- Buttons --- */
+.button {
+ background-color: var(--color-primary);
+ color: white;
+ border: 1px solid transparent; /* Add border for consistency */
+ padding: 10px 18px;
+ border-radius: var(--border-radius);
+ font-weight: 500;
+ font-size: 0.9rem;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ transition: all 0.2s ease;
+ text-decoration: none;
+ line-height: 1.5;
+ vertical-align: middle; /* Align with text/inputs */
+}
+
+.button:hover {
+ background-color: #2c373f; /* Slightly darker hover */
+ box-shadow: var(--shadow-sm);
+}
+.button:active {
+ background-color: #1e2a31; /* Even darker active state */
+}
+
+.button-secondary {
+ background-color: var(--color-surface);
+ color: var(--color-primary);
+ border: 1px solid var(--color-border);
+}
+
+.button-secondary:hover {
+ background-color: #f8f8f8; /* Subtle hover for secondary */
+ border-color: #d0d0d0;
+}
+.button-secondary:active {
+ background-color: #f0f0f0;
+}
+
+.button-danger {
+ background-color: var(--color-danger);
+ border-color: var(--color-danger);
+}
+.button-danger:hover {
+ background-color: var(--color-danger-hover);
+ border-color: var(--color-danger-hover);
+}
+.button-danger:active {
+ background-color: #a52e22; /* Even darker red */
+}
+
+/* Smaller button variant for lists? */
+.button-sm {
+ padding: 5px 10px;
+ font-size: 0.8rem;
+}
+
+/* Ensure buttons added by JS (like submit in public form) get styled */
+#public-form button[type="submit"] {
+ /* Inherit .button styles if possible, otherwise redefine */
+ background-color: var(--color-primary);
+ color: white;
+ border: 1px solid transparent;
+ padding: 10px 18px;
+ border-radius: var(--border-radius);
+ font-weight: 500;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ line-height: 1.5;
+ margin-top: 10px; /* Add some space above submit */
+}
+#public-form button[type="submit"]:hover {
+ background-color: #2c373f;
+ box-shadow: var(--shadow-sm);
+}
+#public-form button[type="submit"]:active {
+ background-color: #1e2a31;
+}
+
+/* --- Lists (Forms & Submissions) --- */
+ul.styled-list {
+ list-style: none;
+ padding: 0;
+ margin-top: 20px; /* Space below heading/button */
+}
+
+ul.styled-list li {
+ background-color: #fcfcfc; /* Slightly off-white */
+ border: 1px solid var(--color-border);
+ padding: 12px 16px;
+ margin-bottom: 8px;
+ border-radius: var(--border-radius);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: background-color 0.2s ease;
+ font-size: 0.95rem;
+}
+
+ul.styled-list li:hover {
+ background-color: #f5f5f5;
+}
+
+ul.styled-list li button {
+ margin-left: 16px; /* Space between text and button */
+ /* Use smaller button style */
+ padding: 5px 10px;
+ font-size: 0.8rem;
+ /* Inherit base button colors or use secondary */
+ background-color: var(--color-surface);
+ color: var(--color-primary);
+ border: 1px solid var(--color-border);
+}
+ul.styled-list li button:hover {
+ background-color: #f8f8f8;
+ border-color: #d0d0d0;
+}
+
+/* Specific styling for submissions list items */
+ul.submissions li {
+ display: block; /* Allow pre tag to format */
+ background-color: var(--color-surface); /* White background for submissions */
+}
+
+ul.submissions li pre {
+ white-space: pre-wrap; /* Wrap long lines */
+ word-wrap: break-word; /* Break long words */
+ background-color: #f9f9f9; /* Light grey background for code block */
+ padding: 10px;
+ border-radius: var(--border-radius);
+ border: 1px solid var(--color-border);
+ font-size: 0.85rem;
+ color: var(--color-text);
+ max-height: 200px; /* Limit height */
+ overflow-y: auto; /* Add scroll if needed */
+}
+
+/* --- Status Area --- */
+.status {
+ padding: 12px 16px;
+ margin-bottom: 20px;
+ border-radius: var(--border-radius);
+ font-weight: 500;
+ border: 1px solid transparent;
+ display: none; /* Hide by default, JS shows it */
+}
+.status.success,
+.status.error {
+ display: block; /* Show when class is added */
+}
+
+.status.success {
+ background-color: var(--color-success-bg);
+ color: var(--color-success);
+ border-color: var(--color-success); /* Darker green border */
+}
+.status.error {
+ background-color: var(--color-error-bg);
+ color: var(--color-error);
+ border-color: var(--color-error); /* Darker red border */
+ white-space: pre-wrap; /* Allow multi-line errors */
+}
+
+/* --- Utility --- */
+.hidden {
+ display: none !important; /* Use !important to override potential inline styles if needed */
+}
+
+/* --- Responsive Adjustments (Basic) --- */
+@media (max-width: 768px) {
+ .container {
+ padding: 24px 16px;
+ }
+ h1.page-title {
+ font-size: 1.5rem;
+ }
+ h2.section-title {
+ font-size: 1.15rem;
+ }
+ ul.styled-list li {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+ ul.styled-list li button {
+ margin-left: 0;
+ align-self: flex-end; /* Move button to bottom right */
+ }
+ form .inline-form-group {
+ flex-direction: column;
+ align-items: stretch; /* Make elements full width */
+ }
+ form .inline-form-group button {
+ width: 100%; /* Make button full width */
+ }
+}
+
+@media (max-width: 576px) {
+ .content-card,
+ .section {
+ padding: 16px;
+ }
+ .button {
+ padding: 8px 14px;
+ font-size: 0.85rem;
+ }
+}
diff --git a/repomix-output.xml b/repomix-output.xml
deleted file mode 100644
index 2b1e3d2..0000000
--- a/repomix-output.xml
+++ /dev/null
@@ -1,1555 +0,0 @@
-This file is a merged representation of the entire codebase, combined into a single document by Repomix.
-
-
-This section contains a summary of this file.
-
-
-This file contains a packed representation of the entire repository's contents.
-It is designed to be easily consumable by AI systems for analysis, code review,
-or other automated processes.
-
-
-
-The content is organized as follows:
-1. This summary section
-2. Repository information
-3. Directory structure
-4. Repository files, each consisting of:
- - File path as an attribute
- - Full contents of the file
-
-
-
-- This file should be treated as read-only. Any changes should be made to the
- original repository files, not this packed version.
-- When processing this file, use the file path to distinguish
- between different files in the repository.
-- Be aware that this file may contain sensitive information. Handle it with
- the same level of security as you would the original repository.
-
-
-
-- Some files may have been excluded based on .gitignore rules and Repomix's configuration
-- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
-- Files matching patterns in .gitignore are excluded
-- Files matching default ignore patterns are excluded
-- Files are sorted by Git change count (files with more changes are at the bottom)
-
-
-
-
-
-
-
-
-
-.gitignore
-Cargo.toml
-src/auth.rs
-src/db.rs
-src/handlers.rs
-src/main.rs
-src/models.rs
-
-
-
-This section contains the contents of the repository's files.
-
-
-/target
-
-
-
-[package]
-name = "formies_be"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-actix-web = "4.0"
-rusqlite = { version = "0.29", features = ["bundled", "chrono"] }
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-uuid = { version = "1.0", features = ["v4"] }
-actix-files = "0.6"
-actix-cors = "0.6"
-env_logger = "0.10"
-log = "0.4"
-futures = "0.3"
-bcrypt = "0.13"
-anyhow = "1.0"
-dotenv = "0.15.0"
-chrono = { version = "0.4", features = ["serde"] }
-regex = "1"
-url = "2"
-
-
-
-// src/auth.rs
-use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; // Specific error types
-use actix_web::{
- dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest,
- HttpRequest, Result as ActixResult,
-};
-use chrono::{Duration, Utc}; // Import chrono for time checks
-use futures::future::{ready, Ready};
-use log; // Use the log crate
-use rusqlite::Connection;
-use std::sync::{Arc, Mutex};
-
-// Represents an authenticated user via token
-pub struct Auth {
- pub user_id: String,
-}
-
-impl FromRequest for Auth {
- // Use actix_web::Error for consistency in error handling within Actix
- type Error = ActixWebError;
- // Use Ready from futures 0.3
- type Future = Ready>;
-
- fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
- // Extract database connection pool from application data
- // Replace .expect() with proper error handling
- let db_data_result = req.app_data::>>>();
-
- let db_data = match db_data_result {
- Some(data) => data,
- None => {
- log::error!("Database connection missing in application data configuration.");
- return ready(Err(ErrorInternalServerError(
- "Internal server error (app configuration)",
- )));
- }
- };
-
- // Extract Authorization header
- let auth_header = req.headers().get(AUTHORIZATION);
-
- if let Some(auth_header_value) = auth_header {
- // Convert header value to string
- if let Ok(auth_str) = auth_header_value.to_str() {
- // Check if it starts with "Bearer "
- if auth_str.starts_with("Bearer ") {
- // Extract the token part
- let token = &auth_str[7..];
-
- // Lock the mutex to get access to the connection
- // Handle potential mutex poisoning explicitly
- let conn_guard = match db_data.lock() {
- Ok(guard) => guard,
- Err(poisoned) => {
- log::error!("Database mutex poisoned: {}", poisoned);
- // Return internal server error if mutex is poisoned
- return ready(Err(ErrorInternalServerError(
- "Internal server error (database lock)",
- )));
- }
- };
-
- // Validate the token against the database (now includes expiration check)
- match super::db::validate_token(&conn_guard, token) {
- // Token is valid and not expired, return Ok with Auth struct
- Ok(Some(user_id)) => {
- log::debug!("Token validated successfully for user_id: {}", user_id);
- ready(Ok(Auth { user_id }))
- }
- // Token is invalid, not found, or expired
- Ok(None) => {
- log::warn!("Invalid or expired token received"); // Avoid logging token
- ready(Err(ErrorUnauthorized("Invalid or expired token")))
- }
- // Database error during token validation
- Err(e) => {
- log::error!("Database error during token validation: {:?}", e);
- // Return Unauthorized to avoid leaking internal error details
- // Consider mapping specific DB errors if needed, but Unauthorized is generally safe
- ready(Err(ErrorUnauthorized("Token validation failed")))
- }
- }
- } else {
- // Header present but not "Bearer " format
- log::warn!("Invalid Authorization header format (not Bearer)");
- ready(Err(ErrorUnauthorized("Invalid token format")))
- }
- } else {
- // Header value contains invalid characters
- log::warn!("Authorization header contains invalid characters");
- ready(Err(ErrorUnauthorized("Invalid token value")))
- }
- } else {
- // Authorization header is missing
- log::warn!("Missing Authorization header");
- ready(Err(ErrorUnauthorized("Missing authorization token")))
- }
- }
-}
-
-
-
-// src/db.rs
-use anyhow::{anyhow, Context, Result as AnyhowResult};
-use bcrypt::{hash, verify, DEFAULT_COST};
-use chrono::{Duration as ChronoDuration, Utc}; // Use Utc for timestamps
-use log; // Use the log crate
-use rusqlite::{
- params, types::Value as RusqliteValue, Connection, OptionalExtension, Result as RusqliteResult,
-};
-use std::env;
-use uuid::Uuid;
-
-use crate::models;
-
-// Configurable token lifetime (e.g., from environment variable or default)
-const TOKEN_LIFETIME_HOURS: i64 = 24; // Default to 24 hours
-
-// Initialize the database connection and create tables if they don't exist
-pub fn init_db(database_url: &str) -> AnyhowResult {
- log::info!("Attempting to open or create database at: {}", database_url);
- let conn = Connection::open(database_url)
- .context(format!("Failed to open the database at {}", database_url))?;
-
- log::debug!("Creating 'users' table if not exists...");
- conn.execute(
- "CREATE TABLE IF NOT EXISTS users (
- id TEXT PRIMARY KEY,
- username TEXT NOT NULL UNIQUE,
- password TEXT NOT NULL, -- Stores bcrypt hashed password
- token TEXT UNIQUE, -- Stores the current session token (UUID)
- token_expires_at DATETIME -- Timestamp when the token expires
- )",
- [],
- )
- .context("Failed to create 'users' table")?;
-
- log::debug!("Creating 'forms' table if not exists...");
- // Storing complex form definitions as JSON blobs in TEXT columns is pragmatic
- // but sacrifices DB-level type safety and query capabilities. Ensure robust
- // application-level validation and consider backup strategies carefully.
- conn.execute(
- "CREATE TABLE IF NOT EXISTS forms (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- fields TEXT NOT NULL, -- Stores JSON definition of form fields
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- )",
- [],
- )
- .context("Failed to create 'forms' table")?;
-
- log::debug!("Creating 'submissions' table if not exists...");
- // Storing submission data as JSON blobs has similar tradeoffs as form fields.
- conn.execute(
- "CREATE TABLE IF NOT EXISTS submissions (
- id TEXT PRIMARY KEY,
- form_id TEXT NOT NULL,
- data TEXT NOT NULL, -- Stores JSON submission data
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (form_id) REFERENCES forms (id) ON DELETE CASCADE
- )",
- [],
- )
- .context("Failed to create 'submissions' table")?;
-
- // Setup the initial admin user if it doesn't exist, using environment variables
- setup_initial_admin(&conn).context("Failed to setup initial admin user")?;
-
- log::info!("Database initialization complete.");
- Ok(conn)
-}
-
-// Sets up the initial admin user from *required* environment variables if it doesn't exist
-fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> {
- // CRITICAL SECURITY CHANGE: Remove default credentials. Require env vars.
- let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME")
- .map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_USERNAME environment variable is not set."))?;
- let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD")
- .map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_PASSWORD environment variable is not set."))?;
-
- if initial_admin_username.is_empty() || initial_admin_password.is_empty() {
- return Err(anyhow!(
- "FATAL: INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD must not be empty."
- ));
- }
-
- // Check password complexity? (Optional enhancement)
-
- add_user_if_not_exists(conn, &initial_admin_username, &initial_admin_password)
- .context("Failed during initial admin user setup")?;
- Ok(())
-}
-
-// Adds a user with a hashed password if the username doesn't exist
-pub fn add_user_if_not_exists(
- conn: &Connection,
- username: &str,
- password: &str,
-) -> AnyhowResult {
- // Check if user already exists
- let user_exists: bool = conn
- .query_row(
- "SELECT EXISTS(SELECT 1 FROM users WHERE username = ?1)",
- params![username],
- |row| row.get::<_, i32>(0),
- )
- .context(format!("Failed to check existence of user '{}'", username))?
- == 1;
-
- if user_exists {
- log::debug!("User '{}' already exists, skipping creation.", username);
- return Ok(false); // User already exists, nothing added
- }
-
- // Generate a UUID for the new user
- let user_id = Uuid::new_v4().to_string();
-
- // Hash the password using bcrypt
- // Ensure the cost factor is appropriate for your security needs and hardware.
- // Higher cost means slower hashing and verification, but better resistance to brute-force.
- log::debug!(
- "Hashing password for user '{}' with cost {}",
- username,
- DEFAULT_COST
- );
- let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?;
-
- // Insert the new user (token and expiry are initially NULL)
- log::info!("Creating new user '{}' with ID: {}", username, user_id);
- conn.execute(
- "INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)",
- params![user_id, username, hashed_password],
- )
- .context(format!("Failed to insert user '{}'", username))?;
-
- Ok(true) // User was added
-}
-
-// Validate a session token and return the associated user ID if valid and not expired
-pub fn validate_token(conn: &Connection, token: &str) -> AnyhowResult
-
-
-// src/handlers.rs
-use crate::auth::Auth;
-use crate::models::{Form, LoginCredentials, LoginResponse, Submission};
-use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
-use anyhow::Context; // Import anyhow::Context for error chaining
-use log;
-use regex::Regex; // For pattern validation
-use rusqlite::{params, Connection};
-use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity
-use std::collections::HashMap;
-use std::error::Error as StdError;
-use std::sync::{Arc, Mutex};
-use uuid::Uuid;
-
-// --- Helper Function for Database Access ---
-
-// Gets a database connection from the request data, handling lock errors consistently.
-fn get_db_conn(
- db: &web::Data>>,
-) -> Result, ActixWebError> {
- db.lock().map_err(|poisoned| {
- log::error!("Database mutex poisoned: {}", poisoned);
- actix_web::error::ErrorInternalServerError("Internal database error (mutex lock)")
- })
-}
-
-// --- Helper Function for Validation ---
-
-/// Validates submission data against the form field definitions with enhanced checks.
-///
-/// Expected field definition properties:
-/// - `name`: string (required)
-/// - `type`: string (e.g., "string", "number", "boolean", "email", "url", "object", "array") (required)
-/// - `required`: boolean (optional, default: false)
-/// - `maxLength`: number (for "string" type)
-/// - `minLength`: number (for "string" type)
-/// - `min`: number (for "number" type)
-/// - `max`: number (for "number" type)
-/// - `pattern`: string (regex for "string", "email", "url" types)
-///
-/// Returns `Ok(())` if valid, or `Err(JsonValue)` containing validation errors.
-fn validate_submission_against_definition(
- submission_data: &JsonValue,
- form_definition_fields: &JsonValue,
-) -> Result<(), JsonValue> {
- let mut errors: HashMap = HashMap::new();
-
- // Ensure 'fields' in the definition is a JSON array
- let field_definitions = match form_definition_fields.as_array() {
- Some(defs) => defs,
- None => {
- log::error!(
- "Form definition 'fields' is not a JSON array. Def: {:?}",
- form_definition_fields
- );
- errors.insert(
- "_internal".to_string(),
- "Invalid form definition format (not an array)".to_string(),
- );
- return Err(json!({ "validation_errors": errors }));
- }
- };
-
- // Ensure the submission data is a JSON object
- let data_map = match submission_data.as_object() {
- Some(map) => map,
- None => {
- errors.insert(
- "_submission".to_string(),
- "Submission data must be a JSON object".to_string(),
- );
- return Err(json!({ "validation_errors": errors }));
- }
- };
-
- // Build a map of valid field names to their definitions from the definition for quick lookup
- let defined_field_names: HashMap> = field_definitions
- .iter()
- .filter_map(|val| val.as_object())
- .filter_map(|def| {
- def.get("name")
- .and_then(JsonValue::as_str)
- .map(|name| (name.to_string(), def))
- })
- .collect();
-
- // 1. Check for submitted fields that are NOT in the definition
- for submitted_key in data_map.keys() {
- if !defined_field_names.contains_key(submitted_key) {
- errors.insert(
- submitted_key.clone(),
- "Unexpected field submitted".to_string(),
- );
- }
- }
- // Exit early if unexpected fields were found
- if !errors.is_empty() {
- log::warn!("Submission validation failed: Unexpected fields submitted.");
- return Err(json!({ "validation_errors": errors }));
- }
-
- // 2. Iterate through each field definition and validate corresponding submitted data
- for (field_name, field_def) in &defined_field_names {
- // Extract properties using helper functions for clarity
- let field_type = field_def
- .get("type")
- .and_then(JsonValue::as_str)
- .unwrap_or("string"); // Default to "string" if type is missing or not a string
- let is_required = field_def
- .get("required")
- .and_then(JsonValue::as_bool)
- .unwrap_or(false); // Default to false if required is missing or not a boolean
- let min_length = field_def.get("minLength").and_then(JsonValue::as_u64);
- let max_length = field_def.get("maxLength").and_then(JsonValue::as_u64);
- let min_value = field_def.get("min").and_then(JsonValue::as_f64); // Use f64 for flexibility
- let max_value = field_def.get("max").and_then(JsonValue::as_f64);
- let pattern = field_def.get("pattern").and_then(JsonValue::as_str);
-
- match data_map.get(field_name) {
- Some(submitted_value) if !submitted_value.is_null() => {
- // Field is present and not null, perform type and constraint checks
- let mut type_error = None;
- let mut constraint_errors = vec![];
-
- match field_type {
- "string" | "email" | "url" => {
- if let Some(s) = submitted_value.as_str() {
- if let Some(min) = min_length {
- if (s.chars().count() as u64) < min {
- // Use chars().count() for UTF-8 correctness
- constraint_errors
- .push(format!("Must be at least {} characters long", min));
- }
- }
- if let Some(max) = max_length {
- if (s.chars().count() as u64) > max {
- constraint_errors.push(format!(
- "Must be no more than {} characters long",
- max
- ));
- }
- }
- if let Some(pat) = pattern {
- // Consider caching compiled Regex if performance is critical
- // and patterns are reused frequently across requests.
- match Regex::new(pat) {
- Ok(re) => {
- if !re.is_match(s) {
- constraint_errors.push(format!("Does not match required pattern"));
- }
- }
- Err(e) => log::warn!("Invalid regex pattern '{}' in form definition for field '{}': {}", pat, field_name, e), // Log regex compilation error
- }
- }
- // Specific checks for email/url
- if field_type == "email" {
- // Basic email regex (adjust for stricter needs or use a validation crate)
- // This regex is very basic and allows many technically invalid addresses.
- // Consider crates like `validator` for more robust validation.
- let email_regex =
- Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); // Safe unwrap for known valid regex
- if !email_regex.is_match(s) {
- constraint_errors
- .push("Must be a valid email address".to_string());
- }
- }
- if field_type == "url" {
- // Basic URL check (consider `url` crate for robustness)
- if url::Url::parse(s).is_err() {
- constraint_errors.push("Must be a valid URL".to_string());
- }
- }
- } else {
- type_error = Some(format!("Expected a string for '{}'", field_name));
- }
- }
- "number" => {
- // Use as_f64 for flexibility (handles integers and floats)
- if let Some(num) = submitted_value.as_f64() {
- if let Some(min) = min_value {
- if num < min {
- constraint_errors.push(format!("Must be at least {}", min));
- }
- }
- if let Some(max) = max_value {
- if num > max {
- constraint_errors.push(format!("Must be no more than {}", max));
- }
- }
- } else {
- type_error = Some(format!("Expected a number for '{}'", field_name));
- }
- }
- "boolean" => {
- if !submitted_value.is_boolean() {
- type_error = Some(format!(
- "Expected a boolean (true/false) for '{}'",
- field_name
- ));
- }
- }
- "object" => {
- if !submitted_value.is_object() {
- type_error =
- Some(format!("Expected a JSON object for '{}'", field_name));
- }
- // TODO: Could add deeper validation for object structure here if needed based on definition
- }
- "array" => {
- if !submitted_value.is_array() {
- type_error =
- Some(format!("Expected a JSON array for '{}'", field_name));
- }
- // TODO: Could add validation for array elements here if needed based on definition
- }
- _ => {
- // Log unsupported types during development/debugging if necessary
- log::trace!(
- "Unsupported field type '{}' encountered during validation for field '{}'. Treating as valid.",
- field_type,
- field_name
- );
- // Assume valid if type is not specifically handled or unknown
- }
- }
-
- // Record errors found for this field
- if let Some(err) = type_error {
- errors.insert(field_name.clone(), err);
- } else if !constraint_errors.is_empty() {
- // Combine multiple constraint errors if necessary
- errors.insert(field_name.clone(), constraint_errors.join("; "));
- }
- } // End check for present and non-null value
- Some(_) => {
- // Value is present but explicitly null (e.g., "fieldName": null)
- if is_required {
- errors.insert(
- field_name.clone(),
- "This field is required and cannot be null".to_string(),
- );
- }
- // Otherwise, null is considered a valid (empty) value for non-required fields
- }
- None => {
- // Field is missing entirely from the submission object
- if is_required {
- errors.insert(field_name.clone(), "This field is required".to_string());
- }
- // Missing is valid for non-required fields
- }
- } // End match data_map.get(field_name)
- } // End loop through field definitions
-
- // Check if any errors were collected
- if errors.is_empty() {
- Ok(()) // Validation passed
- } else {
- log::info!(
- "Submission validation failed with {} error(s).", // Log only the count for brevity
- errors.len()
- );
- // Return a JSON object containing the specific validation errors
- Err(json!({ "validation_errors": errors }))
- }
-}
-
-// Helper function to convert anyhow::Error to actix_web::Error
-fn anyhow_to_actix_error(e: anyhow::Error) -> ActixWebError {
- actix_web::error::ErrorInternalServerError(e.to_string())
-}
-
-// --- Public Handlers ---
-
-// POST /login
-pub async fn login(
- db: web::Data>>,
- creds: web::Json,
-) -> ActixResult {
- let db_conn = db.clone(); // Clone Arc for use in web::block
- let username = creds.username.clone();
- let password = creds.password.clone();
-
- // Wrap the blocking database operations in web::block
- let auth_result = web::block(move || {
- let conn = db_conn
- .lock()
- .map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?;
- crate::db::authenticate_user(&conn, &username, &password)
- })
- .await
- .map_err(|e| {
- log::error!("web::block error during authentication: {:?}", e);
- actix_web::error::ErrorInternalServerError("Authentication process failed (blocking error)")
- })?
- .map_err(anyhow_to_actix_error)?;
-
- match auth_result {
- Some(user_data) => {
- let db_conn_token = db.clone(); // Clone Arc again for token generation
- let user_id = user_data.id.clone();
-
- // Generate and store a new token within web::block
- let token = web::block(move || {
- let conn = db_conn_token
- .lock()
- .map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?;
- crate::db::generate_and_set_token_for_user(&conn, &user_id)
- })
- .await
- .map_err(|e| {
- log::error!("web::block error during token generation: {:?}", e);
- actix_web::error::ErrorInternalServerError(
- "Failed to complete login (token generation blocking error)",
- )
- })?
- .map_err(anyhow_to_actix_error)?;
-
- log::info!("Login successful for user_id: {}", user_data.id);
- Ok(HttpResponse::Ok().json(LoginResponse { token }))
- }
- None => {
- log::warn!("Login failed for username: {}", creds.username);
- // Return 401 Unauthorized for failed login attempts
- Err(actix_web::error::ErrorUnauthorized(
- "Invalid username or password",
- ))
- }
- }
-}
-
-// POST /logout
-pub async fn logout(
- db: web::Data>>,
- auth: Auth, // Requires authentication (extracts user_id from token)
-) -> ActixResult {
- log::info!("User {} requesting logout", auth.user_id);
- let db_conn = db.clone();
- let user_id = auth.user_id.clone();
-
- // Invalidate the token in the database within web::block
- web::block(move || {
- let conn = db_conn
- .lock()
- .map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?;
- crate::db::invalidate_token(&conn, &user_id)
- })
- .await
- .map_err(|e| {
- let user_id = auth.user_id.clone(); // Clone user_id again after the move
- log::error!(
- "web::block error during logout for user {}: {:?}",
- user_id,
- e
- );
- actix_web::error::ErrorInternalServerError("Logout failed (blocking error)")
- })?
- .map_err(anyhow_to_actix_error)?;
-
- log::info!("User {} logged out successfully", auth.user_id);
- Ok(HttpResponse::Ok().json(json!({ "message": "Logged out successfully" })))
-}
-
-// POST /forms/{form_id}/submissions
-pub async fn submit_form(
- db: web::Data>>,
- path: web::Path, // Extracts form_id from path
- submission_payload: web::Json, // Expect arbitrary JSON payload
-) -> ActixResult {
- let form_id = path.into_inner();
- let submission_data = submission_payload.into_inner(); // Get the JSON data
-
- // --- Stage 1: Fetch form definition (Read-only, can use shared lock) ---
- let form_definition = {
- // Acquire lock temporarily for the read operation
- let conn = get_db_conn(&db)?;
- match crate::db::get_form_definition(&conn, &form_id) {
- Ok(Some(form)) => form,
- Ok(None) => {
- log::warn!("Submission attempt for non-existent form_id: {}", form_id);
- return Err(actix_web::error::ErrorNotFound("Form not found"));
- }
- Err(e) => {
- log::error!("Failed to fetch form definition for {}: {:?}", form_id, e);
- return Err(actix_web::error::ErrorInternalServerError(
- "Could not retrieve form information",
- ));
- }
- }
- // Lock is released here when 'conn' goes out of scope
- };
-
- // --- Stage 2: Validate submission against definition (CPU-bound, no DB lock needed) ---
- if let Err(validation_errors) =
- validate_submission_against_definition(&submission_data, &form_definition.fields)
- {
- log::warn!(
- "Submission validation failed for form_id {}. Errors: {:?}", // Log actual errors if needed (might be verbose)
- form_id,
- validation_errors
- );
- // Return 400 Bad Request with validation error details
- return Ok(HttpResponse::BadRequest().json(validation_errors));
- }
-
- // --- Stage 3: Serialize validated data and Insert submission (Write operation, use web::block) ---
- let submission_json = match serde_json::to_string(&submission_data) {
- Ok(json_string) => json_string,
- Err(e) => {
- log::error!(
- "Failed to serialize validated submission data for form {}: {}",
- form_id,
- e
- );
- return Err(actix_web::error::ErrorInternalServerError(
- "Failed to process submission data internally",
- ));
- }
- };
-
- let db_conn_write = db.clone(); // Clone Arc for the blocking operation
- let form_id_clone = form_id.clone(); // Clone for closure
- let submission_id = Uuid::new_v4().to_string(); // Generate unique ID for the submission
- let submission_id_clone = submission_id.clone(); // Clone for closure
-
- web::block(move || {
- let conn = db_conn_write.lock().map_err(|_| {
- anyhow::anyhow!("Database mutex poisoned during submission insert lock")
- })?;
- conn.execute(
- "INSERT INTO submissions (id, form_id, data) VALUES (?1, ?2, ?3)",
- params![submission_id_clone, form_id_clone, submission_json],
- )
- .context(format!(
- "Failed to insert submission for form {}",
- form_id_clone
- ))
- .map_err(anyhow::Error::from)
- })
- .await
- .map_err(|e| {
- log::error!(
- "web::block error during submission insertion for form {}: {:?}",
- form_id,
- e
- );
- actix_web::error::ErrorInternalServerError("Failed to save submission (blocking error)")
- })?
- .map_err(anyhow_to_actix_error)?;
-
- log::info!(
- "Successfully inserted submission {} for form_id {}",
- submission_id,
- form_id
- );
- // Return 200 OK with the new submission ID
- Ok(HttpResponse::Ok().json(json!({ "submission_id": submission_id })))
-}
-
-// --- Protected Handlers (Require Auth) ---
-
-// POST /forms
-pub async fn create_form(
- db: web::Data>>,
- auth: Auth, // Authentication check via Auth extractor
- form_payload: web::Json
-
-
-// src/main.rs
-use actix_cors::Cors;
-use actix_files as fs;
-use actix_web::{http::header, middleware::Logger, web, App, HttpServer}; // Added Logger explicitly
-use dotenv::dotenv;
-use log;
-use std::env;
-use std::io::Result as IoResult; // Alias for clarity
-use std::process;
-use std::sync::{Arc, Mutex};
-
-// Import modules
-mod auth;
-mod db;
-mod handlers;
-mod models;
-
-#[actix_web::main]
-async fn main() -> IoResult<()> {
- dotenv().ok(); // Load .env file
-
- // Initialize logger (using RUST_LOG env var)
- env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
-
- // --- Configuration (Environment Variables) ---
- // CRITICAL: Database URL is required
- let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| {
- log::warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'.");
- "form_data.db".to_string()
- });
- // CRITICAL: Bind address is required
- let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| {
- log::warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'.");
- "127.0.0.1:8080".to_string()
- });
- // CRITICAL: Initial admin credentials (checked in db::init_db)
- // let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME").expect("Missing INITIAL_ADMIN_USERNAME");
- // let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD").expect("Missing INITIAL_ADMIN_PASSWORD");
- // OPTIONAL: Allowed origin for CORS
- let allowed_origin = env::var("ALLOWED_ORIGIN").ok(); // Use ok() to make it optional
-
- log::info!(" --- Formies Backend Configuration ---");
- log::info!("Required Environment Variables:");
- log::info!(" - DATABASE_URL (Current: {})", database_url);
- log::info!(" - BIND_ADDRESS (Current: {})", bind_address);
- log::info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)");
- log::info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)");
- log::info!("Optional Environment Variables:");
- if let Some(ref origin) = allowed_origin {
- log::info!(" - ALLOWED_ORIGIN (Set: {})", origin);
- } else {
- log::warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive, potentially blocking browser access. Set to your frontend URL (e.g., http://localhost:5173 or https://yourdomain.com).");
- }
- log::info!(" - RUST_LOG (e.g., 'info,formies_be=debug')");
- log::info!(" --- End Configuration ---");
-
- // Initialize database connection
- let db_connection = match db::init_db(&database_url) {
- Ok(conn) => conn,
- Err(e) => {
- // Specific check for missing admin credentials error
- if e.to_string().contains("INITIAL_ADMIN_USERNAME")
- || e.to_string().contains("INITIAL_ADMIN_PASSWORD")
- {
- log::error!("FATAL: {}", e);
- log::error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables.");
- } else {
- log::error!(
- "FATAL: Failed to initialize database at {}: {:?}",
- database_url,
- e
- );
- }
- process::exit(1); // Exit if DB initialization fails
- }
- };
-
- // Wrap connection in Arc> for thread-safe sharing
- let db_data = web::Data::new(Arc::new(Mutex::new(db_connection)));
-
- log::info!("Starting server at http://{}", bind_address);
-
- HttpServer::new(move || {
- // Clone shared state for the closure
- let db_data_clone = db_data.clone();
- let allowed_origin_clone = allowed_origin.clone();
-
- // Configure CORS
- let cors = match allowed_origin_clone {
- Some(origin) => {
- log::info!("Configuring CORS for specific origin: {}", origin);
- Cors::default()
- .allowed_origin(&origin) // Allow only the specified origin
- .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
- .allowed_headers(vec![
- header::AUTHORIZATION,
- header::ACCEPT,
- header::CONTENT_TYPE,
- header::ORIGIN, // Add Origin header if needed
- header::ACCESS_CONTROL_REQUEST_METHOD,
- header::ACCESS_CONTROL_REQUEST_HEADERS,
- ])
- .supports_credentials()
- .max_age(3600)
- }
- None => {
- // Default restrictive CORS: No origin allowed explicitly.
- // This will likely block browser requests unless the browser and server are on the same origin.
- log::warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set.");
- Cors::default() // No allowed_origin set
- .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
- .allowed_headers(vec![
- header::AUTHORIZATION,
- header::ACCEPT,
- header::CONTENT_TYPE,
- header::ORIGIN,
- header::ACCESS_CONTROL_REQUEST_METHOD,
- header::ACCESS_CONTROL_REQUEST_HEADERS,
- ])
- .supports_credentials()
- .max_age(3600)
- // DO NOT use allow_any_origin() unless you fully understand the security implications.
- }
- };
-
- App::new()
- .wrap(cors) // Apply CORS middleware
- .wrap(Logger::default()) // Add request logging (default format)
- .app_data(db_data_clone) // Share database connection pool
- // --- API Routes ---
- .service(
- web::scope("/api") // Group API routes under /api
- // --- Public Routes ---
- .route("/login", web::post().to(handlers::login))
- .route(
- "/forms/{form_id}/submissions",
- web::post().to(handlers::submit_form),
- )
- // --- Protected Routes (using Auth extractor) ---
- .route("/logout", web::post().to(handlers::logout)) // Added logout
- .route("/forms", web::post().to(handlers::create_form))
- .route("/forms", web::get().to(handlers::get_forms))
- .route(
- "/forms/{form_id}/submissions",
- web::get().to(handlers::get_submissions),
- ),
- )
- // --- Static Files (Serve Frontend - Optional) ---
- // Assumes frontend build output is in ../frontend/dist
- // Register this LAST to avoid conflicts with API routes
- .service(
- fs::Files::new("/", "../frontend/dist/")
- .index_file("index.html")
- .use_last_modified(true)
- // Optional: Add a fallback to index.html for SPA routing
- .default_handler(
- fs::NamedFile::open("../frontend/dist/index.html").unwrap_or_else(|_| {
- log::error!("Fallback file not found: ../frontend/dist/index.html");
- process::exit(1); // Exit if fallback file is missing
- }), // Handle error explicitly
- ),
- )
- })
- .bind(&bind_address)?
- .run()
- .await
-}
-
-
-
-// src/models.rs
-use serde::{Deserialize, Serialize};
-// Consider adding chrono for DateTime types if needed in responses
-// use chrono::{DateTime, Utc};
-
-// Represents the structure for defining a form
-#[derive(Serialize, Deserialize, Debug, Clone)]
-pub struct Form {
- #[serde(skip_serializing_if = "Option::is_none")]
- pub id: Option,
- pub name: String,
- /// Stores the structure defining the form fields.
- /// Expected to be a JSON array of field definition objects.
- /// Example field definition object:
- /// ```json
- /// {
- /// "name": "email", // String, required: Unique identifier for the field
- /// "type": "email", // String, required: "string", "number", "boolean", "email", "url", "object", "array"
- /// "label": "Email Address", // String, optional: User-friendly label
- /// "required": true, // Boolean, optional (default: false): If the field must have a value
- /// "placeholder": "you@example.com", // String, optional: Placeholder text
- /// "minLength": 5, // Number, optional: Minimum length for strings
- /// "maxLength": 100, // Number, optional: Maximum length for strings
- /// "min": 0, // Number, optional: Minimum value for numbers
- /// "max": 100, // Number, optional: Maximum value for numbers
- /// "pattern": "^\\S+@\\S+\\.\\S+$" // String, optional: Regex pattern for strings (e.g., email, url types might use this implicitly or explicitly)
- /// // Add other properties like "options" for select/radio, etc.
- /// }
- /// ```
- pub fields: serde_json::Value,
- // Optional: Add created_at if needed in API responses
- // pub created_at: Option>,
-}
-
-// Represents a single submission for a specific form
-#[derive(Serialize, Deserialize, Debug, Clone)]
-pub struct Submission {
- pub id: String,
- pub form_id: String,
- /// Stores the data submitted by the user.
- /// Expected to be a JSON object where keys are field names (`name`) from the form definition's `fields` array.
- /// Example: `{ "email": "user@example.com", "age": 30 }`
- pub data: serde_json::Value,
- // Optional: Add created_at if needed in API responses
- // pub created_at: Option>,
-}
-
-// Used for the /login endpoint request body
-#[derive(Debug, Serialize, Deserialize)]
-pub struct LoginCredentials {
- pub username: String,
- pub password: String,
-}
-
-// Used for the /login endpoint response body
-#[derive(Debug, Serialize, Deserialize)]
-pub struct LoginResponse {
- pub token: String, // The session token (UUID)
-}
-
-// Used internally to represent a user fetched from the DB for authentication check
-// Not serialized, only used within db.rs and handlers.rs
-#[derive(Debug)]
-pub struct UserAuthData {
- pub id: String,
- pub hashed_password: String,
- // Note: Token and expiry are handled separately and not needed in this specific struct
-}
-
-// --- Custom Application Error (Optional but Recommended for Consistency) ---
-// Although not fully integrated in this pass to minimize changes,
-// this shows the structure for future improvement.
-
-// use actix_web::{ResponseError, http::StatusCode};
-// use std::fmt;
-
-// #[derive(Debug)]
-// pub enum AppError {
-// DatabaseError(anyhow::Error),
-// ConfigError(String),
-// ValidationError(serde_json::Value), // Store the validation errors JSON
-// NotFound(String),
-// Unauthorized(String),
-// InternalError(String),
-// BlockingError(String),
-// }
-
-// impl fmt::Display for AppError {
-// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-// match self {
-// AppError::DatabaseError(e) => write!(f, "Database error: {}", e),
-// AppError::ConfigError(s) => write!(f, "Configuration error: {}", s),
-// AppError::ValidationError(_) => write!(f, "Validation failed"),
-// AppError::NotFound(s) => write!(f, "Not found: {}", s),
-// AppError::Unauthorized(s) => write!(f, "Unauthorized: {}", s),
-// AppError::InternalError(s) => write!(f, "Internal server error: {}", s),
-// AppError::BlockingError(s) => write!(f, "Blocking operation error: {}", s),
-// }
-// }
-// }
-
-// impl ResponseError for AppError {
-// fn status_code(&self) -> StatusCode {
-// match self {
-// AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
-// AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR,
-// AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
-// AppError::NotFound(_) => StatusCode::NOT_FOUND,
-// AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
-// AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
-// AppError::BlockingError(_) => StatusCode::INTERNAL_SERVER_ERROR,
-// }
-// }
-
-// fn error_response(&self) -> HttpResponse {
-// let status = self.status_code();
-// let error_json = match self {
-// AppError::ValidationError(errors) => errors.clone(),
-// // Provide a generic error structure for others
-// _ => json!({ "error": status.canonical_reason().unwrap_or("Unknown Error"), "message": self.to_string() }),
-// };
-
-// HttpResponse::build(status).json(error_json)
-// }
-// }
-
-// // Implement From traits to convert other errors into AppError easily
-// impl From for AppError {
-// fn from(err: anyhow::Error) -> Self {
-// // Basic conversion, could add more context analysis here
-// AppError::DatabaseError(err)
-// }
-// }
-// impl From for AppError {
-// fn from(err: actix_web::error::BlockingError) -> Self {
-// AppError::BlockingError(err.to_string())
-// }
-//}
-// // Add From, From, etc. as needed
-
-
-
diff --git a/src/auth.rs b/src/auth.rs
index 34645ca..75b7620 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -1,14 +1,14 @@
// src/auth.rs
+use super::AppState;
use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; // Specific error types
use actix_web::{
dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest,
- HttpRequest, Result as ActixResult,
+ HttpRequest,
};
-use chrono::{Duration, Utc}; // Import chrono for time checks
use futures::future::{ready, Ready};
use log; // Use the log crate
use rusqlite::Connection;
-use std::sync::{Arc, Mutex};
+use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely)
// Represents an authenticated user via token
pub struct Auth {
@@ -23,11 +23,13 @@ impl FromRequest for Auth {
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
// Extract database connection pool from application data
- // Replace .expect() with proper error handling
- let db_data_result = req.app_data::>>>();
+ // Extract the *whole* AppState first
+ let app_state_result = req.app_data::>();
- let db_data = match db_data_result {
- Some(data) => data,
+ // Get the Arc> from AppState
+ let db_arc_mutex = match app_state_result {
+ // Access the 'db' field within the AppState
+ Some(data) => data.db.clone(), // Clone the Arc, not the Mutex or Connection
None => {
log::error!("Database connection missing in application data configuration.");
return ready(Err(ErrorInternalServerError(
@@ -49,7 +51,7 @@ impl FromRequest for Auth {
// Lock the mutex to get access to the connection
// Handle potential mutex poisoning explicitly
- let conn_guard = match db_data.lock() {
+ let conn_guard = match db_arc_mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => {
log::error!("Database mutex poisoned: {}", poisoned);
diff --git a/src/db.rs b/src/db.rs
index a9e9f30..2a52f67 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -3,9 +3,7 @@ use anyhow::{anyhow, Context, Result as AnyhowResult};
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{Duration as ChronoDuration, Utc}; // Use Utc for timestamps
use log; // Use the log crate
-use rusqlite::{
- params, types::Value as RusqliteValue, Connection, OptionalExtension, Result as RusqliteResult,
-};
+use rusqlite::{params, Connection, OptionalExtension};
use std::env;
use uuid::Uuid;
@@ -34,22 +32,42 @@ pub fn init_db(database_url: &str) -> AnyhowResult {
.context("Failed to create 'users' table")?;
log::debug!("Creating 'forms' table if not exists...");
- // Storing complex form definitions as JSON blobs in TEXT columns is pragmatic
- // but sacrifices DB-level type safety and query capabilities. Ensure robust
- // application-level validation and consider backup strategies carefully.
conn.execute(
"CREATE TABLE IF NOT EXISTS forms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fields TEXT NOT NULL, -- Stores JSON definition of form fields
+ notify_email TEXT, -- Optional email address for notifications
+ notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.context("Failed to create 'forms' table")?;
+ // Add notify_email column if it doesn't exist (for backward compatibility)
+ match conn.execute("ALTER TABLE forms ADD COLUMN notify_email TEXT", []) {
+ Ok(_) => log::info!("Added notify_email column to forms table"),
+ Err(e) => {
+ if !e.to_string().contains("duplicate column name") {
+ return Err(anyhow!("Failed to add notify_email column: {}", e));
+ }
+ // If it already exists, that's fine
+ }
+ }
+
+ // Add notify_ntfy_topic column if it doesn't exist (for backward compatibility)
+ match conn.execute("ALTER TABLE forms ADD COLUMN notify_ntfy_topic TEXT", []) {
+ Ok(_) => log::info!("Added notify_ntfy_topic column to forms table"),
+ Err(e) => {
+ if !e.to_string().contains("duplicate column name") {
+ return Err(anyhow!("Failed to add notify_ntfy_topic column: {}", e));
+ }
+ // If it already exists, that's fine
+ }
+ }
+
log::debug!("Creating 'submissions' table if not exists...");
- // Storing submission data as JSON blobs has similar tradeoffs as form fields.
conn.execute(
"CREATE TABLE IF NOT EXISTS submissions (
id TEXT PRIMARY KEY,
@@ -250,53 +268,89 @@ 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
> {
let mut stmt = conn
- .prepare("SELECT id, name, fields FROM forms WHERE id = ?1")
- .context("Failed to prepare statement for getting form definition")?;
+ .prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1")
+ .context("Failed to prepare query for fetching form")?;
- let form_option = stmt
+ let result = stmt
.query_row(params![form_id], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let fields_str: String = row.get(2)?;
+ let notify_email: Option = row.get(3)?;
+ let notify_ntfy_topic: Option = row.get(4)?; // Get the new field
+ let created_at: chrono::DateTime = row.get(5)?;
- // Ensure fields can be parsed as valid JSON Value
- let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| {
- // Log clearly that this is a data integrity issue
- log::error!(
- "Database integrity error: Failed to parse 'fields' JSON for form_id {}: {}. Content: '{}'",
- id, e, fields_str // Log content if not too large/sensitive
- );
+ // Parse the fields JSON string
+ let fields = serde_json::from_str(&fields_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
- 2,
+ 2, // Index of 'fields' column
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
- // **Basic check**: Ensure fields is an array (common pattern for form definitions)
- if !fields.is_array() {
- log::error!(
- "Database integrity error: 'fields' column for form_id {} is not a JSON array.",
- id
- );
- return Err(rusqlite::Error::FromSqlConversionFailure(
- 2,
- rusqlite::types::Type::Text,
- "Form fields definition is not a valid JSON array".into(),
- ));
- }
-
Ok(models::Form {
id: Some(id),
name,
fields,
+ notify_email,
+ notify_ntfy_topic, // Include the new field
+ created_at,
})
})
- .optional() // Handle case where form_id doesn't exist
- .context(format!(
- "Failed to execute query for form definition with id {}",
- form_id
- ))?;
+ .optional()
+ .context(format!("Failed to fetch form with ID: {}", form_id))?;
- Ok(form_option)
+ Ok(result)
+}
+
+// Add a function to save a form
+impl models::Form {
+ pub fn save(&self, conn: &Connection) -> AnyhowResult<()> {
+ let id = self
+ .id
+ .clone()
+ .unwrap_or_else(|| Uuid::new_v4().to_string());
+ 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)
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name,
+ fields = excluded.fields,
+ notify_email = excluded.notify_email,
+ notify_ntfy_topic = excluded.notify_ntfy_topic", // Update the new field on conflict
+ params![
+ id,
+ self.name,
+ fields_json,
+ self.notify_email,
+ self.notify_ntfy_topic, // Add the new field to params
+ self.created_at
+ ],
+ )?;
+
+ Ok(())
+ }
+
+ pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult {
+ get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id))
+ // Added ID to error
+ }
+}
+
+// Add a function to save a submission
+impl models::Submission {
+ pub fn save(&self, conn: &Connection) -> AnyhowResult<()> {
+ let data_json = serde_json::to_string(&self.data)?;
+
+ conn.execute(
+ "INSERT INTO submissions (id, form_id, data, created_at)
+ VALUES (?1, ?2, ?3, ?4)",
+ params![self.id, self.form_id, data_json, self.created_at],
+ )?;
+
+ Ok(())
+ }
}
diff --git a/src/handlers.rs b/src/handlers.rs
index 05bd313..2a00411 100644
--- a/src/handlers.rs
+++ b/src/handlers.rs
@@ -1,29 +1,16 @@
-// src/handlers.rs
use crate::auth::Auth;
use crate::models::{Form, LoginCredentials, LoginResponse, Submission};
+use crate::AppState;
use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
-use anyhow::Context; // Import anyhow::Context for error chaining
+use chrono; // Only import the module since we use it qualified
use log;
use regex::Regex; // For pattern validation
use rusqlite::{params, Connection};
use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity
use std::collections::HashMap;
-use std::error::Error as StdError;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
-// --- Helper Function for Database Access ---
-
-// Gets a database connection from the request data, handling lock errors consistently.
-fn get_db_conn(
- db: &web::Data>>,
-) -> Result, ActixWebError> {
- db.lock().map_err(|poisoned| {
- log::error!("Database mutex poisoned: {}", poisoned);
- actix_web::error::ErrorInternalServerError("Internal database error (mutex lock)")
- })
-}
-
// --- Helper Function for Validation ---
/// Validates submission data against the form field definitions with enhanced checks.
@@ -274,16 +261,18 @@ fn anyhow_to_actix_error(e: anyhow::Error) -> ActixWebError {
// POST /login
pub async fn login(
- db: web::Data>>,
+ app_state: web::Data, // Expect AppState like other handlers
creds: web::Json,
) -> ActixResult {
- let db_conn = db.clone(); // Clone Arc for use in web::block
+ // Clone the Arc> from AppState
+ let db_conn_arc = app_state.db.clone();
let username = creds.username.clone();
let password = creds.password.clone();
// Wrap the blocking database operations in web::block
let auth_result = web::block(move || {
- let conn = db_conn
+ // Use the cloned Arc here
+ let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?;
crate::db::authenticate_user(&conn, &username, &password)
@@ -297,12 +286,14 @@ pub async fn login(
match auth_result {
Some(user_data) => {
- let db_conn_token = db.clone(); // Clone Arc again for token generation
+ // Clone Arc again for token generation, using the AppState db field
+ let db_conn_token_arc = app_state.db.clone();
let user_id = user_data.id.clone();
// Generate and store a new token within web::block
let token = web::block(move || {
- let conn = db_conn_token
+ // Use the cloned Arc here
+ let conn = db_conn_token_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?;
crate::db::generate_and_set_token_for_user(&conn, &user_id)
@@ -331,26 +322,26 @@ pub async fn login(
// POST /logout
pub async fn logout(
- db: web::Data>>,
- auth: Auth, // Requires authentication (extracts user_id from token)
+ app_state: web::Data, // Expect AppState
+ auth: Auth, // Requires authentication (extracts user_id from token)
) -> ActixResult {
log::info!("User {} requesting logout", auth.user_id);
- let db_conn = db.clone();
+ let db_conn_arc = app_state.db.clone(); // Get db from AppState
let user_id = auth.user_id.clone();
// Invalidate the token in the database within web::block
web::block(move || {
- let conn = db_conn
+ let conn = db_conn_arc // Use the cloned Arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?;
crate::db::invalidate_token(&conn, &user_id)
})
.await
.map_err(|e| {
- let user_id = auth.user_id.clone(); // Clone user_id again after the move
+ // Use the original auth.user_id here as user_id moved into the block
log::error!(
"web::block error during logout for user {}: {:?}",
- user_id,
+ auth.user_id,
e
);
actix_web::error::ErrorInternalServerError("Logout failed (blocking error)")
@@ -363,274 +354,205 @@ pub async fn logout(
// POST /forms/{form_id}/submissions
pub async fn submit_form(
- db: web::Data>>,
+ app_state: web::Data,
path: web::Path, // Extracts form_id from path
submission_payload: web::Json, // Expect arbitrary JSON payload
) -> ActixResult {
let form_id = path.into_inner();
- let submission_data = submission_payload.into_inner(); // Get the JSON data
+ let conn = app_state.db.lock().map_err(|e| {
+ log::error!("Failed to acquire database lock: {}", e);
+ actix_web::error::ErrorInternalServerError("Database error")
+ })?;
- // --- Stage 1: Fetch form definition (Read-only, can use shared lock) ---
- let form_definition = {
- // Acquire lock temporarily for the read operation
- let conn = get_db_conn(&db)?;
- match crate::db::get_form_definition(&conn, &form_id) {
- Ok(Some(form)) => form,
- Ok(None) => {
- log::warn!("Submission attempt for non-existent form_id: {}", form_id);
- return Err(actix_web::error::ErrorNotFound("Form not found"));
- }
- Err(e) => {
- log::error!("Failed to fetch form definition for {}: {:?}", form_id, e);
- return Err(actix_web::error::ErrorInternalServerError(
- "Could not retrieve form information",
- ));
- }
- }
- // Lock is released here when 'conn' goes out of scope
- };
+ // Get form definition
+ let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?;
- // --- Stage 2: Validate submission against definition (CPU-bound, no DB lock needed) ---
+ // Validate submission against form definition
if let Err(validation_errors) =
- validate_submission_against_definition(&submission_data, &form_definition.fields)
+ validate_submission_against_definition(&submission_payload, &form.fields)
{
- log::warn!(
- "Submission validation failed for form_id {}. Errors: {:?}", // Log actual errors if needed (might be verbose)
- form_id,
- validation_errors
- );
- // Return 400 Bad Request with validation error details
return Ok(HttpResponse::BadRequest().json(validation_errors));
}
- // --- Stage 3: Serialize validated data and Insert submission (Write operation, use web::block) ---
- let submission_json = match serde_json::to_string(&submission_data) {
- Ok(json_string) => json_string,
- Err(e) => {
- log::error!(
- "Failed to serialize validated submission data for form {}: {}",
- form_id,
- e
- );
- return Err(actix_web::error::ErrorInternalServerError(
- "Failed to process submission data internally",
- ));
- }
+ // Create submission record
+ let submission = Submission {
+ id: Uuid::new_v4().to_string(),
+ form_id: form_id.clone(),
+ data: submission_payload.into_inner(),
+ created_at: chrono::Utc::now(),
};
- let db_conn_write = db.clone(); // Clone Arc for the blocking operation
- let form_id_clone = form_id.clone(); // Clone for closure
- let submission_id = Uuid::new_v4().to_string(); // Generate unique ID for the submission
- let submission_id_clone = submission_id.clone(); // Clone for closure
+ // Save submission to database
+ submission.save(&conn).map_err(|e| {
+ log::error!("Failed to save submission: {}", e);
+ actix_web::error::ErrorInternalServerError("Failed to save submission")
+ })?;
- web::block(move || {
- let conn = db_conn_write.lock().map_err(|_| {
- anyhow::anyhow!("Database mutex poisoned during submission insert lock")
- })?;
- conn.execute(
- "INSERT INTO submissions (id, form_id, data) VALUES (?1, ?2, ?3)",
- params![submission_id_clone, form_id_clone, submission_json],
- )
- .context(format!(
- "Failed to insert submission for form {}",
- form_id_clone
- ))
- .map_err(anyhow::Error::from)
- })
- .await
- .map_err(|e| {
- log::error!(
- "web::block error during submission insertion for form {}: {:?}",
- form_id,
- e
+ // 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()
);
- actix_web::error::ErrorInternalServerError("Failed to save submission (blocking error)")
- })?
- .map_err(anyhow_to_actix_error)?;
- log::info!(
- "Successfully inserted submission {} for form_id {}",
- submission_id,
- form_id
- );
- // Return 200 OK with the new submission ID
- Ok(HttpResponse::Ok().json(json!({ "submission_id": submission_id })))
+ 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);
+ }
+
+ // Also send ntfy notification if configured (sends to the global topic)
+ 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_title,
+ &ntfy_message,
+ Some(3), // Medium priority
+ ) {
+ log::warn!("Failed to send ntfy notification (global topic): {}", e);
+ }
+ }
+ }
+ }
+
+ Ok(HttpResponse::Created().json(json!({
+ "message": "Submission received",
+ "submission_id": submission.id
+ })))
}
-// --- Protected Handlers (Require Auth) ---
-
// POST /forms
pub async fn create_form(
- db: web::Data>>,
- auth: Auth, // Authentication check via Auth extractor
- form_payload: web::Json
,
+ app_state: web::Data,
+ _auth: Auth, // Authentication check via Auth extractor
+ payload: web::Json,
) -> ActixResult {
- log::info!(
- "User {} attempting to create form: {}",
- auth.user_id,
- form_payload.name
- );
+ let payload = payload.into_inner();
- let mut form = form_payload.into_inner();
- // Generate a new UUID for the form if not provided (or overwrite if provided)
- let form_id = form.id.unwrap_or_else(|| Uuid::new_v4().to_string());
- form.id = Some(form_id.clone()); // Ensure the form object has the ID
+ // 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();
- // Basic structural validation: Ensure 'fields' is a JSON array before serialization/saving
- if !form.fields.is_array() {
- log::error!(
- "User {} attempted to create form '{}' ('{}') where 'fields' is not a JSON array.",
- auth.user_id,
- form.name,
- form_id
- );
+ let fields = payload["fields"].clone();
+ if !fields.is_array() {
return Err(actix_web::error::ErrorBadRequest(
- "Form 'fields' must be a valid JSON array.",
+ "'fields' must be a JSON array",
));
}
- // TODO: Add deeper validation of the 'fields' structure itself if needed
- // e.g., check if each element in 'fields' is an object with 'name' and 'type'.
- // Serialize the fields part to JSON string for DB storage
- let fields_json = match serde_json::to_string(&form.fields) {
- Ok(json_str) => json_str,
- Err(e) => {
- log::error!(
- "Failed to serialize form fields for form '{}' ('{}') by user {}: {}",
- form.name,
- form_id,
- auth.user_id,
- e
- );
- return Err(actix_web::error::ErrorInternalServerError(
- "Failed to process form fields internally",
- ));
- }
+ 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(),
};
- // Clone data needed for the blocking database operation
- let db_conn = db.clone();
- // let form_id = form_id; // Already have it from above
- let form_name = form.name.clone();
- let user_id = auth.user_id.clone(); // For logging inside block if needed
+ // 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")
+ })?;
- // Insert the form using web::block for the blocking DB write
- web::block(move || {
- let conn = db_conn
- .lock()
- .map_err(|_| anyhow::anyhow!("Database mutex poisoned during form creation lock"))?;
- conn.execute(
- // Consider adding user_id to the forms table if forms are user-specific
- "INSERT INTO forms (id, name, fields) VALUES (?1, ?2, ?3)",
- params![form_id, form_name, fields_json],
- )
- .context("Failed to insert new form into database")
- .map_err(anyhow::Error::from)
- })
- .await
- .map_err(|e| {
- log::error!(
- "web::block error during form creation by user {}: {:?}",
- auth.user_id,
- e
- );
- actix_web::error::ErrorInternalServerError("Failed to create form (blocking error)")
- })?
- .map_err(anyhow_to_actix_error)?;
+ form.save(&conn).map_err(|e| {
+ log::error!("Failed to save form: {}", e);
+ actix_web::error::ErrorInternalServerError("Failed to save form")
+ })?;
- log::info!(
- "Successfully created form '{}' with id {} by user {}",
- form.name,
- form.id.as_ref().unwrap(), // Safe unwrap as we set it
- auth.user_id
- );
- // Return 200 OK with the newly created form object (including its ID)
- Ok(HttpResponse::Ok().json(form))
+ Ok(HttpResponse::Created().json(form))
}
// GET /forms
pub async fn get_forms(
- db: web::Data>>,
+ app_state: web::Data,
auth: Auth, // Requires authentication
) -> ActixResult {
log::info!("User {} requesting list of forms", auth.user_id);
- let db_conn = db.clone();
- let user_id = auth.user_id.clone(); // Clone for logging context if needed inside block
- // Wrap DB query in web::block as it might be slow with many forms or complex parsing
- let forms_result = web::block(move || {
- let conn = db_conn
- .lock()
- .map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_forms lock"))?;
+ let conn = app_state.db.lock().map_err(|e| {
+ log::error!("Failed to acquire database lock: {}", e);
+ actix_web::error::ErrorInternalServerError("Database error")
+ })?;
- let mut stmt = conn
- .prepare("SELECT id, name, fields FROM forms")
- .context("Failed to prepare statement for getting forms")?;
+ 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 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 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 = row.get(3)?;
+ let notify_ntfy_topic: Option = row.get(4)?;
+ let created_at: chrono::DateTime = row.get(5)?;
- // Parse the 'fields' JSON string. If it fails, log the error and skip the row.
- let fields: serde_json::Value = match serde_json::from_str(&fields_str) {
- Ok(json_value) => json_value,
- Err(e) => {
- // Log the data integrity issue clearly
- log::error!(
- "DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.",
- id, e
- );
- // Return a special error that `filter_map` below can catch,
- // without failing the entire query_map.
- // Using a specific rusqlite error type here is okay.
- return Err(rusqlite::Error::FromSqlConversionFailure(
- 2, // Column index
- rusqlite::types::Type::Text,
- Box::new(e) // Box the original error
- ));
- }
- };
+ // 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),
+ )
+ })?;
- Ok(Form { id: Some(id), name, fields })
+ Ok(Form {
+ id: Some(id),
+ name,
+ fields,
+ notify_email,
+ notify_ntfy_topic,
+ created_at,
})
- .context("Failed to execute query map for getting forms")?;
+ })
+ .map_err(|e| {
+ log::error!("Failed to execute query: {}", e);
+ actix_web::error::ErrorInternalServerError("Database error")
+ })?;
- // Collect results, filtering out rows that failed parsing WITHIN the block
- let forms: Vec
= forms_iter
- .filter_map(|result| match result {
- Ok(form) => Some(form),
- Err(e) => {
- // Error was already logged inside the query_map closure.
- // We just filter out the failed row here.
- log::warn!("Skipping a form row due to a processing error: {}", e);
- None // Skip this row
- }
- })
- .collect();
+ // Collect results, filtering out rows that failed parsing
+ let forms: Vec
= 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();
- Ok::<_, anyhow::Error>(forms) // Ensure block returns Result compatible with flattening
- })
- .await
- .map_err(|e| {
- // Handle web::block error
- log::error!("web::block error during get_forms for user {}: {:?}", user_id, e);
- actix_web::error::ErrorInternalServerError("Failed to retrieve forms (blocking error)")
- })?
- .map_err(anyhow_to_actix_error)?; // Flatten Result, anyhow::Error>, BlockingError>
-
- log::debug!(
- "Returning {} forms for user {}",
- forms_result.len(),
- auth.user_id
- );
- Ok(HttpResponse::Ok().json(forms_result))
+ 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(
- db: web::Data>>,
+ app_state: web::Data,
auth: Auth, // Requires authentication
path: web::Path, // Extracts form_id from the path
) -> ActixResult {
@@ -641,106 +563,189 @@ pub async fn get_submissions(
form_id
);
- let db_conn = db.clone();
- let form_id_clone = form_id.clone();
- let user_id = auth.user_id.clone(); // Clone for logging context
+ let conn = app_state.db.lock().map_err(|e| {
+ log::error!("Failed to acquire database lock: {}", e);
+ actix_web::error::ErrorInternalServerError("Database error")
+ })?;
- // Wrap DB queries (existence check + fetching submissions) in web::block
- let submissions_result = web::block(move || {
- let conn = db_conn
- .lock()
- .map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_submissions lock"))?;
-
- // 1. Check if the form exists first
- let form_exists: bool = match conn.query_row(
- "SELECT EXISTS(SELECT 1 FROM forms WHERE id = ?1 LIMIT 1)", // Added LIMIT 1 for potential optimization
- params![form_id_clone],
- |row| row.get::<_, i32>(0), // sqlite returns 0 or 1 for EXISTS
- ) {
- Ok(count) => count == 1,
- Err(rusqlite::Error::QueryReturnedNoRows) => false, // Should not happen with EXISTS, but handle defensively
- Err(e) => return Err(anyhow::Error::from(e) // Propagate other DB errors
- .context(format!("Failed check existence of form {}", form_id_clone))),
- };
-
- if !form_exists {
- // Use Ok(None) to signal "form not found" to the calling async context
- return Ok(None);
+ // 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")
}
+ })?;
- // 2. If form exists, fetch its submissions
- let mut stmt = conn.prepare(
- "SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", // Include created_at if needed
- )
- .context(format!("Failed to prepare statement for getting submissions for form {}", form_id_clone))?;
+ // 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")
+ })?;
- let submissions_iter = stmt
- .query_map(params![form_id_clone], |row| {
- let id: String = row.get(0)?;
- let form_id_db: String = row.get(1)?;
- let data_str: String = row.get(2)?;
- // let created_at: String = row.get(3)?; // Example: If you fetch created_at
+ let submissions_iter = stmt
+ .query_map(params![form_id], |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 = row.get(3)?;
- // Parse the 'data' JSON string, handling potential errors
- let data: serde_json::Value = match serde_json::from_str(&data_str) {
- Ok(json_value) => json_value,
- Err(e) => {
- log::error!(
- "DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.",
- id, e
- );
- // Return specific error for filter_map
- return Err(rusqlite::Error::FromSqlConversionFailure(
- 2, rusqlite::types::Type::Text, Box::new(e)
- ));
- }
- };
+ 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
+ );
+ rusqlite::Error::FromSqlConversionFailure(
+ 2,
+ rusqlite::types::Type::Text,
+ Box::new(e),
+ )
+ })?;
- Ok(Submission { id, form_id: form_id_db, data }) // Add created_at if fetched
- })
- .context(format!("Failed to execute query map for getting submissions for form {}", form_id_clone))?;
-
- // Collect valid submissions, filtering out rows that failed parsing
- let submissions: Vec = submissions_iter
- .filter_map(|result| match result {
- Ok(submission) => Some(submission),
- Err(e) => {
- log::warn!("Skipping a submission row due to processing error: {}", e);
- None // Skip this row
- }
- })
- .collect();
-
- Ok(Some(submissions)) // Indicate success with the (potentially empty) list of submissions
-
- })
- .await
- .map_err(|e| { // Handle web::block error (cancellation, panic)
- log::error!("web::block error during get_submissions for form {} by user {}: {:?}", form_id, user_id, e);
- actix_web::error::ErrorInternalServerError("Failed to retrieve submissions (blocking error)")
- })?
- .map_err(anyhow_to_actix_error)?; // Flatten Result>, anyhow::Error>, BlockingError>
-
- // Process the result obtained from the web::block
- match submissions_result {
- Some(submissions) => {
- // Form exists, return the found submissions (might be an empty list)
- log::debug!(
- "Returning {} submissions for form {} requested by user {}",
- submissions.len(),
+ Ok(Submission {
+ id,
form_id,
- auth.user_id
- );
- Ok(HttpResponse::Ok().json(submissions))
- }
- None => {
- // Form was not found (signaled by Ok(None) from the block)
- log::warn!(
- "Attempt by user {} to get submissions for non-existent form_id: {}",
- auth.user_id,
- form_id
- );
- Err(actix_web::error::ErrorNotFound("Form not found"))
- }
- }
+ data,
+ created_at,
+ })
+ })
+ .map_err(|e| {
+ log::error!("Failed to execute query: {}", e);
+ actix_web::error::ErrorInternalServerError("Database error")
+ })?;
+
+ let submissions: Vec = 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();
+
+ log::debug!(
+ "Returning {} submissions for form {} requested by user {}",
+ submissions.len(),
+ form_id,
+ auth.user_id
+ );
+ Ok(HttpResponse::Ok().json(submissions))
+}
+
+// --- Notification Settings Handlers ---
+
+// GET /forms/{form_id}/notifications
+pub async fn get_notification_settings(
+ app_state: web::Data,
+ auth: Auth, // Requires authentication
+ path: web::Path,
+) -> ActixResult {
+ let form_id = path.into_inner();
+ log::info!(
+ "User {} requesting notification settings for form_id: {}",
+ auth.user_id,
+ form_id
+ );
+
+ let conn = app_state.db.lock().map_err(|e| {
+ log::error!(
+ "Failed to acquire database lock for get_notification_settings: {}",
+ e
+ );
+ actix_web::error::ErrorInternalServerError("Database error")
+ })?;
+
+ // Get the form to ensure it exists and retrieve current settings
+ let form = Form::get_by_id(&conn, &form_id).map_err(|e| {
+ log::warn!(
+ "Attempt to get settings for non-existent form {}: {}",
+ form_id,
+ e
+ );
+ if e.to_string().contains("not found") {
+ actix_web::error::ErrorNotFound("Form not found")
+ } else {
+ actix_web::error::ErrorInternalServerError("Database error retrieving form")
+ }
+ })?;
+
+ let settings = crate::models::NotificationSettingsPayload {
+ notify_email: form.notify_email,
+ notify_ntfy_topic: form.notify_ntfy_topic,
+ };
+
+ Ok(HttpResponse::Ok().json(settings))
+}
+
+// PUT /forms/{form_id}/notifications
+pub async fn update_notification_settings(
+ app_state: web::Data,
+ auth: Auth, // Requires authentication
+ path: web::Path,
+ payload: web::Json,
+) -> ActixResult {
+ let form_id = path.into_inner();
+ let new_settings = payload.into_inner();
+ log::info!(
+ "User {} updating notification settings for form_id: {}. Settings: {:?}",
+ auth.user_id,
+ form_id,
+ new_settings
+ );
+
+ let conn = app_state.db.lock().map_err(|e| {
+ log::error!(
+ "Failed to acquire database lock for update_notification_settings: {}",
+ e
+ );
+ actix_web::error::ErrorInternalServerError("Database error")
+ })?;
+
+ // Fetch the existing form to update it
+ let mut form = Form::get_by_id(&conn, &form_id).map_err(|e| {
+ log::warn!(
+ "Attempt to update settings for non-existent form {}: {}",
+ form_id,
+ e
+ );
+ if e.to_string().contains("not found") {
+ actix_web::error::ErrorNotFound("Form not found")
+ } else {
+ actix_web::error::ErrorInternalServerError("Database error retrieving form")
+ }
+ })?;
+
+ // Update the form fields
+ form.notify_email = new_settings.notify_email;
+ form.notify_ntfy_topic = new_settings.notify_ntfy_topic;
+
+ // Save the updated form
+ form.save(&conn).map_err(|e| {
+ log::error!(
+ "Failed to save updated notification settings for form {}: {}",
+ form_id,
+ e
+ );
+ actix_web::error::ErrorInternalServerError("Failed to save notification settings")
+ })?;
+
+ log::info!(
+ "Successfully updated notification settings for form {}",
+ form_id
+ );
+ Ok(HttpResponse::Ok().json(json!({ "message": "Notification settings updated successfully" })))
+}
+
+pub async fn health_check() -> impl Responder {
+ HttpResponse::Ok().json(serde_json::json!({
+ "status": "ok",
+ "version": env!("CARGO_PKG_VERSION"),
+ "timestamp": chrono::Utc::now().to_rfc3339()
+ }))
}
diff --git a/src/main.rs b/src/main.rs
index 79e36d6..af0a0e9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,164 +1,238 @@
// src/main.rs
use actix_cors::Cors;
use actix_files as fs;
-use actix_web::{http::header, middleware::Logger, web, App, HttpServer}; // Added Logger explicitly
+use actix_route_rate_limiter::{Limiter, RateLimiter};
+use actix_web::{http::header, middleware::Logger, web, App, HttpServer};
+use config::{Config, Environment};
use dotenv::dotenv;
-use log;
use std::env;
-use std::io::Result as IoResult; // Alias for clarity
+use std::io::Result as IoResult;
use std::process;
use std::sync::{Arc, Mutex};
+use std::time::Duration;
+use tracing::{error, info, warn};
+use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// Import modules
mod auth;
mod db;
mod handlers;
mod models;
+mod notifications;
+
+use notifications::{NotificationConfig, NotificationService};
+
+// Application state that will be shared across all routes
+pub struct AppState {
+ db: Arc>,
+ notification_service: Arc,
+}
#[actix_web::main]
async fn main() -> IoResult<()> {
- dotenv().ok(); // Load .env file
+ // Load environment variables from .env file
+ dotenv().ok();
- // Initialize logger (using RUST_LOG env var)
- env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
+ // Initialize Sentry for error tracking
+ let _guard = sentry::init((
+ env::var("SENTRY_DSN").unwrap_or_default(),
+ sentry::ClientOptions {
+ release: sentry::release_name!(),
+ ..Default::default()
+ },
+ ));
+
+ // Initialize structured logging
+ tracing_subscriber::registry()
+ .with(tracing_subscriber::EnvFilter::new(
+ env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
+ ))
+ .with(tracing_subscriber::fmt::layer())
+ .init();
+
+ // Load configuration
+ let settings = Config::builder()
+ .add_source(Environment::default())
+ .build()
+ .unwrap_or_else(|e| {
+ error!("Failed to load configuration: {}", e);
+ process::exit(1);
+ });
// --- Configuration (Environment Variables) ---
- // CRITICAL: Database URL is required
- let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| {
- log::warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'.");
+ let database_url = settings.get_string("DATABASE_URL").unwrap_or_else(|_| {
+ warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'.");
"form_data.db".to_string()
});
- // CRITICAL: Bind address is required
- let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| {
- log::warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'.");
+
+ let bind_address = settings.get_string("BIND_ADDRESS").unwrap_or_else(|_| {
+ warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'.");
"127.0.0.1:8080".to_string()
});
- // CRITICAL: Initial admin credentials (checked in db::init_db)
- // let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME").expect("Missing INITIAL_ADMIN_USERNAME");
- // let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD").expect("Missing INITIAL_ADMIN_PASSWORD");
- // OPTIONAL: Allowed origin for CORS
- let allowed_origin = env::var("ALLOWED_ORIGIN").ok(); // Use ok() to make it optional
- log::info!(" --- Formies Backend Configuration ---");
- log::info!("Required Environment Variables:");
- log::info!(" - DATABASE_URL (Current: {})", database_url);
- log::info!(" - BIND_ADDRESS (Current: {})", bind_address);
- log::info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)");
- log::info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)");
- log::info!("Optional Environment Variables:");
- if let Some(ref origin) = allowed_origin {
- log::info!(" - ALLOWED_ORIGIN (Set: {})", origin);
+ // Read allowed origins as a comma-separated string, defaulting to empty
+ let allowed_origins_str = env::var("ALLOWED_ORIGIN").unwrap_or_else(|_| {
+ warn!("ALLOWED_ORIGIN environment variable not set. CORS will be restrictive.");
+ String::new() // Default to empty string if not set
+ });
+
+ // Split the string into a vector of origins
+ let allowed_origins_list: Vec = if allowed_origins_str.is_empty() {
+ Vec::new() // Return an empty vector if the string is empty
} else {
- log::warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive, potentially blocking browser access. Set to your frontend URL (e.g., http://localhost:5173 or https://yourdomain.com).");
+ allowed_origins_str
+ .split(',')
+ .map(|s| s.trim().to_string()) // Trim whitespace and convert to String
+ .filter(|s| !s.is_empty()) // Remove empty strings resulting from extra commas
+ .collect()
+ };
+
+ info!(" --- Formies Backend Configuration ---");
+ info!("Required Environment Variables:");
+ info!(" - DATABASE_URL (Current: {})", database_url);
+ info!(" - BIND_ADDRESS (Current: {})", bind_address);
+ info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)");
+ info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)");
+ info!("Optional Environment Variables:");
+ if !allowed_origins_list.is_empty() {
+ info!(
+ " - ALLOWED_ORIGIN (Set: {})",
+ allowed_origins_list.join(", ") // Log the list nicely
+ );
+ } else {
+ warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive");
}
- log::info!(" - RUST_LOG (e.g., 'info,formies_be=debug')");
- log::info!(" --- End Configuration ---");
+ info!(" - RUST_LOG (e.g., 'info,formies_be=debug')");
+ info!(" --- End Configuration ---");
// Initialize database connection
let db_connection = match db::init_db(&database_url) {
Ok(conn) => conn,
Err(e) => {
- // Specific check for missing admin credentials error
if e.to_string().contains("INITIAL_ADMIN_USERNAME")
|| e.to_string().contains("INITIAL_ADMIN_PASSWORD")
{
- log::error!("FATAL: {}", e);
- log::error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables.");
+ error!("FATAL: {}", e);
+ error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables.");
} else {
- log::error!(
+ error!(
"FATAL: Failed to initialize database at {}: {:?}",
- database_url,
- e
+ database_url, e
);
}
- process::exit(1); // Exit if DB initialization fails
+ process::exit(1);
}
};
- // Wrap connection in Arc> for thread-safe sharing
- let db_data = web::Data::new(Arc::new(Mutex::new(db_connection)));
+ // Initialize rate limiter using the correct fields
+ let limiter = Limiter {
+ ip_addresses: std::collections::HashMap::new(), // Stores IP request counts
+ duration: chrono::TimeDelta::from_std(Duration::from_secs(60)).expect("Invalid duration"), // Convert std::time::Duration
+ num_requests: 100, // Max requests allowed in the duration
+ };
+ // Create the cloneable Arc> outside the closure
+ let limiter_data = Arc::new(Mutex::new(limiter));
- log::info!("Starting server at http://{}", bind_address);
+ // Initialize notification service
+ let notification_config = NotificationConfig::from_env().unwrap_or_else(|e| {
+ warn!(
+ "Failed to load notification configuration: {}. Notifications will not be available.",
+ e
+ );
+ NotificationConfig::default()
+ });
+ let notification_service = Arc::new(NotificationService::new(notification_config));
+
+ // Create AppState with both database and notification service
+ let app_state = web::Data::new(AppState {
+ db: Arc::new(Mutex::new(db_connection)),
+ notification_service: notification_service.clone(),
+ });
+
+ info!("Starting server at http://{}", bind_address);
HttpServer::new(move || {
- // Clone shared state for the closure
- let db_data_clone = db_data.clone();
- let allowed_origin_clone = allowed_origin.clone();
+ let app_state = app_state.clone();
+ let allowed_origins = allowed_origins_list.clone();
+ let rate_limiter = RateLimiter::new(limiter_data.clone());
// Configure CORS
- let cors = match allowed_origin_clone {
- Some(origin) => {
- log::info!("Configuring CORS for specific origin: {}", origin);
- Cors::default()
- .allowed_origin(&origin) // Allow only the specified origin
- .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
- .allowed_headers(vec![
- header::AUTHORIZATION,
- header::ACCEPT,
- header::CONTENT_TYPE,
- header::ORIGIN, // Add Origin header if needed
- header::ACCESS_CONTROL_REQUEST_METHOD,
- header::ACCESS_CONTROL_REQUEST_HEADERS,
- ])
- .supports_credentials()
- .max_age(3600)
- }
- None => {
- // Default restrictive CORS: No origin allowed explicitly.
- // This will likely block browser requests unless the browser and server are on the same origin.
- log::warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set.");
- Cors::default() // No allowed_origin set
- .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
- .allowed_headers(vec![
- header::AUTHORIZATION,
- header::ACCEPT,
- header::CONTENT_TYPE,
- header::ORIGIN,
- header::ACCESS_CONTROL_REQUEST_METHOD,
- header::ACCESS_CONTROL_REQUEST_HEADERS,
- ])
- .supports_credentials()
- .max_age(3600)
- // DO NOT use allow_any_origin() unless you fully understand the security implications.
+ let cors = if !allowed_origins.is_empty() {
+ info!("Configuring CORS for origins: {:?}", allowed_origins);
+ let mut cors = Cors::default();
+ for origin in allowed_origins {
+ cors = cors.allowed_origin(&origin); // Add each origin
}
+ cors.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
+ .allowed_headers(vec![
+ header::AUTHORIZATION,
+ header::ACCEPT,
+ header::CONTENT_TYPE,
+ header::ORIGIN,
+ header::ACCESS_CONTROL_REQUEST_METHOD,
+ header::ACCESS_CONTROL_REQUEST_HEADERS,
+ ])
+ .supports_credentials()
+ .max_age(3600)
+ } else {
+ warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set.");
+ Cors::default() // Keep restrictive default if no origins are provided
+ .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
+ .allowed_headers(vec![
+ header::AUTHORIZATION,
+ header::ACCEPT,
+ header::CONTENT_TYPE,
+ header::ORIGIN,
+ header::ACCESS_CONTROL_REQUEST_METHOD,
+ header::ACCESS_CONTROL_REQUEST_HEADERS,
+ ])
+ .supports_credentials()
+ .max_age(3600)
};
App::new()
- .wrap(cors) // Apply CORS middleware
- .wrap(Logger::default()) // Add request logging (default format)
- .app_data(db_data_clone) // Share database connection pool
- // --- API Routes ---
+ .wrap(cors)
+ .wrap(Logger::default())
+ .wrap(tracing_actix_web::TracingLogger::default())
+ .wrap(rate_limiter)
+ .app_data(app_state)
.service(
- web::scope("/api") // Group API routes under /api
- // --- Public Routes ---
+ web::scope("/api")
+ // Health check endpoint
+ .route("/health", web::get().to(handlers::health_check))
+ // Public routes
.route("/login", web::post().to(handlers::login))
.route(
"/forms/{form_id}/submissions",
web::post().to(handlers::submit_form),
)
- // --- Protected Routes (using Auth extractor) ---
- .route("/logout", web::post().to(handlers::logout)) // Added logout
+ // Protected routes
+ .route("/logout", web::post().to(handlers::logout))
.route("/forms", web::post().to(handlers::create_form))
.route("/forms", web::get().to(handlers::get_forms))
.route(
"/forms/{form_id}/submissions",
web::get().to(handlers::get_submissions),
+ )
+ .route(
+ "/forms/{form_id}/notifications",
+ web::get().to(handlers::get_notification_settings),
+ )
+ .route(
+ "/forms/{form_id}/notifications",
+ web::put().to(handlers::update_notification_settings),
),
)
- // --- Static Files (Serve Frontend - Optional) ---
- // Assumes frontend build output is in ../frontend/dist
- // Register this LAST to avoid conflicts with API routes
.service(
- fs::Files::new("/", "../frontend/dist/")
+ fs::Files::new("/", "./frontend/")
.index_file("index.html")
.use_last_modified(true)
- // Optional: Add a fallback to index.html for SPA routing
- .default_handler(
- fs::NamedFile::open("../frontend/dist/index.html").unwrap_or_else(|_| {
- log::error!("Fallback file not found: ../frontend/dist/index.html");
- process::exit(1); // Exit if fallback file is missing
- }), // Handle error explicitly
- ),
+ .default_handler(fs::NamedFile::open("./frontend/index.html").unwrap_or_else(
+ |_| {
+ error!("Fallback file not found: ../frontend/index.html");
+ process::exit(1);
+ },
+ )),
)
})
.bind(&bind_address)?
diff --git a/src/models.rs b/src/models.rs
index 19f584e..3562944 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -1,4 +1,5 @@
// src/models.rs
+use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// Consider adding chrono for DateTime types if needed in responses
// use chrono::{DateTime, Utc};
@@ -28,8 +29,9 @@ pub struct Form {
/// }
/// ```
pub fields: serde_json::Value,
- // Optional: Add created_at if needed in API responses
- // pub created_at: Option>,
+ pub notify_email: Option,
+ pub notify_ntfy_topic: Option,
+ pub created_at: DateTime,
}
// Represents a single submission for a specific form
@@ -41,8 +43,7 @@ pub struct Submission {
/// Expected to be a JSON object where keys are field names (`name`) from the form definition's `fields` array.
/// Example: `{ "email": "user@example.com", "age": 30 }`
pub data: serde_json::Value,
- // Optional: Add created_at if needed in API responses
- // pub created_at: Option>,
+ pub created_at: DateTime,
}
// Used for the /login endpoint request body
@@ -67,73 +68,9 @@ pub struct UserAuthData {
// Note: Token and expiry are handled separately and not needed in this specific struct
}
-// --- Custom Application Error (Optional but Recommended for Consistency) ---
-// Although not fully integrated in this pass to minimize changes,
-// this shows the structure for future improvement.
-
-// use actix_web::{ResponseError, http::StatusCode};
-// use std::fmt;
-
-// #[derive(Debug)]
-// pub enum AppError {
-// DatabaseError(anyhow::Error),
-// ConfigError(String),
-// ValidationError(serde_json::Value), // Store the validation errors JSON
-// NotFound(String),
-// Unauthorized(String),
-// InternalError(String),
-// BlockingError(String),
-// }
-
-// impl fmt::Display for AppError {
-// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-// match self {
-// AppError::DatabaseError(e) => write!(f, "Database error: {}", e),
-// AppError::ConfigError(s) => write!(f, "Configuration error: {}", s),
-// AppError::ValidationError(_) => write!(f, "Validation failed"),
-// AppError::NotFound(s) => write!(f, "Not found: {}", s),
-// AppError::Unauthorized(s) => write!(f, "Unauthorized: {}", s),
-// AppError::InternalError(s) => write!(f, "Internal server error: {}", s),
-// AppError::BlockingError(s) => write!(f, "Blocking operation error: {}", s),
-// }
-// }
-// }
-
-// impl ResponseError for AppError {
-// fn status_code(&self) -> StatusCode {
-// match self {
-// AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
-// AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR,
-// AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
-// AppError::NotFound(_) => StatusCode::NOT_FOUND,
-// AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
-// AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
-// AppError::BlockingError(_) => StatusCode::INTERNAL_SERVER_ERROR,
-// }
-// }
-
-// fn error_response(&self) -> HttpResponse {
-// let status = self.status_code();
-// let error_json = match self {
-// AppError::ValidationError(errors) => errors.clone(),
-// // Provide a generic error structure for others
-// _ => json!({ "error": status.canonical_reason().unwrap_or("Unknown Error"), "message": self.to_string() }),
-// };
-
-// HttpResponse::build(status).json(error_json)
-// }
-// }
-
-// // Implement From traits to convert other errors into AppError easily
-// impl From for AppError {
-// fn from(err: anyhow::Error) -> Self {
-// // Basic conversion, could add more context analysis here
-// AppError::DatabaseError(err)
-// }
-// }
-// impl From for AppError {
-// fn from(err: actix_web::error::BlockingError) -> Self {
-// AppError::BlockingError(err.to_string())
-// }
-//}
-// // Add From, From, etc. as needed
+// Used for the GET/PUT /forms/{form_id}/notifications endpoints
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct NotificationSettingsPayload {
+ pub notify_email: Option,
+ pub notify_ntfy_topic: Option,
+}
diff --git a/src/notifications.rs b/src/notifications.rs
new file mode 100644
index 0000000..8f0503e
--- /dev/null
+++ b/src/notifications.rs
@@ -0,0 +1,148 @@
+use anyhow::Result;
+use lettre::message::header::ContentType;
+use lettre::transport::smtp::authentication::Credentials;
+use lettre::{Message, SmtpTransport, Transport};
+use serde::Serialize;
+use std::env;
+
+#[derive(Debug, Serialize)]
+pub struct NotificationConfig {
+ smtp_host: String,
+ smtp_port: u16,
+ smtp_username: String,
+ smtp_password: String,
+ from_email: String,
+ ntfy_topic: String,
+ ntfy_server: String,
+}
+
+impl Default for NotificationConfig {
+ fn default() -> Self {
+ Self {
+ smtp_host: String::new(),
+ smtp_port: 587,
+ smtp_username: String::new(),
+ smtp_password: String::new(),
+ from_email: String::new(),
+ ntfy_topic: String::new(),
+ ntfy_server: "https://ntfy.sh".to_string(),
+ }
+ }
+}
+
+impl NotificationConfig {
+ pub fn from_env() -> Result {
+ Ok(Self {
+ smtp_host: env::var("SMTP_HOST")?,
+ smtp_port: env::var("SMTP_PORT")?.parse()?,
+ smtp_username: env::var("SMTP_USERNAME")?,
+ smtp_password: env::var("SMTP_PASSWORD")?,
+ from_email: env::var("FROM_EMAIL")?,
+ ntfy_topic: env::var("NTFY_TOPIC")?,
+ ntfy_server: env::var("NTFY_SERVER").unwrap_or_else(|_| "https://ntfy.sh".to_string()),
+ })
+ }
+
+ pub fn is_email_configured(&self) -> bool {
+ !self.smtp_host.is_empty()
+ && !self.smtp_username.is_empty()
+ && !self.smtp_password.is_empty()
+ && !self.from_email.is_empty()
+ }
+
+ pub fn is_ntfy_configured(&self) -> bool {
+ !self.ntfy_topic.is_empty()
+ }
+}
+
+pub struct NotificationService {
+ config: NotificationConfig,
+}
+
+impl NotificationService {
+ pub fn new(config: NotificationConfig) -> Self {
+ Self { config }
+ }
+
+ pub async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
+ if !self.config.is_email_configured() {
+ return Ok(());
+ }
+
+ let email = Message::builder()
+ .from(self.config.from_email.parse()?)
+ .to(to.parse()?)
+ .subject(subject)
+ .header(ContentType::TEXT_PLAIN)
+ .body(body.to_string())?;
+
+ let creds = Credentials::new(
+ self.config.smtp_username.clone(),
+ self.config.smtp_password.clone(),
+ );
+
+ let mailer = SmtpTransport::relay(&self.config.smtp_host)?
+ .port(self.config.smtp_port)
+ .credentials(creds)
+ .build();
+
+ mailer.send(&email)?;
+ Ok(())
+ }
+
+ pub fn send_ntfy(&self, title: &str, message: &str, priority: Option) -> Result<()> {
+ if !self.config.is_ntfy_configured() {
+ return Ok(());
+ }
+
+ let url = format!("{}/{}", self.config.ntfy_server, self.config.ntfy_topic);
+
+ let mut request = ureq::post(&url).set("Title", title);
+
+ if let Some(p) = priority {
+ request = request.set("Priority", &p.to_string());
+ }
+
+ request.send_string(message)?;
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_notification_config() {
+ std::env::set_var("SMTP_HOST", "smtp.example.com");
+ std::env::set_var("SMTP_PORT", "587");
+ std::env::set_var("SMTP_USERNAME", "test@example.com");
+ std::env::set_var("SMTP_PASSWORD", "password");
+ std::env::set_var("FROM_EMAIL", "noreply@example.com");
+ std::env::set_var("NTFY_TOPIC", "my-topic");
+
+ let config = NotificationConfig::from_env().unwrap();
+ assert_eq!(config.smtp_host, "smtp.example.com");
+ assert_eq!(config.smtp_port, 587);
+ assert_eq!(config.ntfy_server, "https://ntfy.sh");
+ }
+
+ #[test]
+ fn test_config_validation() {
+ let default_config = NotificationConfig::default();
+ assert!(!default_config.is_email_configured());
+ assert!(!default_config.is_ntfy_configured());
+
+ let config = NotificationConfig {
+ smtp_host: "smtp.example.com".to_string(),
+ smtp_port: 587,
+ smtp_username: "user".to_string(),
+ smtp_password: "pass".to_string(),
+ from_email: "test@example.com".to_string(),
+ ntfy_topic: "topic".to_string(),
+ ntfy_server: "https://ntfy.sh".to_string(),
+ };
+ assert!(config.is_email_configured());
+ assert!(config.is_ntfy_configured());
+ }
+}
diff --git a/tests/handlers_test.rs b/tests/handlers_test.rs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/handlers_test.rs
@@ -0,0 +1 @@
+