diff --git a/Cargo.lock b/Cargo.lock index 4397a09..67c3570 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,6 +875,7 @@ dependencies = [ "sentry", "serde", "serde_json", + "tokio", "tracing", "tracing-actix-web", "tracing-appender", @@ -3122,9 +3123,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.42.0" +version = "1.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" dependencies = [ "backtrace", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 2f94962..158bef6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,4 +36,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-actix-web = "0.7" tracing-log = "0.2" tracing-appender = "0.2" -tracing-bunyan-formatter = "0.3" \ No newline at end of file +tracing-bunyan-formatter = "0.3" +tokio = "1.45.0" diff --git a/README.md b/README.md index b74a4f2..250f569 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ The application can be configured using environment variables or a configuration - `SENTRY_DSN`: Sentry DSN for error tracking - `JWT_SECRET`: JWT secret key - `JWT_EXPIRATION`: JWT expiration time in seconds +- `CAPTCHA_ENABLED`: Enable CAPTCHA verification for public form submissions (`true` or `false`, default: `false`) +- `CAPTCHA_SECRET_KEY`: The secret key provided by your CAPTCHA service (e.g., hCaptcha, reCAPTCHA) +- `CAPTCHA_VERIFICATION_URL`: The verification endpoint URL for your CAPTCHA service (e.g., `https://hcaptcha.com/siteverify`) ## Development @@ -144,6 +147,17 @@ tail -f logs/app.log - Passwords are hashed using bcrypt - SQLite database is protected with proper file permissions +### Form Submission Security + +The public form submission endpoint (`/api/forms/{form_id}/submissions`) includes several security measures: + +- **Global Rate Limiting:** The overall number of requests to the API is limited. +- **Per-Form, Per-IP Rate Limiting:** Limits the number of submissions one IP address can make to a specific form within a time window (e.g., 5 submissions per minute). Configurable in code. +- **CAPTCHA Verification:** If enabled via environment variables (`CAPTCHA_ENABLED=true`), requires a valid CAPTCHA token (e.g., from hCaptcha, reCAPTCHA, Turnstile) to be sent in the `captcha_token` field of the submission payload. The backend verifies this token with the configured provider. +- **Payload Size Limit:** The maximum size of the submission payload is limited (e.g., 1MB) to prevent DoS attacks. Configurable in code. +- **Input Validation:** Submission data is validated against the specific form's field definitions (type, required, length, pattern, etc.). +- **Notification Throttling:** Limits the rate at which notifications (Email, Ntfy) are sent per form to prevent spamming channels (e.g., max 1 per minute). Configurable in code. + ## License MIT diff --git a/frontend/index.html b/frontend/index.html index e7f8172..74f4b26 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -67,7 +67,7 @@

Login

-
+
diff --git a/repomix-output.xml b/repomix-output.xml new file mode 100644 index 0000000..a9f29db --- /dev/null +++ b/repomix-output.xml @@ -0,0 +1,4594 @@ +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) + + + + + + + + + +.gitea/workflows/docker-build.yml +.gitignore +Cargo.toml +config/default.toml +design.html +Dockerfile +frontend/index.html +frontend/script.js +frontend/style.css +README.md +src/auth.rs +src/db.rs +src/handlers.rs +src/main.rs +src/models.rs +src/notifications.rs +tests/handlers_test.rs + + + +This section contains the contents of the repository's files. + + +/target + + + +[server] +bind_address = "127.0.0.1:8080" +workers = 4 +keep_alive = 60 +client_timeout = 5000 +client_shutdown = 5000 + +[database] +url = "form_data.db" +pool_size = 5 +connection_timeout = 30 + +[security] +rate_limit_requests = 100 +rate_limit_interval = 60 +allowed_origins = ["http://localhost:5173"] +jwt_secret = "your-secret-key" +jwt_expiration = 3600 + +[logging] +level = "info" +format = "json" +file = "logs/app.log" +max_size = 10485760 # 10MB +max_files = 5 + +[monitoring] +sentry_dsn = "" +enable_metrics = true +metrics_port = 9090 + + + + + + + + + FormCraft - Scandinavian Industrial Form Management + + + + +
+
+ + +
+
+ + + + + 3 +
+
JD
+
+
+
+ + +
+
+ + +
+

Dashboard Overview

+ +
+ + +
+
+
Total Submissions
+
1,248
+
+ + + + 12% from last month +
+
+
+
Active Forms
+
24
+
+ + + + 3 new this month +
+
+
+
Avg. Conversion Rate
+
68.4%
+
+ + + + 2.1% from last month +
+
+
+
Storage Used
+
342 MB
+
+ + + + + + 24 MB from last month +
+
+
+ + +
+ + +
+
+

+ + + + + + + + Recent Forms +

+ View All Forms +
+ +
+
+
+
+
+ +
+ +
+
+
Customer Feedback Q2
+
+ + + + + +
+
+
+
+
+ 486 + Submissions +
+
+ 75% + Completion +
+
+
+
+
+ +
+
+ + +
+
+
Annual Conf Registration
+
+ + + + + +
+
+
+
+
+ 312 + Submissions +
+
+ 92% + Completion +
+
+
+
+
+ +
+
+ + +
+
+
Frontend Dev Application
+
+ + + + + +
+
+
+
+
+ 124 + Submissions +
+
+ 88% + Completion +
+
+
+
+
+ +
+
+
+
+
+ + +
+
+

+ + + + + + + + + Recent Submissions +

+ + View All Submissions + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Form NameSubmitted byDateStatusActions
Customer Feedback Q2john.doe@example.comMay 05, 2025
New
+ +
Annual Conf Registrationsarah.smith@example.comMay 04, 2025
Pending
+ +
Customer Feedback Q2mark.rivera@sample.netMay 03, 2025
Reviewed
+ +
+
+
+
+ +
+ + + + + +
+ + + + + + + + Formies + + + + + + +
+ +
+ +

Formies - Simple Form Manager

+ + +
+

Login

+ +
+ + +
+
+ + +
+ + + +
+ + + + + +
+
+

Submit to a Form

+

Enter a Form ID to load and submit:

+
+ + + +
+ + +
+
+ + + + + + + + + + +
+ + +document.addEventListener("DOMContentLoaded", () => { + // --- Configuration --- + const API_BASE_URL = "http://localhost:8080/api"; // Assuming backend serves API under /api + + // --- State --- + let authToken = sessionStorage.getItem("authToken"); // Use sessionStorage for non-persistent login + + // --- DOM Elements --- + const loginSection = document.getElementById("login-section"); + const adminSection = document.getElementById("admin-section"); + const loginForm = document.getElementById("login-form"); + const usernameInput = document.getElementById("username"); + const passwordInput = document.getElementById("password"); + const logoutButton = document.getElementById("logout-button"); + const statusArea = document.getElementById("status-area"); + const loggedInUserSpan = document.getElementById("logged-in-user"); // Added this if needed + + const createForm = document.getElementById("create-form"); + const formNameInput = document.getElementById("form-name"); + + const loadFormsButton = document.getElementById("load-forms-button"); + const formsList = document.getElementById("forms-list"); + + const submissionsSection = document.getElementById("submissions-section"); + const submissionsList = document.getElementById("submissions-list"); + const submissionsFormNameSpan = document.getElementById( + "submissions-form-name" + ); + + const publicFormIdInput = document.getElementById("public-form-id-input"); + const loadPublicFormButton = document.getElementById( + "load-public-form-button" + ); + const publicFormArea = document.getElementById("public-form-area"); + const publicFormTitle = document.getElementById("public-form-title"); + const publicForm = document.getElementById("public-form"); + + // --- Helper Functions --- + function showStatus(message, isError = false) { + statusArea.textContent = message; + statusArea.className = "status"; // Reset classes + if (message) { + statusArea.classList.add(isError ? "error" : "success"); + } + } + + function toggleSections() { + console.log("toggleSections called. Current authToken:", authToken); // Log 3 + if (authToken) { + console.log("AuthToken found, showing admin section."); // Log 4 + loginSection.classList.add("hidden"); + adminSection.classList.remove("hidden"); + // Optionally display username if you fetch it after login + // loggedInUserSpan.textContent = 'Admin'; // Placeholder + } else { + console.log("AuthToken not found, showing login section."); // Log 5 + loginSection.classList.remove("hidden"); + adminSection.classList.add("hidden"); + submissionsSection.classList.add("hidden"); // Hide submissions when logged out + } + // Always hide public form initially on state change + publicFormArea.classList.add("hidden"); + publicForm.innerHTML = ''; // Reset form content + } + + async function makeApiRequest( + endpoint, + method = "GET", + body = null, + requiresAuth = false + ) { + const url = `${API_BASE_URL}${endpoint}`; + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + }; + + if (requiresAuth) { + if (!authToken) { + throw new Error("Authentication required, but no token found."); + } + headers["Authorization"] = `Bearer ${authToken}`; + } + + const options = { + method, + headers, + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, options); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); // Try to parse error JSON + } catch (e) { + // If response is not JSON + errorData = { + message: `HTTP Error: ${response.status} ${response.statusText}`, + }; + } + // Check for backend's validation error structure + if (errorData && errorData.validation_errors) { + throw { validationErrors: errorData.validation_errors }; + } + // Throw a more generic error message or the one from backend if available + throw new Error( + errorData.message || `Request failed with status ${response.status}` + ); + } + + // Handle responses with no content (e.g., logout) + if ( + response.status === 204 || + response.headers.get("content-length") === "0" + ) { + return null; // Or return an empty object/success indicator + } + + return await response.json(); // Parse successful JSON response + } catch (error) { + console.error(`API Request Error (${method} ${endpoint}):`, error); + // Re-throw validation errors specifically if they exist + if (error.validationErrors) { + throw error; + } + // Re-throw other errors + throw new Error(error.message || "Network error or failed to fetch"); + } + } + + // --- Event Handlers --- + loginForm.addEventListener("submit", async (e) => { + e.preventDefault(); + showStatus(""); // Clear previous status + const username = usernameInput.value.trim(); + const password = passwordInput.value.trim(); + + if (!username || !password) { + showStatus("Username and password are required.", true); + return; + } + + try { + const data = await makeApiRequest("/login", "POST", { + username, + password, + }); + if (data && data.token) { + console.log("Login successful, received token:", data.token); // Log 1 + authToken = data.token; + sessionStorage.setItem("authToken", authToken); // Store token + console.log("Calling toggleSections after login..."); // Log 2 + toggleSections(); + showStatus("Login successful!"); + usernameInput.value = ""; // Clear fields + passwordInput.value = ""; + } else { + throw new Error("Login failed: No token received."); + } + } catch (error) { + showStatus(`Login failed: ${error.message}`, true); + authToken = null; + sessionStorage.removeItem("authToken"); + toggleSections(); + } + }); + + logoutButton.addEventListener("click", async () => { + showStatus(""); + if (!authToken) return; + + try { + await makeApiRequest("/logout", "POST", null, true); + showStatus("Logout successful!"); + } catch (error) { + showStatus(`Logout failed: ${error.message}`, true); + // Decide if you still want to clear local state even if server fails + // Forcing logout locally might be better UX in case of server error + } finally { + // Always clear local state on logout attempt + authToken = null; + sessionStorage.removeItem("authToken"); + toggleSections(); + } + }); + + if (createForm) { + createForm.addEventListener("submit", async (e) => { + e.preventDefault(); + showStatus(""); + const formName = formNameInput.value.trim(); + if (!formName) { + showStatus("Please enter a form name", true); + return; + } + + try { + // Refactor to use makeApiRequest + const data = await makeApiRequest( + "/forms", // Endpoint relative to API_BASE_URL + "POST", + // TODO: Need a way to define form fields in the UI. + // Sending minimal structure for now. + { name: formName, fields: [] }, + true // Requires authentication + ); + + if (!data || !data.id) { + throw new Error( + "Failed to create form or received invalid response." + ); + } + + showStatus( + `Form '${data.name}' created successfully! (ID: ${data.id})`, + "success" + ); + formNameInput.value = ""; + // Automatically refresh the forms list after creation + if (loadFormsButton) { + loadFormsButton.click(); + } + } catch (error) { + showStatus(`Error creating form: ${error.message}`, true); + } + }); + } + + // Ensure createFormFromUrl exists before adding listener + const createFormFromUrlEl = document.getElementById("create-form-from-url"); + if (createFormFromUrlEl) { + // Check if the element exists + const formNameUrlInput = document.getElementById("form-name-url"); + const formUrlInput = document.getElementById("form-url"); + + createFormFromUrlEl.addEventListener("submit", async (e) => { + e.preventDefault(); + showStatus(""); + const name = formNameUrlInput.value.trim(); + const url = formUrlInput.value.trim(); + + if (!name || !url) { + showStatus("Form name and URL are required.", true); + return; + } + + try { + const newForm = await makeApiRequest( + "/forms/from-url", + "POST", + { name, url }, + true + ); + showStatus( + `Form '${newForm.name}' created successfully with ID: ${newForm.id}` + ); + formNameUrlInput.value = ""; // Clear form + formUrlInput.value = ""; + loadFormsButton.click(); // Refresh the forms list + } catch (error) { + showStatus(`Failed to create form from URL: ${error.message}`, true); + } + }); + } + + if (loadFormsButton) { + loadFormsButton.addEventListener("click", async () => { + showStatus(""); + submissionsSection.classList.add("hidden"); // Hide submissions when reloading forms + formsList.innerHTML = "
  • Loading...
  • "; // Indicate loading + + try { + const forms = await makeApiRequest("/forms", "GET", null, true); + formsList.innerHTML = ""; // Clear list + + if (forms && forms.length > 0) { + forms.forEach((form) => { + const li = document.createElement("li"); + li.textContent = `${form.name} (ID: ${form.id})`; + + const viewSubmissionsButton = document.createElement("button"); + viewSubmissionsButton.textContent = "View Submissions"; + viewSubmissionsButton.onclick = () => + loadSubmissions(form.id, form.name); + + li.appendChild(viewSubmissionsButton); + formsList.appendChild(li); + }); + } else { + formsList.innerHTML = "
  • No forms found.
  • "; + } + } catch (error) { + showStatus(`Failed to load forms: ${error.message}`, true); + formsList.innerHTML = "
  • Error loading forms.
  • "; + } + }); + } + + async function loadSubmissions(formId, formName) { + showStatus(""); + submissionsList.innerHTML = "
  • Loading submissions...
  • "; + submissionsFormNameSpan.textContent = `${formName} (ID: ${formId})`; + submissionsSection.classList.remove("hidden"); + + try { + const submissions = await makeApiRequest( + `/forms/${formId}/submissions`, + "GET", + null, + true + ); + submissionsList.innerHTML = ""; // Clear list + + if (submissions && submissions.length > 0) { + submissions.forEach((sub) => { + const li = document.createElement("li"); + // Display submission data safely - avoid rendering raw HTML + const pre = document.createElement("pre"); + pre.textContent = JSON.stringify(sub.data, null, 2); // Pretty print JSON + li.appendChild(pre); + // Optionally display submission ID and timestamp if available + // const info = document.createElement('small'); + // info.textContent = `ID: ${sub.id}, Submitted: ${sub.created_at || 'N/A'}`; + // li.appendChild(info); + + submissionsList.appendChild(li); + }); + } else { + submissionsList.innerHTML = + "
  • No submissions found for this form.
  • "; + } + } 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 + } +}); +
    + + +/* --- 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; + } +} + + + +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()); + } +} + + + + + + + +[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" +reqwest = { version = "0.11", features = ["json"] } +scraper = "0.18" +lettre = { version = "0.10", features = ["builder", "tokio1-native-tls"] } +ureq = { version = "2.9", features = ["json"] } +# Production dependencies +actix_route_rate_limiter = "0.2.2" +actix-rt = "2.0" +actix-http = "3.0" +config = "0.13" +sentry = { version = "0.37", features = ["log"] } +validator = { version = "0.16", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-actix-web = "0.7" +tracing-log = "0.2" +tracing-appender = "0.2" +tracing-bunyan-formatter = "0.3" + + + +// 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, +}; +use futures::future::{ready, Ready}; +use log; // Use the log crate +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely) + +// Represents an authenticated user via token +pub struct Auth { + pub user_id: String, +} + +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 + // Extract the *whole* AppState first + let app_state_result = req.app_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( + "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_arc_mutex.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, Connection, OptionalExtension}; +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..."); + 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..."); + 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> { + log::debug!("Validating received token (existence and expiration)..."); + let mut stmt = conn.prepare( + // Select user ID only if token matches AND it hasn't expired + "SELECT id FROM users WHERE token = ?1 AND token_expires_at IS NOT NULL AND token_expires_at > ?2" + ).context("Failed to prepare query for validating token")?; + + let now_ts = Utc::now().to_rfc3339(); // Use ISO 8601 / RFC 3339 format compatible with SQLite DATETIME + + let user_id_option: Option = stmt + .query_row(params![token, now_ts], |row| row.get(0)) + .optional() // Makes it return Option instead of erroring on no rows + .context("Failed to execute query for validating token")?; + + if user_id_option.is_some() { + log::debug!("Token validation successful."); + } else { + // This covers token not found OR token expired + log::debug!("Token validation failed (token not found or expired)."); + } + + Ok(user_id_option) +} + +// Invalidate a user's token (e.g., on logout) by setting it to NULL and clearing expiration +pub fn invalidate_token(conn: &Connection, user_id: &str) -> AnyhowResult<()> { + log::debug!("Invalidating token for user_id {}", user_id); + conn.execute( + "UPDATE users SET token = NULL, token_expires_at = NULL WHERE id = ?1", + params![user_id], + ) + .context(format!( + "Failed to invalidate token for user_id {}", + user_id + ))?; + Ok(()) +} + +// Authenticate a user by username and password, returning user ID and hash if successful +pub fn authenticate_user( + conn: &Connection, + username: &str, + password: &str, +) -> AnyhowResult> { + log::debug!("Attempting to authenticate user: {}", username); + let mut stmt = conn + .prepare("SELECT id, password FROM users WHERE username = ?1") + .context("Failed to prepare query for authenticating user")?; + + let result = stmt + .query_row(params![username], |row| { + Ok(models::UserAuthData { + id: row.get(0)?, + hashed_password: row.get(1)?, + }) + }) + .optional() + .context(format!( + "Failed to execute query to fetch auth data for user '{}'", + username + ))?; + + match result { + Some(user_data) => { + // Verify the provided password against the stored hash + let is_valid = verify(password, &user_data.hashed_password) + .context("Failed to verify password hash")?; + + if is_valid { + log::info!("Authentication successful for user: {}", username); + Ok(Some(user_data)) // Return user ID and hash + } else { + log::warn!( + "Authentication failed for user '{}' (invalid password)", + username + ); + Ok(None) // Invalid password + } + } + None => { + log::warn!( + "Authentication failed for user '{}' (user not found)", + username + ); + Ok(None) // User not found + } + } +} + +// Generate and save a new session token (with expiration) for a user +pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> AnyhowResult { + let new_token = Uuid::new_v4().to_string(); + // Calculate expiration time + let expires_at = Utc::now() + ChronoDuration::hours(TOKEN_LIFETIME_HOURS); + let expires_at_ts = expires_at.to_rfc3339(); // Store as string + + log::debug!( + "Generating new token for user_id {} expiring at {}", + user_id, + expires_at_ts + ); + + conn.execute( + "UPDATE users SET token = ?1, token_expires_at = ?2 WHERE id = ?3", + params![new_token, expires_at_ts, user_id], + ) + .context(format!("Failed to update token for user_id {}", user_id))?; + + Ok(new_token) +} + +// 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, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1") + .context("Failed to prepare query for fetching form")?; + + 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)?; + + // Parse the fields JSON string + let fields = serde_json::from_str(&fields_str).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 2, // Index of 'fields' column + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; + + Ok(models::Form { + id: Some(id), + name, + fields, + notify_email, + notify_ntfy_topic, // Include the new field + created_at, + }) + }) + .optional() + .context(format!("Failed to fetch form with ID: {}", form_id))?; + + 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(()) + } +} + + + +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 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::sync::{Arc, Mutex}; +use uuid::Uuid; + +// --- 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( + app_state: web::Data, // Expect AppState like other handlers + creds: web::Json, +) -> ActixResult { + // 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 || { + // 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) + }) + .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) => { + // 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 || { + // 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) + }) + .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( + 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_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_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| { + // Use the original auth.user_id here as user_id moved into the block + log::error!( + "web::block error during logout for user {}: {:?}", + auth.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( + 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 conn = app_state.db.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; + + // Get form definition + let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?; + + // Validate submission against form definition + if let Err(validation_errors) = + validate_submission_against_definition(&submission_payload, &form.fields) + { + return Ok(HttpResponse::BadRequest().json(validation_errors)); + } + + // 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(), + }; + + // 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") + })?; + + // 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() + ); + + 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 + }))) +} + +// POST /forms +pub async fn create_form( + app_state: web::Data, + _auth: Auth, // Authentication check via Auth extractor + payload: web::Json, +) -> ActixResult { + let payload = payload.into_inner(); + + // Extract form data from payload + let name = payload["name"] + .as_str() + .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))? + .to_string(); + + let fields = payload["fields"].clone(); + if !fields.is_array() { + return Err(actix_web::error::ErrorBadRequest( + "'fields' must be a JSON array", + )); + } + + let notify_email = payload["notify_email"].as_str().map(|s| s.to_string()); + let notify_ntfy_topic = payload["notify_ntfy_topic"].as_str().map(|s| s.to_string()); + + // Create new form + let form = Form { + id: None, // Will be generated during save + name, + fields, + notify_email, + notify_ntfy_topic, + created_at: chrono::Utc::now(), + }; + + // Save the form + let conn = app_state.db.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; + + form.save(&conn).map_err(|e| { + log::error!("Failed to save form: {}", e); + actix_web::error::ErrorInternalServerError("Failed to save form") + })?; + + Ok(HttpResponse::Created().json(form)) +} + +// GET /forms +pub async fn get_forms( + app_state: web::Data, + auth: Auth, // Requires authentication +) -> ActixResult { + log::info!("User {} requesting list of forms", auth.user_id); + + 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, 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 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 + 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, + notify_email, + notify_ntfy_topic, + created_at, + }) + }) + .map_err(|e| { + log::error!("Failed to execute query: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; + + // 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(); + + log::debug!("Returning {} forms for user {}", forms.len(), auth.user_id); + Ok(HttpResponse::Ok().json(forms)) +} + +// GET /forms/{form_id}/submissions +pub async fn get_submissions( + app_state: web::Data, + auth: Auth, // Requires authentication + path: web::Path, // Extracts form_id from the path +) -> ActixResult { + let form_id = path.into_inner(); + log::info!( + "User {} requesting submissions for form_id: {}", + auth.user_id, + form_id + ); + + let conn = app_state.db.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; + + // 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") + } + })?; + + // 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], |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)?; + + 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, + 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() + })) +} + + + +// src/main.rs +use actix_cors::Cors; +use actix_files as fs; +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 std::env; +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<()> { + // Load environment variables from .env file + dotenv().ok(); + + // 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) --- + 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() + }); + + 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() + }); + + // 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 { + 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"); + } + 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) => { + if e.to_string().contains("INITIAL_ADMIN_USERNAME") + || e.to_string().contains("INITIAL_ADMIN_PASSWORD") + { + error!("FATAL: {}", e); + error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables."); + } else { + error!( + "FATAL: Failed to initialize database at {}: {:?}", + database_url, e + ); + } + process::exit(1); + } + }; + + // 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)); + + // 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 || { + 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 = 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) + .wrap(Logger::default()) + .wrap(tracing_actix_web::TracingLogger::default()) + .wrap(rate_limiter) + .app_data(app_state) + .service( + web::scope("/api") + // Health check endpoint + .route("/health", web::get().to(handlers::health_check)) + // Public routes + .route("/login", web::post().to(handlers::login)) + .route( + "/forms/{form_id}/submissions", + web::post().to(handlers::submit_form), + ) + // 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), + ), + ) + .service( + fs::Files::new("/", "./frontend/") + .index_file("index.html") + .use_last_modified(true) + .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)? + .run() + .await +} + + + +// 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}; + +// 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, + pub notify_email: Option, + pub notify_ntfy_topic: Option, + pub created_at: DateTime, +} + +// 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, + pub created_at: DateTime, +} + +// 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 +} + +// 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, +} + + + +# Formies Backend + +A production-ready Rust backend for the Formies application. + +## Features + +- RESTful API endpoints +- SQLite database with connection pooling +- JWT-based authentication +- Rate limiting +- Structured logging +- Error tracking with Sentry +- Health check endpoint +- CORS support +- Configuration management +- Metrics endpoint + +## Prerequisites + +- Rust 1.70 or later +- SQLite 3 +- Make (optional, for using Makefile commands) + +## Configuration + +The application can be configured using environment variables or a configuration file. The following environment variables are supported: + +### Required Environment Variables + +- `DATABASE_URL`: SQLite database URL (default: form_data.db) +- `BIND_ADDRESS`: Server bind address (default: 127.0.0.1:8080) +- `INITIAL_ADMIN_USERNAME`: Initial admin username +- `INITIAL_ADMIN_PASSWORD`: Initial admin password + +### Optional Environment Variables + +- `ALLOWED_ORIGIN`: CORS allowed origin +- `RUST_LOG`: Log level (default: info) +- `SENTRY_DSN`: Sentry DSN for error tracking +- `JWT_SECRET`: JWT secret key +- `JWT_EXPIRATION`: JWT expiration time in seconds + +## Development + +1. Clone the repository +2. Install dependencies: + ```bash + cargo build + ``` +3. Set up environment variables: + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` +4. Run the development server: + ```bash + cargo run + ``` + +## Production Deployment + +### Docker + +1. Build the Docker image: + + ```bash + docker build -t formies-backend . + ``` + +2. Run the container: + ```bash + docker run -d \ + --name formies-backend \ + -p 8080:8080 \ + -v $(pwd)/data:/app/data \ + -e DATABASE_URL=/app/data/form_data.db \ + -e BIND_ADDRESS=0.0.0.0:8080 \ + -e INITIAL_ADMIN_USERNAME=admin \ + -e INITIAL_ADMIN_PASSWORD=your-secure-password \ + -e ALLOWED_ORIGIN=https://your-frontend-domain.com \ + -e SENTRY_DSN=your-sentry-dsn \ + formies-backend + ``` + +### Systemd Service + +1. Create a systemd service file at `/etc/systemd/system/formies-backend.service`: + + ```ini + [Unit] + Description=Formies Backend Service + After=network.target + + [Service] + Type=simple + User=formies + WorkingDirectory=/opt/formies-backend + ExecStart=/opt/formies-backend/formies-be + Restart=always + Environment=DATABASE_URL=/opt/formies-backend/data/form_data.db + Environment=BIND_ADDRESS=0.0.0.0:8080 + Environment=INITIAL_ADMIN_USERNAME=admin + Environment=INITIAL_ADMIN_PASSWORD=your-secure-password + Environment=ALLOWED_ORIGIN=https://your-frontend-domain.com + Environment=SENTRY_DSN=your-sentry-dsn + + [Install] + WantedBy=multi-user.target + ``` + +2. Enable and start the service: + ```bash + sudo systemctl enable formies-backend + sudo systemctl start formies-backend + ``` + +## Monitoring + +### Health Check + +The application exposes a health check endpoint at `/api/health`: + +```bash +curl http://localhost:8080/api/health +``` + +### Metrics + +Metrics are available at `/metrics` when enabled in the configuration. + +### Logging + +Logs are written to the configured log file and can be viewed using: + +```bash +tail -f logs/app.log +``` + +## Security + +- All API endpoints are rate-limited +- CORS is configured to only allow specified origins +- JWT tokens are used for authentication +- Passwords are hashed using bcrypt +- SQLite database is protected with proper file permissions + +## License + +MIT + + + +name: Build and Push Docker Image + +on: + push: + branches: + - build + +jobs: + build_and_push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install Docker + run: | + sudo apt-get update + sudo apt-get install -y docker.io + + - name: Build Docker image + run: | + docker build -t git.vinylnostalgia.com/mo/formies:latest . + + - name: Push Docker image to Gitea + env: + GITEA_USERNAME: ${{ secrets.ME_USERNAME }} + GITEA_PASSWORD: ${{ secrets.ME_PASSWORD }} + run: | + echo $GITEA_PASSWORD | docker login git.vinylnostalgia.com -u $GITEA_USERNAME --password-stdin + docker push git.vinylnostalgia.com/mo/formies:latest + + + +# Build stage +FROM rust:1.70-slim as builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy source code +COPY . . + +# Build the application +RUN cargo build --release + +# Runtime stage +FROM debian:bullseye-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + libsqlite3-0 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create necessary directories +RUN mkdir -p /app/data /app/logs + +# Copy the binary from builder +COPY --from=builder /app/target/release/formies-be /app/ + +# Copy configuration +COPY config/default.toml /app/config/default.toml + +# Set environment variables +ENV RUST_LOG=info +ENV DATABASE_URL=/app/data/form_data.db +ENV BIND_ADDRESS=0.0.0.0:8080 + +# Expose port +EXPOSE 8080 + +# Set proper permissions +RUN chown -R nobody:nogroup /app +USER nobody + +# Run the application +CMD ["./formies-be"] + + + diff --git a/src/auth.rs b/src/auth.rs index 75b7620..1d4b4a3 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,14 +5,17 @@ use actix_web::{ dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest, HttpRequest, }; +use chrono::Utc; use futures::future::{ready, Ready}; use log; // Use the log crate +use rusqlite::params; use rusqlite::Connection; use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely) // Represents an authenticated user via token pub struct Auth { pub user_id: String, + pub role: String, } impl FromRequest for Auth { @@ -62,23 +65,30 @@ impl FromRequest for Auth { } }; - // Validate the token against the database (now includes expiration check) - match super::db::validate_token(&conn_guard, token) { - // Token is valid and not expired, return Ok with Auth struct - Ok(Some(user_id)) => { - log::debug!("Token validated successfully for user_id: {}", user_id); - ready(Ok(Auth { user_id })) + // Get user_id and role from token + let user_result = conn_guard + .query_row( + "SELECT u.id, u.role FROM users u WHERE u.token = ?1 AND u.token_expires_at > ?2", + params![token, Utc::now().to_rfc3339()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional(); + + match user_result { + Ok(Some((user_id, role))) => { + log::debug!( + "Token validated successfully for user_id: {} with role: {}", + user_id, + role + ); + ready(Ok(Auth { user_id, role })) } - // Token is invalid, not found, or expired Ok(None) => { - log::warn!("Invalid or expired token received"); // Avoid logging token + log::warn!("Invalid or expired token received"); ready(Err(ErrorUnauthorized("Invalid or expired token"))) } - // Database error during token validation Err(e) => { log::error!("Database error during token validation: {:?}", e); - // Return Unauthorized to avoid leaking internal error details - // Consider mapping specific DB errors if needed, but Unauthorized is generally safe ready(Err(ErrorUnauthorized("Token validation failed"))) } } @@ -99,3 +109,11 @@ impl FromRequest for Auth { } } } + +// Helper function to check if a user has admin role +pub fn require_admin(auth: &Auth) -> Result<(), ActixWebError> { + if auth.role != "admin" { + return Err(ErrorUnauthorized("Admin access required")); + } + Ok(()) +} diff --git a/src/db.rs b/src/db.rs index 2a52f67..c210be8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -24,8 +24,10 @@ pub fn init_db(database_url: &str) -> AnyhowResult { id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, -- Stores bcrypt hashed password + role TEXT NOT NULL DEFAULT 'user', -- 'admin' or 'user' token TEXT UNIQUE, -- Stores the current session token (UUID) - token_expires_at DATETIME -- Timestamp when the token expires + token_expires_at DATETIME, -- Timestamp when the token expires + created_at DATETIME DEFAULT CURRENT_TIMESTAMP )", [], ) @@ -37,9 +39,11 @@ pub fn init_db(database_url: &str) -> AnyhowResult { id TEXT PRIMARY KEY, name TEXT NOT NULL, fields TEXT NOT NULL, -- Stores JSON definition of form fields + owner_id TEXT NOT NULL, -- Reference to the user who created the form notify_email TEXT, -- Optional email address for notifications notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE )", [], ) @@ -103,8 +107,13 @@ fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> { // Check password complexity? (Optional enhancement) - add_user_if_not_exists(conn, &initial_admin_username, &initial_admin_password) - .context("Failed during initial admin user setup")?; + add_user_if_not_exists( + conn, + &initial_admin_username, + &initial_admin_password, + Some("admin"), + ) + .context("Failed during initial admin user setup")?; Ok(()) } @@ -113,6 +122,7 @@ pub fn add_user_if_not_exists( conn: &Connection, username: &str, password: &str, + role: Option<&str>, // Optional role parameter ) -> AnyhowResult { // Check if user already exists let user_exists: bool = conn @@ -142,11 +152,19 @@ pub fn add_user_if_not_exists( ); let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?; - // Insert the new user (token and expiry are initially NULL) - log::info!("Creating new user '{}' with ID: {}", username, user_id); + // Use provided role or default to "user" + let role = role.unwrap_or("user"); + + // Insert the new user + log::info!( + "Creating new user '{}' with ID: {} and role: {}", + username, + user_id, + role + ); conn.execute( - "INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)", - params![user_id, username, hashed_password], + "INSERT INTO users (id, username, password, role) VALUES (?1, ?2, ?3, ?4)", + params![user_id, username, hashed_password, role], ) .context(format!("Failed to insert user '{}'", username))?; @@ -268,7 +286,7 @@ pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> Anyh // Fetch a specific form definition by its ID pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult> { let mut stmt = conn - .prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1") + .prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1") .context("Failed to prepare query for fetching form")?; let result = stmt @@ -276,9 +294,10 @@ pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult = row.get(3)?; - let notify_ntfy_topic: Option = row.get(4)?; // Get the new field - let created_at: chrono::DateTime = row.get(5)?; + let owner_id: String = row.get(3)?; + let notify_email: Option = row.get(4)?; + let notify_ntfy_topic: Option = row.get(5)?; + let created_at: chrono::DateTime = row.get(6)?; // Parse the fields JSON string let fields = serde_json::from_str(&fields_str).map_err(|e| { @@ -293,8 +312,9 @@ pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult AnyhowResult { get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id)) - // Added ID to error } } @@ -354,3 +375,99 @@ impl models::Submission { Ok(()) } } + +// Get user by ID +pub fn get_user_by_id(conn: &Connection, user_id: &str) -> AnyhowResult> { + let mut stmt = + conn.prepare("SELECT id, username, role, created_at FROM users WHERE id = ?1")?; + + let result = stmt + .query_row(params![user_id], |row| { + Ok(models::User { + id: row.get(0)?, + username: row.get(1)?, + password: None, // Never return password + role: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .optional()?; + + Ok(result) +} + +// Get user by username +pub fn get_user_by_username( + conn: &Connection, + username: &str, +) -> AnyhowResult> { + let mut stmt = + conn.prepare("SELECT id, username, role, created_at FROM users WHERE username = ?1")?; + + let result = stmt + .query_row(params![username], |row| { + Ok(models::User { + id: row.get(0)?, + username: row.get(1)?, + password: None, // Never return password + role: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .optional()?; + + Ok(result) +} + +// List all users (for admin use) +pub fn list_users(conn: &Connection) -> AnyhowResult> { + let mut stmt = conn.prepare("SELECT id, username, role, created_at FROM users")?; + + let users_iter = stmt.query_map([], |row| { + Ok(models::User { + id: row.get(0)?, + username: row.get(1)?, + password: None, // Never return password + role: row.get(2)?, + created_at: row.get(3)?, + }) + })?; + + let mut users = Vec::new(); + for user_result in users_iter { + users.push(user_result?); + } + + Ok(users) +} + +// Update user +pub fn update_user( + conn: &Connection, + user_id: &str, + update: &models::UserUpdate, +) -> AnyhowResult<()> { + if let Some(username) = &update.username { + conn.execute( + "UPDATE users SET username = ?1 WHERE id = ?2", + params![username, user_id], + )?; + } + + if let Some(password) = &update.password { + let hashed_password = hash(password, DEFAULT_COST)?; + conn.execute( + "UPDATE users SET password = ?1 WHERE id = ?2", + params![hashed_password, user_id], + )?; + } + + Ok(()) +} + +// Delete user +pub fn delete_user(conn: &Connection, user_id: &str) -> AnyhowResult { + let rows_affected = conn.execute("DELETE FROM users WHERE id = ?1", params![user_id])?; + + Ok(rows_affected > 0) +} diff --git a/src/handlers.rs b/src/handlers.rs index 2a00411..54ae05d 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,5 +1,7 @@ use crate::auth::Auth; -use crate::models::{Form, LoginCredentials, LoginResponse, Submission}; +use crate::models::{ + Form, LoginCredentials, LoginResponse, Submission, User, UserRegistration, UserUpdate, +}; use crate::AppState; use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult}; use chrono; // Only import the module since we use it qualified @@ -11,6 +13,23 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use uuid::Uuid; +// Added imports for CAPTCHA verification +use actix_web::HttpRequest; +use reqwest; +use serde::Deserialize; + +// Added for throttling +use std::time::{Duration, Instant}; + +// --- Struct for CAPTCHA Verification Response --- +#[derive(Deserialize, Debug)] +struct CaptchaVerificationResponse { + success: bool, + // Providers might include other fields like challenge_ts, hostname, error-codes + #[serde(rename = "error-codes")] + error_codes: Option>, +} + // --- Helper Function for Validation --- /// Validates submission data against the form field definitions with enhanced checks. @@ -354,12 +373,167 @@ pub async fn logout( // POST /forms/{form_id}/submissions pub async fn submit_form( + req: HttpRequest, // Add HttpRequest to access connection info app_state: web::Data, path: web::Path, // Extracts form_id from path submission_payload: web::Json, // Expect arbitrary JSON payload ) -> ActixResult { let form_id = path.into_inner(); - let conn = app_state.db.lock().map_err(|e| { + // Use .get_ref() to borrow AppState without consuming web::Data + let app_state_ref = app_state.get_ref(); + let captcha_config = &app_state_ref.captcha_config; + + // --- Per-Form Per-IP Rate Limiting --- + const RATE_LIMIT_DURATION: Duration = Duration::from_secs(60); // 1 minute window + const RATE_LIMIT_MAX_ATTEMPTS: u32 = 5; // Max 5 attempts per window + + let client_ip_opt = req + .connection_info() + .realip_remote_addr() + .map(|s| s.to_string()); + + if let Some(client_ip) = client_ip_opt { + let mut attempts_map = app_state_ref.form_submission_attempts.lock().map_err(|e| { + log::error!("Failed to acquire rate limit lock: {}", e); + actix_web::error::ErrorInternalServerError("Internal error (rate limit state)") + })?; + + let now = Instant::now(); + let form_attempts = attempts_map.entry(form_id.clone()).or_default(); + let (last_attempt, count) = form_attempts.entry(client_ip.clone()).or_insert((now, 0)); + + if now.duration_since(*last_attempt) > RATE_LIMIT_DURATION { + // Reset count if window expired + *last_attempt = now; + *count = 1; + } else { + // Increment count within the window + *count += 1; + } + + log::debug!( + "Rate limit check for form '{}', IP '{}': attempt count = {}, last attempt = {:?}", + form_id, + client_ip, + *count, + last_attempt + ); + + if *count > RATE_LIMIT_MAX_ATTEMPTS { + log::warn!( + "Rate limit exceeded for form '{}', IP '{}'. Count: {}. Blocking request.", + form_id, + client_ip, + *count + ); + // Consider clearing the entry after a longer block duration if needed + return Ok(HttpResponse::TooManyRequests().json(json!({ + "error": "rate_limit_exceeded", + "message": "Too many submission attempts. Please try again later." + }))); + } + } else { + // Cannot rate limit if IP address is unknown + log::warn!("Could not determine client IP for rate limiting."); + } + // --- End Rate Limiting --- + + let payload_value = submission_payload.into_inner(); // Get the owned JsonValue + + // --- CAPTCHA Verification --- + if captcha_config.enabled { + let captcha_token = payload_value.get("captcha_token").and_then(|v| v.as_str()); + + match captcha_token { + Some(token) if !token.is_empty() => { + // Get client IP address + let client_ip = req + .connection_info() + .realip_remote_addr() + .map(|s| s.to_string()); + // Note: Ensure Actix is configured correctly behind a proxy if needed + // using .forwarded_for() or similar mechanisms if realip_remote_addr() isn't sufficient. + + log::debug!( + "Verifying CAPTCHA token for IP: {:?}", + client_ip.as_deref().unwrap_or("Unknown") + ); + + let mut params = HashMap::new(); + params.insert("secret", captcha_config.secret_key.as_str()); + params.insert("response", token); + if let Some(ip) = client_ip.as_deref() { + params.insert("remoteip", ip); + } + + // Consider creating the client once and storing it in AppState for reuse + let client = reqwest::Client::new(); + let res = client + .post(&captcha_config.verification_url) + .form(¶ms) + .send() + .await; + + match res { + Ok(response) => { + if response.status().is_success() { + match response.json::().await { + Ok(verification_response) => { + if verification_response.success { + log::info!("CAPTCHA verification successful."); + } else { + log::warn!( + "CAPTCHA verification failed: {:?}", + verification_response.error_codes + ); + return Ok(HttpResponse::BadRequest().json(json!({ + "error": "captcha_verification_failed", + "message": "Invalid CAPTCHA token." + }))); + } + } + Err(e) => { + log::error!( + "Failed to parse CAPTCHA verification response: {}", + e + ); + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": "captcha_provider_error", + "message": "Failed to process CAPTCHA provider response." + }))); + } + } + } else { + log::error!( + "CAPTCHA provider request failed with status: {}", + response.status() + ); + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": "captcha_provider_error", + "message": "Could not reach CAPTCHA provider." + }))); + } + } + Err(e) => { + log::error!("Failed to send CAPTCHA verification request: {}", e); + return Ok(HttpResponse::InternalServerError().json(json!({ + "error": "captcha_provider_error", + "message": "Failed to send request to CAPTCHA provider." + }))); + } + } + } + _ => { + log::warn!("CAPTCHA enabled, but no valid token provided in submission."); + return Ok(HttpResponse::BadRequest().json(json!({ "error": "captcha_token_missing", "message": "CAPTCHA token is required."}))); + } + } + } + // --- End CAPTCHA Verification --- + + // Lock DB connection AFTER CAPTCHA check + // Use app_state_ref here as well + let conn = app_state_ref.db.lock().map_err(|e| { log::error!("Failed to acquire database lock: {}", e); actix_web::error::ErrorInternalServerError("Database error") })?; @@ -367,9 +541,9 @@ pub async fn submit_form( // Get form definition let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?; - // Validate submission against form definition + // Validate submission against form definition (using the owned payload_value) if let Err(validation_errors) = - validate_submission_against_definition(&submission_payload, &form.fields) + validate_submission_against_definition(&payload_value, &form.fields) { return Ok(HttpResponse::BadRequest().json(validation_errors)); } @@ -378,7 +552,7 @@ pub async fn submit_form( let submission = Submission { id: Uuid::new_v4().to_string(), form_id: form_id.clone(), - data: submission_payload.into_inner(), + data: payload_value, // Store the full validated payload (including captcha_token if sent) created_at: chrono::Utc::now(), }; @@ -388,42 +562,96 @@ pub async fn submit_form( actix_web::error::ErrorInternalServerError("Failed to save submission") })?; - // Send notifications if configured - if let Some(notify_email) = form.notify_email { - let email_subject = format!("New submission for form: {}", form.name); - let email_body = format!( - "A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}", - form.name, - submission.id, - submission.created_at, - serde_json::to_string_pretty(&submission.data).unwrap_or_default() - ); + // --- Notification Throttling & Sending --- + const NOTIFICATION_THROTTLE_DURATION: Duration = Duration::from_secs(60); + let mut should_send_notification = true; // Assume we should send initially - if let Err(e) = app_state - .notification_service - .send_email(¬ify_email, &email_subject, &email_body) - .await - { - log::warn!("Failed to send email notification: {}", e); + // Check if notifications are configured for this form at all + let notifications_configured = form.notify_email.is_some() + || form + .notify_ntfy_topic + .as_ref() + .map_or(false, |s| !s.is_empty()); + + if notifications_configured { + let mut last_times = app_state_ref.last_notification_times.lock().map_err(|e| { + log::error!("Failed to acquire notification throttle lock: {}", e); + actix_web::error::ErrorInternalServerError("Internal error (notification state)") + })?; + + let now = Instant::now(); + if let Some(last_time) = last_times.get(&form_id) { + if now.duration_since(*last_time) < NOTIFICATION_THROTTLE_DURATION { + log::info!( + "Notification throttled for form_id: {}. Last sent {:?} ago.", + form_id, + now.duration_since(*last_time) + ); + should_send_notification = false; + } } - // Also send ntfy notification if configured (sends to the global topic) + // If not throttled, update the timestamp *before* attempting to send + if should_send_notification { + log::debug!("Updating last notification time for form_id: {}", form_id); + last_times.insert(form_id.clone(), now); + } + } else { + should_send_notification = false; // Don't attempt if not configured + } + + // Send notifications only if not throttled and configured + if should_send_notification { + log::info!("Attempting to send notifications for form_id: {}", form_id); + // Send Email if configured + if let Some(notify_email) = &form.notify_email { + let email_subject = format!("New submission for form: {}", form.name); + let email_body = format!( + "A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}", + form.name, + submission.id, + submission.created_at, + serde_json::to_string_pretty(&submission.data).unwrap_or_default() + ); + // Use a clone of notification_service if it needs to move into async block + let notification_service_clone = app_state_ref.notification_service.clone(); + let notify_email_clone = notify_email.clone(); + let email_subject_clone = email_subject.clone(); + let email_body_clone = email_body.clone(); + + // Spawn email sending as a background task so it doesn't block the response + tokio::spawn(async move { + if let Err(e) = notification_service_clone + .send_email(¬ify_email_clone, &email_subject_clone, &email_body_clone) + .await + { + log::warn!( + "Failed to send email notification in background task: {}", + e + ); + } + }); + } + + // Send ntfy if configured if let Some(topic_flag) = &form.notify_ntfy_topic { - // Use field presence as a flag if !topic_flag.is_empty() { - // Check if the flag string is non-empty let ntfy_title = format!("New submission for: {}", form.name); let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id); - if let Err(e) = app_state.notification_service.send_ntfy( + // Ntfy send is synchronous in the current implementation, can block + // Consider spawning if it becomes slow + if let Err(e) = app_state_ref.notification_service.send_ntfy( &ntfy_title, &ntfy_message, Some(3), // Medium priority ) { - log::warn!("Failed to send ntfy notification (global topic): {}", e); + log::warn!("Failed to send ntfy notification: {}", e); + // Don't return error to client, just log } } } - } + } // End if should_send_notification + // --- End Notification Throttling & Sending --- Ok(HttpResponse::Created().json(json!({ "message": "Submission received", @@ -434,172 +662,189 @@ pub async fn submit_form( // POST /forms pub async fn create_form( app_state: web::Data, - _auth: Auth, // Authentication check via Auth extractor - payload: web::Json, + auth: Auth, + form_data: web::Json, ) -> ActixResult { - let payload = payload.into_inner(); + let mut form = form_data.into_inner(); + form.owner_id = auth.user_id.clone(); // Set the owner_id to the authenticated user's ID - // Extract form data from payload - let name = payload["name"] - .as_str() - .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))? - .to_string(); - - let fields = payload["fields"].clone(); - if !fields.is_array() { - return Err(actix_web::error::ErrorBadRequest( - "'fields' must be a JSON array", - )); - } - - let notify_email = payload["notify_email"].as_str().map(|s| s.to_string()); - let notify_ntfy_topic = payload["notify_ntfy_topic"].as_str().map(|s| s.to_string()); - - // Create new form - let form = Form { - id: None, // Will be generated during save - name, - fields, - notify_email, - notify_ntfy_topic, - created_at: chrono::Utc::now(), - }; - - // Save the form - let conn = app_state.db.lock().map_err(|e| { - log::error!("Failed to acquire database lock: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - form.save(&conn).map_err(|e| { - log::error!("Failed to save form: {}", e); - actix_web::error::ErrorInternalServerError("Failed to save form") - })?; + let db_conn_arc = app_state.db.clone(); + web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; + form.save(&conn) + }) + .await + .map_err(|e| { + log::error!("web::block error while creating form: {:?}", e); + actix_web::error::ErrorInternalServerError("Failed to create form") + })? + .map_err(anyhow_to_actix_error)?; Ok(HttpResponse::Created().json(form)) } // GET /forms -pub async fn get_forms( - app_state: web::Data, - auth: Auth, // Requires authentication -) -> ActixResult { - log::info!("User {} requesting list of forms", auth.user_id); +pub async fn get_forms(app_state: web::Data, auth: Auth) -> ActixResult { + let db_conn_arc = app_state.db.clone(); + let user_id = auth.user_id.clone(); + let is_admin = auth.role == "admin"; - let conn = app_state.db.lock().map_err(|e| { - log::error!("Failed to acquire database lock: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; + let forms = web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - let mut stmt = conn - .prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms") - .map_err(|e| { - log::error!("Failed to prepare statement: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; + let mut stmt = if is_admin { + // Admins can see all forms + conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms")? + } else { + // Regular users can only see their own forms + conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms WHERE owner_id = ?1")? + }; - let forms_iter = stmt - .query_map([], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let fields_str: String = row.get(2)?; - let notify_email: Option = row.get(3)?; - let notify_ntfy_topic: Option = row.get(4)?; - let created_at: chrono::DateTime = row.get(5)?; + let forms_iter = if is_admin { + stmt.query_map([], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let fields_str: String = row.get(2)?; + let owner_id: String = row.get(3)?; + let notify_email: Option = row.get(4)?; + let notify_ntfy_topic: Option = row.get(5)?; + let created_at: chrono::DateTime = row.get(6)?; - // Parse the 'fields' JSON string - let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| { - log::error!( - "DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.", - id, - e - ); - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; + let fields = serde_json::from_str(&fields_str).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; - Ok(Form { - id: Some(id), - name, - fields, - notify_email, - notify_ntfy_topic, - created_at, - }) - }) - .map_err(|e| { - log::error!("Failed to execute query: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; + Ok(Form { + id: Some(id), + name, + fields, + owner_id, + notify_email, + notify_ntfy_topic, + created_at, + }) + })? + } else { + stmt.query_map(params![user_id], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let fields_str: String = row.get(2)?; + let owner_id: String = row.get(3)?; + let notify_email: Option = row.get(4)?; + let notify_ntfy_topic: Option = row.get(5)?; + let created_at: chrono::DateTime = row.get(6)?; - // 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(); + let fields = serde_json::from_str(&fields_str).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; + + Ok(Form { + id: Some(id), + name, + fields, + owner_id, + notify_email, + notify_ntfy_topic, + created_at, + }) + })? + }; + + let mut forms = Vec::new(); + for form_result in forms_iter { + forms.push(form_result?); + } + + Ok::<_, anyhow::Error>(forms) + }) + .await + .map_err(|e| { + log::error!("web::block error while fetching forms: {:?}", e); + actix_web::error::ErrorInternalServerError("Failed to fetch forms") + })? + .map_err(anyhow_to_actix_error)?; - log::debug!("Returning {} forms for user {}", forms.len(), auth.user_id); Ok(HttpResponse::Ok().json(forms)) } // GET /forms/{form_id}/submissions pub async fn get_submissions( app_state: web::Data, - auth: Auth, // Requires authentication - path: web::Path, // Extracts form_id from the path + auth: Auth, + form_id: web::Path, ) -> ActixResult { - let form_id = path.into_inner(); - log::info!( - "User {} requesting submissions for form_id: {}", - auth.user_id, - form_id - ); + let db_conn_arc = app_state.db.clone(); + let form_id_str = form_id.into_inner(); + let user_id = auth.user_id.clone(); + let is_admin = auth.role == "admin"; - let conn = app_state.db.lock().map_err(|e| { - log::error!("Failed to acquire database lock: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; + // First check if the user has access to this form + let can_access = web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - // Check if the form exists - let _form = Form::get_by_id(&conn, &form_id).map_err(|e| { - if e.to_string().contains("not found") { - actix_web::error::ErrorNotFound("Form not found") - } else { - actix_web::error::ErrorInternalServerError("Database error") + if is_admin { + // Admins can access all forms + return Ok(true); } - })?; - // Get submissions - let mut stmt = conn - .prepare( - "SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", - ) - .map_err(|e| { - log::error!("Failed to prepare statement: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; + // Check if the form belongs to the user + let owner_id: Option = conn + .query_row( + "SELECT owner_id FROM forms WHERE id = ?1", + params![form_id_str], + |row| row.get(0), + ) + .optional()?; - let submissions_iter = stmt - .query_map(params![form_id], |row| { + match owner_id { + Some(owner_id) => Ok(owner_id == user_id), + None => Ok(false), // Form doesn't exist + } + }) + .await + .map_err(|e| { + log::error!("web::block error while checking form access: {:?}", e); + actix_web::error::ErrorInternalServerError("Failed to check form access") + })? + .map_err(anyhow_to_actix_error)?; + + if !can_access { + return Err(actix_web::error::ErrorForbidden("Access denied")); + } + + // Now fetch the submissions + let db_conn_arc = app_state.db.clone(); + let form_id_str = form_id.into_inner(); + + let submissions = web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; + + let mut stmt = conn + .prepare("SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1")?; + + let submissions_iter = stmt.query_map(params![form_id_str], |row| { let id: String = row.get(0)?; let form_id: String = row.get(1)?; let data_str: String = row.get(2)?; let created_at: chrono::DateTime = row.get(3)?; - let data: serde_json::Value = serde_json::from_str(&data_str).map_err(|e| { - log::error!( - "DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.", - id, - e - ); + let data = serde_json::from_str(&data_str).map_err(|e| { rusqlite::Error::FromSqlConversionFailure( 2, rusqlite::types::Type::Text, @@ -613,28 +858,22 @@ pub async fn get_submissions( data, created_at, }) - }) - .map_err(|e| { - log::error!("Failed to execute query: {}", e); - actix_web::error::ErrorInternalServerError("Database error") })?; - let submissions: Vec = submissions_iter - .filter_map(|result| match result { - Ok(submission) => Some(submission), - Err(e) => { - log::warn!("Skipping a submission row due to processing error: {}", e); - None - } - }) - .collect(); + let mut submissions = Vec::new(); + for submission_result in submissions_iter { + submissions.push(submission_result?); + } + + Ok::<_, anyhow::Error>(submissions) + }) + .await + .map_err(|e| { + log::error!("web::block error while fetching submissions: {:?}", e); + actix_web::error::ErrorInternalServerError("Failed to fetch submissions") + })? + .map_err(anyhow_to_actix_error)?; - log::debug!( - "Returning {} submissions for form {} requested by user {}", - submissions.len(), - form_id, - auth.user_id - ); Ok(HttpResponse::Ok().json(submissions)) } @@ -749,3 +988,172 @@ pub async fn health_check() -> impl Responder { "timestamp": chrono::Utc::now().to_rfc3339() })) } + +// POST /register +pub async fn register( + app_state: web::Data, + registration: web::Json, +) -> ActixResult { + let db_conn_arc = app_state.db.clone(); + let username = registration.username.clone(); + let password = registration.password.clone(); + + // Register user in a blocking operation + let result = web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned during registration"))?; + + // Check if username already exists + if let Some(_) = crate::db::get_user_by_username(&conn, &username)? { + return Err(anyhow::anyhow!("Username already exists")); + } + + // Add new user with default role "user" + crate::db::add_user_if_not_exists(&conn, &username, &password, None) + }) + .await + .map_err(|e| { + log::error!("web::block error during registration: {:?}", e); + actix_web::error::ErrorInternalServerError("Registration process failed") + })? + .map_err(|e| { + if e.to_string().contains("already exists") { + actix_web::error::ErrorConflict(e.to_string()) + } else { + actix_web::error::ErrorInternalServerError(e.to_string()) + } + })?; + + Ok(HttpResponse::Created().json(json!({ + "message": "User registered successfully" + }))) +} + +// GET /users (admin only) +pub async fn list_users(app_state: web::Data, auth: Auth) -> ActixResult { + // Check admin role + crate::auth::require_admin(&auth)?; + + let db_conn_arc = app_state.db.clone(); + + let users = web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; + crate::db::list_users(&conn) + }) + .await + .map_err(|e| { + log::error!("web::block error while listing users: {:?}", e); + actix_web::error::ErrorInternalServerError("Failed to list users") + })? + .map_err(anyhow_to_actix_error)?; + + Ok(HttpResponse::Ok().json(users)) +} + +// GET /users/{user_id} (admin or self) +pub async fn get_user( + app_state: web::Data, + auth: Auth, + user_id: web::Path, +) -> ActixResult { + // Allow if admin or if user is requesting their own data + if auth.role != "admin" && auth.user_id != user_id.as_str() { + return Err(actix_web::error::ErrorForbidden("Access denied")); + } + + let db_conn_arc = app_state.db.clone(); + let user_id_str = user_id.into_inner(); + + let user = web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; + crate::db::get_user_by_id(&conn, &user_id_str) + }) + .await + .map_err(|e| { + log::error!("web::block error while fetching user: {:?}", e); + actix_web::error::ErrorInternalServerError("Failed to fetch user") + })? + .map_err(anyhow_to_actix_error)?; + + match user { + Some(user) => Ok(HttpResponse::Ok().json(user)), + None => Ok(HttpResponse::NotFound().json(json!({ + "message": "User not found" + }))), + } +} + +// PUT /users/{user_id} (admin or self) +pub async fn update_user( + app_state: web::Data, + auth: Auth, + user_id: web::Path, + update: web::Json, +) -> ActixResult { + // Allow if admin or if user is updating their own data + if auth.role != "admin" && auth.user_id != user_id.as_str() { + return Err(actix_web::error::ErrorForbidden("Access denied")); + } + + let db_conn_arc = app_state.db.clone(); + let user_id_str = user_id.into_inner(); + let update_data = update.into_inner(); + + web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; + crate::db::update_user(&conn, &user_id_str, &update_data) + }) + .await + .map_err(|e| { + log::error!("web::block error while updating user: {:?}", e); + actix_web::error::ErrorInternalServerError("Failed to update user") + })? + .map_err(anyhow_to_actix_error)?; + + Ok(HttpResponse::Ok().json(json!({ + "message": "User updated successfully" + }))) +} + +// DELETE /users/{user_id} (admin only) +pub async fn delete_user( + app_state: web::Data, + auth: Auth, + user_id: web::Path, +) -> ActixResult { + // Only admins can delete users + crate::auth::require_admin(&auth)?; + + let db_conn_arc = app_state.db.clone(); + let user_id_str = user_id.into_inner(); + + let deleted = web::block(move || { + let conn = db_conn_arc + .lock() + .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; + crate::db::delete_user(&conn, &user_id_str) + }) + .await + .map_err(|e| { + log::error!("web::block error while deleting user: {:?}", e); + actix_web::error::ErrorInternalServerError("Failed to delete user") + })? + .map_err(anyhow_to_actix_error)?; + + if deleted { + Ok(HttpResponse::Ok().json(json!({ + "message": "User deleted successfully" + }))) + } else { + Ok(HttpResponse::NotFound().json(json!({ + "message": "User not found" + }))) + } +} diff --git a/src/main.rs b/src/main.rs index af0a0e9..4f337ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,10 +9,13 @@ use std::env; use std::io::Result as IoResult; use std::process; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tracing::{error, info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +// Added for throttling map +use std::collections::HashMap; + // Import modules mod auth; mod db; @@ -22,10 +25,54 @@ mod notifications; use notifications::{NotificationConfig, NotificationService}; +// --- CAPTCHA Configuration --- +#[derive(Clone, Debug)] +pub struct CaptchaConfig { + pub enabled: bool, + pub secret_key: String, + pub verification_url: String, // e.g., "https://hcaptcha.com/siteverify" +} + +impl CaptchaConfig { + // Function to load from environment variables + pub fn from_env() -> Result { + // Return VarError for simplicity + let enabled = std::env::var("CAPTCHA_ENABLED") + .map(|v| v.parse().unwrap_or(false)) + .unwrap_or(false); // Default to false if not set or parse error + + // Use Ok variant of Result for keys, default to empty if not found + let secret_key = std::env::var("CAPTCHA_SECRET_KEY").unwrap_or_default(); + let verification_url = std::env::var("CAPTCHA_VERIFICATION_URL").unwrap_or_default(); + + // Basic validation: if enabled, secret key and URL must be present + if enabled && (secret_key.is_empty() || verification_url.is_empty()) { + warn!("CAPTCHA_ENABLED is true, but CAPTCHA_SECRET_KEY or CAPTCHA_VERIFICATION_URL is missing. CAPTCHA will be effectively disabled."); + Ok(Self { + enabled: false, // Force disable if config is incomplete + secret_key, + verification_url, + }) + } else { + Ok(Self { + enabled, + secret_key, + verification_url, + }) + } + } +} +// --- End CAPTCHA Configuration --- + // Application state that will be shared across all routes pub struct AppState { db: Arc>, notification_service: Arc, + captcha_config: CaptchaConfig, + // Map form_id to the Instant of the last notification attempt for that form + last_notification_times: Arc>>, + // Map form_id -> ip_address -> (last_attempt_time, count) for rate limiting + form_submission_attempts: Arc>>>, } #[actix_web::main] @@ -143,16 +190,38 @@ async fn main() -> IoResult<()> { }); let notification_service = Arc::new(NotificationService::new(notification_config)); - // Create AppState with both database and notification service + // Load CAPTCHA Configuration + let captcha_config = CaptchaConfig::from_env().unwrap_or_else(|e| { + warn!( + "Failed to load CAPTCHA configuration: {}. CAPTCHA will be disabled.", + e + ); + // Ensure default is truly disabled + CaptchaConfig { + enabled: false, + secret_key: String::new(), + verification_url: String::new(), + } + }); + if captcha_config.enabled { + info!("CAPTCHA verification is ENABLED."); + } else { + info!("CAPTCHA verification is DISABLED (or required env vars missing)."); + } + + // Create AppState with all services let app_state = web::Data::new(AppState { db: Arc::new(Mutex::new(db_connection)), notification_service: notification_service.clone(), + captcha_config: captcha_config.clone(), + last_notification_times: Arc::new(Mutex::new(HashMap::new())), + form_submission_attempts: Arc::new(Mutex::new(HashMap::new())), // Initialize rate limit map }); info!("Starting server at http://{}", bind_address); HttpServer::new(move || { - let app_state = app_state.clone(); + let app_state = app_state.clone(); // This now includes captcha_config let allowed_origins = allowed_origins_list.clone(); let rate_limiter = RateLimiter::new(limiter_data.clone()); @@ -190,18 +259,23 @@ async fn main() -> IoResult<()> { .max_age(3600) }; + // Configure JSON payload limits (e.g., 1MB) + let json_config = web::JsonConfig::default().limit(1024 * 1024); + App::new() .wrap(cors) .wrap(Logger::default()) .wrap(tracing_actix_web::TracingLogger::default()) .wrap(rate_limiter) - .app_data(app_state) + .app_data(app_state) // Share app state (db, notifications, captcha) + .app_data(json_config) // Add JSON payload configuration .service( web::scope("/api") // Health check endpoint .route("/health", web::get().to(handlers::health_check)) // Public routes .route("/login", web::post().to(handlers::login)) + .route("/register", web::post().to(handlers::register)) .route( "/forms/{form_id}/submissions", web::post().to(handlers::submit_form), @@ -221,7 +295,12 @@ async fn main() -> IoResult<()> { .route( "/forms/{form_id}/notifications", web::put().to(handlers::update_notification_settings), - ), + ) + // User management routes + .route("/users", web::get().to(handlers::list_users)) + .route("/users/{user_id}", web::get().to(handlers::get_user)) + .route("/users/{user_id}", web::put().to(handlers::update_user)) + .route("/users/{user_id}", web::delete().to(handlers::delete_user)), ) .service( fs::Files::new("/", "./frontend/") diff --git a/src/models.rs b/src/models.rs index 3562944..57e1615 100644 --- a/src/models.rs +++ b/src/models.rs @@ -29,6 +29,7 @@ pub struct Form { /// } /// ``` pub fields: serde_json::Value, + pub owner_id: String, pub notify_email: Option, pub notify_ntfy_topic: Option, pub created_at: DateTime, @@ -74,3 +75,27 @@ pub struct NotificationSettingsPayload { pub notify_email: Option, pub notify_ntfy_topic: Option, } + +#[derive(Debug, Serialize, Deserialize)] +pub struct User { + pub id: String, + pub username: String, + #[serde(skip_serializing)] // Never send password in responses + pub password: Option, + pub role: String, + pub created_at: DateTime, +} + +// Used for user registration +#[derive(Debug, Serialize, Deserialize)] +pub struct UserRegistration { + pub username: String, + pub password: String, +} + +// Used for user profile updates +#[derive(Debug, Serialize, Deserialize)] +pub struct UserUpdate { + pub username: Option, + pub password: Option, +}