";
- }
- } catch (error) {
- showStatus(
- `Failed to load submissions for form ${formId}: ${error.message}`,
- true
- );
- submissionsList.innerHTML = "
Error loading submissions.
";
- submissionsSection.classList.add("hidden"); // Hide section on error
- }
- }
-
- // --- Public Form Handling ---
-
- if (loadPublicFormButton) {
- loadPublicFormButton.addEventListener("click", async () => {
- const formId = publicFormIdInput.value.trim();
- if (!formId) {
- showStatus("Please enter a Form ID.", true);
- return;
- }
- showStatus("");
- publicFormArea.classList.add("hidden");
- publicForm.innerHTML = "Loading form..."; // Clear previous form
-
- // NOTE: Fetching form definition is NOT directly possible with the current backend
- // The backend only provides GET /forms (all, protected) and GET /forms/{id}/submissions (protected)
- // It DOES NOT provide a public GET /forms/{id} endpoint to fetch the definition.
- //
- // **WORKAROUND:** We will *assume* the user knows the structure or we have it cached/predefined.
- // For this example, we'll fetch *all* forms (if logged in) and find it, OR fail if not logged in.
- // A *better* backend design would include a public GET /forms/{id} endpoint.
-
- try {
- // Attempt to get the form definition (requires login for this workaround)
- if (!authToken) {
- showStatus(
- "Loading public forms requires login in this demo version.",
- true
- );
- publicForm.innerHTML = ""; // Clear loading message
- return;
- }
- const forms = await makeApiRequest("/forms", "GET", null, true);
- const formDefinition = forms.find((f) => f.id === formId);
-
- if (!formDefinition) {
- throw new Error(`Form with ID ${formId} not found or access denied.`);
- }
-
- renderPublicForm(formDefinition);
- publicFormArea.classList.remove("hidden");
- } catch (error) {
- showStatus(`Failed to load form ${formId}: ${error.message}`, true);
- publicForm.innerHTML = ""; // Clear loading message
- publicFormArea.classList.add("hidden");
- }
- });
- }
-
- function renderPublicForm(formDefinition) {
- publicFormTitle.textContent = formDefinition.name;
- publicForm.innerHTML = ""; // Clear previous fields
- publicForm.dataset.formId = formDefinition.id; // Store form ID for submission
-
- if (!formDefinition.fields || !Array.isArray(formDefinition.fields)) {
- publicForm.innerHTML = "
Error: Form definition is invalid.
";
- console.error("Invalid form fields definition:", formDefinition.fields);
- return;
- }
-
- formDefinition.fields.forEach((field) => {
- const div = document.createElement("div");
- const label = document.createElement("label");
- label.htmlFor = `field-${field.name}`;
- label.textContent = field.label || field.name; // Use label, fallback to name
- div.appendChild(label);
-
- let input;
- // Basic type handling - could be expanded
- switch (field.type) {
- case "textarea": // Allow explicit textarea type
- case "string":
- // Use textarea for string if maxLength suggests it might be long
- if (field.maxLength && field.maxLength > 100) {
- input = document.createElement("textarea");
- input.rows = 4; // Default rows
- } else {
- input = document.createElement("input");
- input.type = "text";
- }
- if (field.minLength) input.minLength = field.minLength;
- if (field.maxLength) input.maxLength = field.maxLength;
- break;
- case "email":
- input = document.createElement("input");
- input.type = "email";
- break;
- case "url":
- input = document.createElement("input");
- input.type = "url";
- break;
- case "number":
- input = document.createElement("input");
- input.type = "number";
- if (field.min !== undefined) input.min = field.min;
- if (field.max !== undefined) input.max = field.max;
- input.step = field.step || "any"; // Allow decimals by default
- break;
- case "boolean":
- input = document.createElement("input");
- input.type = "checkbox";
- // Checkbox label handling is slightly different
- label.insertBefore(input, label.firstChild); // Put checkbox before text
- input.style.width = "auto"; // Override default width
- input.style.marginRight = "10px";
- break;
- // Add cases for 'select', 'radio', 'date' etc. if needed
- default:
- input = document.createElement("input");
- input.type = "text";
- console.warn(
- `Unsupported field type "${field.type}" for field "${field.name}". Rendering as text.`
- );
- }
-
- if (input.type !== "checkbox") {
- // Checkbox is already appended inside label
- div.appendChild(input);
- }
- input.id = `field-${field.name}`;
- input.name = field.name; // Crucial for form data collection
- if (field.required) input.required = true;
- if (field.placeholder) input.placeholder = field.placeholder;
- if (field.pattern) input.pattern = field.pattern; // Add regex pattern validation
-
- publicForm.appendChild(div);
- });
-
- const submitButton = document.createElement("button");
- submitButton.type = "submit";
- submitButton.textContent = "Submit Form";
- publicForm.appendChild(submitButton);
- }
-
- publicForm.addEventListener("submit", async (e) => {
- e.preventDefault();
- showStatus("");
- const formId = e.target.dataset.formId;
- if (!formId) {
- showStatus("Error: Form ID is missing.", true);
- return;
- }
-
- const formData = new FormData(e.target);
- const submissionData = {};
-
- // Convert FormData to a plain object, handling checkboxes correctly
- for (const [key, value] of formData.entries()) {
- const inputElement = e.target.elements[key];
-
- // Handle Checkboxes (boolean)
- if (inputElement && inputElement.type === "checkbox") {
- // A checkbox value is only present in FormData if it's checked.
- // We need to ensure we always send a boolean.
- // Check if the element exists in the form (it might be unchecked)
- submissionData[key] = inputElement.checked;
- }
- // Handle Number inputs (convert from string)
- else if (inputElement && inputElement.type === "number") {
- // Only convert if the value is not empty, otherwise send null or handle as needed
- if (value !== "") {
- submissionData[key] = parseFloat(value); // Or parseInt if only integers allowed
- if (isNaN(submissionData[key])) {
- // Handle potential parsing errors if input validation fails
- console.warn(`Could not parse number for field ${key}: ${value}`);
- submissionData[key] = null; // Or keep as string, or show error
- }
- } else {
- submissionData[key] = null; // Or undefined, depending on backend expectation for empty numbers
- }
- }
- // Handle potential multiple values for the same name (e.g., multi-select), though not rendered here
- else if (submissionData.hasOwnProperty(key)) {
- if (!Array.isArray(submissionData[key])) {
- submissionData[key] = [submissionData[key]];
- }
- submissionData[key].push(value);
- }
- // Default: treat as string
- else {
- submissionData[key] = value;
- }
- }
-
- // Ensure boolean fields that were *unchecked* are explicitly set to false
- // FormData only includes checked checkboxes. Find all checkbox inputs in the form.
- const checkboxes = e.target.querySelectorAll('input[type="checkbox"]');
- checkboxes.forEach((cb) => {
- if (!submissionData.hasOwnProperty(cb.name)) {
- submissionData[cb.name] = false; // Set unchecked boxes to false
- }
- });
-
- console.log("Submitting data:", submissionData); // Debugging
-
- try {
- // Public submission endpoint doesn't require auth
- const result = await makeApiRequest(
- `/forms/${formId}/submissions`,
- "POST",
- submissionData,
- false
- );
- showStatus(
- `Submission successful! Submission ID: ${result.submission_id}`
- );
- e.target.reset(); // Clear the form
- // Optionally hide the form after successful submission
- // publicFormArea.classList.add('hidden');
- } catch (error) {
- let errorMsg = `Submission failed: ${error.message}`;
- // Handle validation errors specifically
- if (error.validationErrors) {
- errorMsg = "Submission failed due to validation errors:\n";
- for (const [field, message] of Object.entries(error.validationErrors)) {
- errorMsg += `- ${field}: ${message}\n`;
- }
- // Highlight invalid fields? (More complex UI update)
- }
- showStatus(errorMsg, true);
- }
- });
-
- // --- Initial Setup ---
- toggleSections(); // Set initial view based on stored token
- if (authToken) {
- loadFormsButton.click(); // Auto-load forms if logged in
- }
-});
diff --git a/frontend/style.css b/frontend/style.css
deleted file mode 100644
index 33e22c2..0000000
--- a/frontend/style.css
+++ /dev/null
@@ -1,411 +0,0 @@
-/* --- Variables copied from FormCraft --- */
-:root {
- --color-bg: #f7f7f7;
- --color-surface: #ffffff;
- --color-primary: #3a4750; /* Dark grayish blue */
- --color-secondary: #d8d8d8; /* Light gray */
- --color-accent: #b06f42; /* Warm wood/leather brown */
- --color-text: #2d3436; /* Dark gray */
- --color-text-light: #636e72; /* Medium gray */
- --color-border: #e0e0e0; /* Light border gray */
- --color-success: #2e7d32; /* Green */
- --color-success-bg: #e8f5e9;
- --color-error: #a94442; /* Red for errors */
- --color-error-bg: #f2dede;
- --color-danger: #e74c3c; /* Red for danger buttons */
- --color-danger-hover: #c0392b;
-
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
- --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
- --border-radius: 6px;
-}
-
-/* --- Global Reset & Body Styles --- */
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
-}
-
-body {
- background-color: var(--color-bg);
- color: var(--color-text);
- line-height: 1.6;
- min-height: 100vh;
- display: flex; /* Helps with potential footer later */
- flex-direction: column;
-}
-
-/* --- Container --- */
-.container {
- max-width: 900px; /* Adjusted width for simpler content */
- width: 100%;
- margin: 0 auto;
- padding: 32px 24px; /* Add padding like main content */
-}
-
-.page-container {
- flex: 1; /* Make container take available space if using flex on body */
-}
-
-/* --- Typography --- */
-h1,
-h2,
-h3 {
- color: var(--color-primary);
- margin-bottom: 16px;
- line-height: 1.3;
-}
-
-h1.page-title {
- font-size: 1.75rem;
- font-weight: 600;
- margin-bottom: 24px;
- text-align: center; /* Center main title */
-}
-
-h2.section-title {
- font-size: 1.25rem;
- font-weight: 600;
- border-bottom: 1px solid var(--color-border);
- padding-bottom: 8px;
- margin-bottom: 20px;
-}
-
-h3.card-title {
- font-size: 1.1rem;
- font-weight: 600;
- color: var(--color-primary);
- margin-bottom: 16px;
-}
-
-p {
- margin-bottom: 16px;
- color: var(--color-text-light);
-}
-p:last-child {
- margin-bottom: 0;
-}
-
-hr.divider {
- border: 0;
- height: 1px;
- background: var(--color-border);
- margin: 32px 0;
-}
-
-/* --- Content Card / Section Styling --- */
-.content-card,
-.section {
- background-color: var(--color-surface);
- padding: 24px;
- margin-bottom: 24px;
- border: 1px solid var(--color-border);
- border-radius: var(--border-radius);
- box-shadow: var(--shadow-sm);
-}
-
-.admin-header p {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 0;
- color: var(--color-text);
- font-weight: 500;
-}
-
-.admin-header span {
- font-weight: 600;
- color: var(--color-primary);
-}
-
-/* --- Forms --- */
-form .form-group {
- margin-bottom: 16px;
-}
-/* For side-by-side input and button */
-form .inline-form-group {
- display: flex;
- gap: 10px;
- align-items: flex-start; /* Align items to top */
-}
-form .inline-form-group input {
- flex-grow: 1; /* Allow input to take available space */
- margin-bottom: 0; /* Remove bottom margin */
-}
-form .inline-form-group button {
- flex-shrink: 0; /* Prevent button from shrinking */
-}
-
-label {
- display: block;
- margin-bottom: 6px;
- font-weight: 500;
- font-size: 0.9rem;
- color: var(--color-text-light);
-}
-
-input[type="text"],
-input[type="password"],
-input[type="email"],
-input[type="url"],
-input[type="number"],
-textarea {
- width: 100%;
- padding: 10px 12px;
- border: 1px solid var(--color-border);
- border-radius: var(--border-radius);
- font-size: 0.95rem;
- color: var(--color-text);
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
-}
-
-input[type="text"]:focus,
-input[type="password"]:focus,
-input[type="email"]:focus,
-input[type="url"]:focus,
-input[type="number"]:focus,
-textarea:focus {
- outline: none;
- border-color: var(--color-accent);
- box-shadow: 0 0 0 2px rgba(176, 111, 66, 0.2); /* Accent focus ring */
-}
-
-textarea {
- min-height: 80px;
- resize: vertical;
-}
-
-/* Styling for dynamically generated public form fields */
-#public-form div {
- margin-bottom: 16px; /* Keep consistent spacing */
-}
-
-/* Specific styles for checkboxes */
-#public-form input[type="checkbox"] {
- width: auto; /* Override 100% width */
- margin-right: 10px;
- vertical-align: middle; /* Align checkbox nicely with label text */
- margin-bottom: 0; /* Remove bottom margin if label handles spacing */
-}
-#public-form input[type="checkbox"] + label, /* Style label differently if needed when next to checkbox */
-#public-form label input[type="checkbox"] /* Style if checkbox is inside label */ {
- display: inline-flex; /* Or inline-block */
- align-items: center;
- margin-bottom: 0; /* Prevent double margin */
- font-weight: normal; /* Checkboxes often have normal weight labels */
- color: var(--color-text);
-}
-
-/* --- Buttons --- */
-.button {
- background-color: var(--color-primary);
- color: white;
- border: 1px solid transparent; /* Add border for consistency */
- padding: 10px 18px;
- border-radius: var(--border-radius);
- font-weight: 500;
- font-size: 0.9rem;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- transition: all 0.2s ease;
- text-decoration: none;
- line-height: 1.5;
- vertical-align: middle; /* Align with text/inputs */
-}
-
-.button:hover {
- background-color: #2c373f; /* Slightly darker hover */
- box-shadow: var(--shadow-sm);
-}
-.button:active {
- background-color: #1e2a31; /* Even darker active state */
-}
-
-.button-secondary {
- background-color: var(--color-surface);
- color: var(--color-primary);
- border: 1px solid var(--color-border);
-}
-
-.button-secondary:hover {
- background-color: #f8f8f8; /* Subtle hover for secondary */
- border-color: #d0d0d0;
-}
-.button-secondary:active {
- background-color: #f0f0f0;
-}
-
-.button-danger {
- background-color: var(--color-danger);
- border-color: var(--color-danger);
-}
-.button-danger:hover {
- background-color: var(--color-danger-hover);
- border-color: var(--color-danger-hover);
-}
-.button-danger:active {
- background-color: #a52e22; /* Even darker red */
-}
-
-/* Smaller button variant for lists? */
-.button-sm {
- padding: 5px 10px;
- font-size: 0.8rem;
-}
-
-/* Ensure buttons added by JS (like submit in public form) get styled */
-#public-form button[type="submit"] {
- /* Inherit .button styles if possible, otherwise redefine */
- background-color: var(--color-primary);
- color: white;
- border: 1px solid transparent;
- padding: 10px 18px;
- border-radius: var(--border-radius);
- font-weight: 500;
- font-size: 0.9rem;
- cursor: pointer;
- transition: all 0.2s ease;
- line-height: 1.5;
- margin-top: 10px; /* Add some space above submit */
-}
-#public-form button[type="submit"]:hover {
- background-color: #2c373f;
- box-shadow: var(--shadow-sm);
-}
-#public-form button[type="submit"]:active {
- background-color: #1e2a31;
-}
-
-/* --- Lists (Forms & Submissions) --- */
-ul.styled-list {
- list-style: none;
- padding: 0;
- margin-top: 20px; /* Space below heading/button */
-}
-
-ul.styled-list li {
- background-color: #fcfcfc; /* Slightly off-white */
- border: 1px solid var(--color-border);
- padding: 12px 16px;
- margin-bottom: 8px;
- border-radius: var(--border-radius);
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: background-color 0.2s ease;
- font-size: 0.95rem;
-}
-
-ul.styled-list li:hover {
- background-color: #f5f5f5;
-}
-
-ul.styled-list li button {
- margin-left: 16px; /* Space between text and button */
- /* Use smaller button style */
- padding: 5px 10px;
- font-size: 0.8rem;
- /* Inherit base button colors or use secondary */
- background-color: var(--color-surface);
- color: var(--color-primary);
- border: 1px solid var(--color-border);
-}
-ul.styled-list li button:hover {
- background-color: #f8f8f8;
- border-color: #d0d0d0;
-}
-
-/* Specific styling for submissions list items */
-ul.submissions li {
- display: block; /* Allow pre tag to format */
- background-color: var(--color-surface); /* White background for submissions */
-}
-
-ul.submissions li pre {
- white-space: pre-wrap; /* Wrap long lines */
- word-wrap: break-word; /* Break long words */
- background-color: #f9f9f9; /* Light grey background for code block */
- padding: 10px;
- border-radius: var(--border-radius);
- border: 1px solid var(--color-border);
- font-size: 0.85rem;
- color: var(--color-text);
- max-height: 200px; /* Limit height */
- overflow-y: auto; /* Add scroll if needed */
-}
-
-/* --- Status Area --- */
-.status {
- padding: 12px 16px;
- margin-bottom: 20px;
- border-radius: var(--border-radius);
- font-weight: 500;
- border: 1px solid transparent;
- display: none; /* Hide by default, JS shows it */
-}
-.status.success,
-.status.error {
- display: block; /* Show when class is added */
-}
-
-.status.success {
- background-color: var(--color-success-bg);
- color: var(--color-success);
- border-color: var(--color-success); /* Darker green border */
-}
-.status.error {
- background-color: var(--color-error-bg);
- color: var(--color-error);
- border-color: var(--color-error); /* Darker red border */
- white-space: pre-wrap; /* Allow multi-line errors */
-}
-
-/* --- Utility --- */
-.hidden {
- display: none !important; /* Use !important to override potential inline styles if needed */
-}
-
-/* --- Responsive Adjustments (Basic) --- */
-@media (max-width: 768px) {
- .container {
- padding: 24px 16px;
- }
- h1.page-title {
- font-size: 1.5rem;
- }
- h2.section-title {
- font-size: 1.15rem;
- }
- ul.styled-list li {
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
- }
- ul.styled-list li button {
- margin-left: 0;
- align-self: flex-end; /* Move button to bottom right */
- }
- form .inline-form-group {
- flex-direction: column;
- align-items: stretch; /* Make elements full width */
- }
- form .inline-form-group button {
- width: 100%; /* Make button full width */
- }
-}
-
-@media (max-width: 576px) {
- .content-card,
- .section {
- padding: 16px;
- }
- .button {
- padding: 8px 14px;
- font-size: 0.85rem;
- }
-}
diff --git a/init.sql b/init.sql
new file mode 100644
index 0000000..f868dcf
--- /dev/null
+++ b/init.sql
@@ -0,0 +1,133 @@
+-- init.sql
+CREATE DATABASE IF NOT EXISTS forms_db;
+USE forms_db;
+
+-- Users table for authentication and authorization
+CREATE TABLE IF NOT EXISTS `users` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `uuid` TEXT NOT NULL UNIQUE,
+ `email` TEXT NOT NULL UNIQUE,
+ `password_hash` TEXT NOT NULL,
+ `first_name` TEXT DEFAULT NULL,
+ `last_name` TEXT DEFAULT NULL,
+ `role` TEXT DEFAULT 'user' CHECK(`role` IN ('user', 'admin', 'super_admin')),
+ `is_verified` INTEGER DEFAULT 0,
+ `is_active` INTEGER DEFAULT 1,
+ `verification_token` TEXT DEFAULT NULL,
+ `password_reset_token` TEXT DEFAULT NULL,
+ `password_reset_expires` DATETIME NULL DEFAULT NULL,
+ `last_login` DATETIME NULL DEFAULT NULL,
+ `failed_login_attempts` INTEGER DEFAULT 0,
+ `account_locked_until` DATETIME NULL DEFAULT NULL,
+ `must_change_password` INTEGER DEFAULT 0,
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (`email`),
+ UNIQUE (`uuid`)
+);
+CREATE INDEX IF NOT EXISTS `idx_email` ON `users` (`email`);
+CREATE INDEX IF NOT EXISTS `idx_verification_token` ON `users` (`verification_token`);
+CREATE INDEX IF NOT EXISTS `idx_password_reset_token` ON `users` (`password_reset_token`);
+CREATE INDEX IF NOT EXISTS `idx_uuid_users` ON `users` (`uuid`);
+
+-- User sessions table for JWT blacklisting and session management
+CREATE TABLE IF NOT EXISTS `user_sessions` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `user_id` INTEGER NOT NULL,
+ `token_jti` TEXT NOT NULL UNIQUE,
+ `expires_at` DATETIME NOT NULL,
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `user_agent` TEXT DEFAULT NULL,
+ `ip_address` TEXT DEFAULT NULL,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS `idx_token_jti` ON `user_sessions` (`token_jti`);
+CREATE INDEX IF NOT EXISTS `idx_user_id_sessions` ON `user_sessions` (`user_id`);
+CREATE INDEX IF NOT EXISTS `idx_expires_at_sessions` ON `user_sessions` (`expires_at`);
+
+-- Update forms table to associate with users
+CREATE TABLE IF NOT EXISTS `forms` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `uuid` TEXT NOT NULL UNIQUE,
+ `user_id` INTEGER NOT NULL,
+ `name` TEXT DEFAULT 'My Form',
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `thank_you_url` TEXT DEFAULT NULL,
+ `thank_you_message` TEXT DEFAULT NULL,
+ `ntfy_enabled` INTEGER DEFAULT 1,
+ `is_archived` INTEGER DEFAULT 0,
+ `allowed_domains` TEXT DEFAULT NULL,
+ `email_notifications_enabled` INTEGER NOT NULL DEFAULT 0,
+ `notification_email_address` TEXT DEFAULT NULL,
+ `recaptcha_enabled` INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS `idx_user_id_forms` ON `forms` (`user_id`);
+CREATE INDEX IF NOT EXISTS `idx_uuid_forms` ON `forms` (`uuid`);
+
+CREATE TABLE IF NOT EXISTS `submissions` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `form_uuid` TEXT NOT NULL,
+ `user_id` INTEGER NOT NULL,
+ `data` TEXT NOT NULL, -- Storing JSON as TEXT
+ `ip_address` TEXT NULL,
+ `submitted_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`form_uuid`) REFERENCES `forms`(`uuid`) ON DELETE CASCADE,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS `idx_form_uuid_submissions` ON `submissions` (`form_uuid`);
+CREATE INDEX IF NOT EXISTS `idx_user_id_submissions` ON `submissions` (`user_id`);
+CREATE INDEX IF NOT EXISTS `idx_submitted_at_submissions` ON `submissions` (`submitted_at`);
+
+-- Rate limiting table for enhanced security (Simplified for SQLite)
+-- Note: TIMESTAMP logic for window_start and expires_at might need application-level management
+-- depending on how it was used with MySQL.
+CREATE TABLE IF NOT EXISTS `rate_limits` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `identifier` TEXT NOT NULL,
+ `action` TEXT NOT NULL,
+ `count` INTEGER DEFAULT 1,
+ `window_start` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `expires_at` DATETIME NOT NULL,
+ UNIQUE (`identifier`, `action`)
+);
+CREATE INDEX IF NOT EXISTS `idx_identifier_action_rate_limits` ON `rate_limits` (`identifier`, `action`);
+CREATE INDEX IF NOT EXISTS `idx_expires_at_rate_limits` ON `rate_limits` (`expires_at`);
+
+-- Create default admin user (password will be set on first login)
+-- You should change this immediately after first login
+INSERT OR IGNORE INTO users (email, password_hash, first_name, last_name, role, is_verified, is_active, must_change_password, uuid)
+VALUES ('admin@formies.local', 'NEEDS_TO_BE_SET_ON_FIRST_LOGIN', 'Admin', 'User', 'super_admin', 1, 1, 1, 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'); -- Placeholder UUID, generate dynamically in app if needed
+
+-- API Keys table for user-generated API access
+CREATE TABLE IF NOT EXISTS `api_keys` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `uuid` TEXT NOT NULL UNIQUE,
+ `user_id` INTEGER NOT NULL,
+ `key_name` TEXT DEFAULT NULL,
+ `api_key_identifier` TEXT NOT NULL UNIQUE, -- Public, non-secret identifier for lookup
+ `hashed_api_key_secret` TEXT NOT NULL, -- Hashed version of the secret part of the API key
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `last_used_at` DATETIME NULL DEFAULT NULL,
+ `expires_at` DATETIME NULL DEFAULT NULL, -- For future use
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS `idx_user_id_api_keys` ON `api_keys` (`user_id`);
+CREATE INDEX IF NOT EXISTS `idx_api_key_identifier_api_keys` ON `api_keys` (`api_key_identifier`);
+
+-- Trigger to update 'updated_at' timestamp on users table (optional, can be handled in app code)
+CREATE TRIGGER IF NOT EXISTS update_users_updated_at
+AFTER UPDATE ON users
+FOR EACH ROW
+BEGIN
+ UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
+END;
+
+-- Trigger to update 'updated_at' timestamp on forms table (optional, can be handled in app code)
+CREATE TRIGGER IF NOT EXISTS update_forms_updated_at
+AFTER UPDATE ON forms
+FOR EACH ROW
+BEGIN
+ UPDATE forms SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
+END;
\ No newline at end of file
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..5f953bc
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,28 @@
+// jest.config.js
+module.exports = {
+ testEnvironment: "node",
+ verbose: true,
+ coveragePathIgnorePatterns: [
+ "/node_modules/",
+ "/__tests__/setup/", // Ignore setup files from coverage
+ "/src/config/", // Often configuration files don't need testing
+ "/config/", // logger config
+ ],
+ // Automatically clear mock calls and instances between every test
+ clearMocks: true,
+ // A path to a module which exports an async function that is triggered once before all test suites
+ // globalSetup: './__tests__/setup/globalSetup.js', // Optional: If you need global setup
+ // A path to a module which exports an async function that is triggered once after all test suites
+ // globalTeardown: './__tests__/setup/globalTeardown.js', // Optional: If you need global teardown
+ // The directory where Jest should output its coverage files
+ coverageDirectory: "coverage",
+ // Indicates whether the coverage information should be collected while executing the test
+ collectCoverage: true,
+ // An array of glob patterns indicating a set of files for which coverage information should be collected
+ collectCoverageFrom: [
+ "src/**/*.js",
+ "!server.js", // Usually the main server start file is hard to unit test directly
+ "!src/app.js", // If you extract Express app setup to app.js for testability
+ ],
+ setupFilesAfterEnv: ["./__tests__/setup/jest.setup.js"], // For things like extending expect
+};
diff --git a/middleware/errorHandler.js b/middleware/errorHandler.js
new file mode 100644
index 0000000..459ed1f
--- /dev/null
+++ b/middleware/errorHandler.js
@@ -0,0 +1,31 @@
+const logger = require("../config/logger");
+
+const errorHandler = (err, req, res, next) => {
+ logger.error(err.message, {
+ stack: err.stack,
+ path: req.path,
+ method: req.method,
+ });
+
+ // If the error is a known type, customize the response
+ // Otherwise, send a generic server error
+ if (err.isOperational) {
+ // You can add an 'isOperational' property to your custom errors
+ res.status(err.statusCode || 500).json({
+ error: {
+ message: err.message,
+ code: err.errorCode || "INTERNAL_SERVER_ERROR",
+ },
+ });
+ } else {
+ // For unexpected errors, don't leak details to the client
+ res.status(500).json({
+ error: {
+ message: "An unexpected error occurred.",
+ code: "INTERNAL_SERVER_ERROR",
+ },
+ });
+ }
+};
+
+module.exports = errorHandler;
diff --git a/notes.md b/notes.md
new file mode 100644
index 0000000..7f56132
--- /dev/null
+++ b/notes.md
@@ -0,0 +1,340 @@
+## Task 2.1: User Dashboard & Form Management UI (Replacing current "admin")
+
+- Mindset Shift: This is no longer an admin panel. It's the user's control center.
+
+### Subtask 2.1.1: Design User Dashboard Layout
+
+- **Wireframe basic layout:**
+ - **Navigation Bar:**
+ - Logo/App Name (e.g., "Formies")
+ - My Forms (Active Link)
+ - Create New Form
+ - Account Settings (e.g., "Hi, [User Name]" dropdown with "Settings", "Logout")
+ - **Main Content Area (for "My Forms" view):**
+ - Header: "My Forms"
+ - Button: "+ Create New Form"
+ - Forms List Table:
+ - Columns: Form Name, Submissions (count), Endpoint URL, Created Date, Actions
+ - Actions per row: View Submissions, Settings, Archive/Delete
+ - Pagination for the forms list if it becomes long.
+ - **Main Content Area (for "Create New Form" view - initial thought, might be a separate page/modal):**
+ - Header: "Create New Form"
+ - Form fields: Form Name
+ - Button: "Create Form"
+ - **Main Content Area (for "Account Settings" - placeholder for now):**
+ - Header: "Account Settings"
+ - Placeholder content.
+- **Frontend Tech Decision:**
+ - EJS for templating, made dynamic with client-side JavaScript. This aligns with the existing structure and MVP scope. We will enhance EJS views to be more interactive.
+
+[X] Wireframe basic layout: List forms, create form, account settings (placeholder). - _Textual wireframe defined above_
+[X] Decide on frontend tech (EJS is fine for MVP if you make it dynamic with client-side JS, or consider a simple SPA framework if comfortable). - _Decision made: EJS with client-side JS_
+
+- Created `views/dashboard.ejs` as the main layout.
+- Created `views/partials/_forms_table.ejs` for displaying the list of forms.
+
+### Subtask 2.1.2: "My Forms" View:
+
+- Objective: Fetch and display forms owned by the logged-in user.
+- Show key info: name, submission count, endpoint URL, created date, status (Active/Archived).
+- Links/Actions: View Submissions, Settings, Archive/Unarchive, Delete.
+- Frontend: `views/dashboard.ejs` with `view = 'my_forms'` and `views/partials/_forms_table.ejs` will handle this.
+- Backend:
+ - Need a new route, e.g., `GET /dashboard`, protected by authentication (e.g., `requireAuth` from `authMiddleware.js`).
+ - This route handler will:
+ - Fetch forms for `req.user.id` from the database.
+ - Query should include `name`, `uuid`, `created_at`, `is_archived`, and `submission_count`.
+ - Render `views/dashboard.ejs` passing the forms data, `user` object, `appUrl`, and `view = 'my_forms'`.
+ - Implemented in `src/routes/dashboard.js` via GET `/`.
+
+[X] Fetch and display forms owned by the logged-in user.
+[X] Show key info: name, submission count, endpoint URL, created date.
+[X] Links to: view submissions, edit settings, delete. (Links are present in `_forms_table.ejs`, functionality for all to be built out in subsequent tasks)
+
+### Subtask 2.1.3: "Create New Form" Functionality (for logged-in user):
+
+- UI: `dashboard.ejs` (with `view = 'create_form'`) provides the form input.
+ - Route `GET /dashboard/create-form` in `src/routes/dashboard.js` renders this view.
+- Backend: `POST /dashboard/forms/create` route in `src/routes/dashboard.js` handles form submission.
+ - Associates form with `req.user.id`.
+ - Redirects to `/dashboard` on success.
+ - Handles errors and re-renders create form view with an error message.
+
+[X] UI and backend logic. Associates form with req.user.id.
+
+### Subtask 2.1.4: "View Form Submissions" (Scoped & Paginated):
+
+- Objective: Allow users to view submissions for their specific forms, with pagination.
+- UI:
+ - `views/partials/_submissions_view.ejs` created to display submissions list and pagination.
+ - `views/dashboard.ejs` updated to include this partial when `view = 'form_submissions'`.
+- Backend:
+ - Route: `GET /dashboard/submissions/:formUuid` added to `src/routes/dashboard.js`.
+ - Verifies that `req.user.id` owns the `formUuid`.
+ - Fetches paginated submissions for the given `formUuid`.
+ - Renders `dashboard.ejs` with `view = 'form_submissions'`, passing submissions data, form details, and pagination info.
+ - Error handling improved to render user-friendly messages within the dashboard view.
+
+[X] UI and backend for a user to view submissions for their specific form.
+[X] Pagination is critical here (as you have).
+
+### Subtask 2.1.5: Form Settings UI (Basic):
+
+- Objective: Allow users to update basic form settings, starting with the form name.
+- UI:
+ - A new view/section in `dashboard.ejs` (e.g., when `view = 'form_settings'`).
+ - This view will display a form with an input for the form name.
+ - It will also be a placeholder for future settings (thank you URL, notifications).
+- Backend:
+ - Route: `GET /dashboard/forms/:formUuid/settings` to display the settings page.
+ - Implemented in `src/routes/dashboard.js`.
+ - Verifies form ownership by `req.user.id`.
+ - Fetches current form details (name).
+ - Renders the `form_settings` view in `dashboard.ejs`.
+ - Route: `POST /dashboard/forms/:formUuid/settings/update-name` to handle the update.
+ - Implemented in `src/routes/dashboard.js`.
+ - Verifies form ownership.
+ - Updates the form name in the database.
+ - Redirects back to form settings page with a success/error message via query parameters.
+
+[X] Allow users to update form name.
+[X] Placeholder for future settings (thank you URL, notifications) - (Placeholders added in EJS).
+
+### Subtask 2.1.6: Delete Form/Submission (with soft delete/archival consideration):
+
+- Objective: Implement form archival (soft delete) and permanent deletion for users.
+- Users should be able to archive/unarchive their forms.
+- True delete should be a confirmed, rare operation.
+- The `is_archived` field in the `forms` table will be used.
+- Submissions deletion is already partially handled in `_submissions_view.ejs` via a POST to `/dashboard/submissions/delete/:submissionId`. We need to implement this backend route.
+
+- **Form Archival/Unarchival:**
+ - UI: Buttons for "Archive" / "Unarchive" are already in `views/partials/_forms_table.ejs`.
+ - Archive action: `POST /dashboard/forms/archive/:formUuid`
+ - Unarchive action: `POST /dashboard/forms/unarchive/:formUuid`
+ - Backend:
+ - Create these two POST routes in `src/routes/dashboard.js`.
+ - Must verify form ownership by `req.user.id`.
+ - Fetch current form details (name).
+ - Render the settings view.
+ - Route: `POST /dashboard/forms/:formUuid/settings` (or `/dashboard/forms/:formUuid/update-name`) to handle the update.
+ - Must verify form ownership.
+ - Update the form name in the database.
+ - Redirect back to form settings page or main dashboard with a success message.
+
+* **Submission Deletion (User-scoped):**
+ - UI: "Delete" button per submission in `views/partials/_submissions_view.ejs` (with `confirm()` dialog).
+ - Action: `POST /dashboard/submissions/delete/:submissionId`
+ - Backend (in `src/routes/dashboard.js`):
+ - Implemented `POST /dashboard/submissions/delete/:submissionId`:
+ - Verifies the `req.user.id` owns the form to which the submission belongs.
+ - Deletes the specific submission.
+ - Redirects back to the form's submissions view (`/dashboard/submissions/:formUuid`) with message.
+
+[X] You have is_archived. Solidify this. Users should be able to archive/unarchive.
+[X] True delete should be a confirmed, rare operation.
+[X] Implement user-scoped submission deletion.
+
+## Task 2.2: Per-Form Configuration by User
+
+- Mindset Shift: Empower users to customize their form behavior.
+
+### Subtask 2.2.1: Database Schema Updates for forms Table:
+
+- Objective: Add new fields to the `forms` table to support per-form email notification settings.
+- Review existing fields (`thank_you_url`, `thank_you_message`, `ntfy_enabled`, `allowed_domains`) - these are good as per plan.
+- **New fields to add:**
+ - `email_notifications_enabled` (BOOLEAN, DEFAULT FALSE, NOT NULL)
+ - `notification_email_address` (VARCHAR(255), NULL) - This will store an override email address. If NULL, the user's primary email will be used.
+
+[X] Review existing fields (thank_you_url, thank_you_message, ntfy_enabled, allowed_domains). These are good.
+[X] Add email_notifications_enabled (boolean). (Added to `init.sql`)
+[X] Add notification_email_address (string, defaults to user's email, but allow override). (Added to `init.sql`)
+
+### Subtask 2.2.2: UI for Form Settings Page:
+
+- Objective: Enhance the form settings page to allow users to configure these new email notification options.
+- The existing form settings page is `dashboard.ejs` with `view = 'form_settings'` (created in Subtask 2.1.5).
+- **UI Elements to add to this page:**
+ - **Email Notifications Section:**
+ - Checkbox/Toggle: "Enable Email Notifications for new submissions"
+ - Controls `email_notifications_enabled`.
+ - Input Field (text, email type): "Notification Email Address"
+ - Controls `notification_email_address`.
+ - Should be pre-filled with the user's primary email if `notification_email_address` is NULL/empty in the DB.
+ - Label should indicate that if left blank, notifications will go to the account email.
+- The `GET /dashboard/forms/:formUuid/settings` route will need to fetch these new fields.
+- The form on this page will need to be updated to submit these new fields. The POST route will likely be `/dashboard/forms/:formUuid/settings/update-notifications` or similar, or a general update to the existing `/dashboard/forms/:formUuid/settings/update-name` to become a general settings update route.
+
+[X] Create a dedicated page/modal for each form's settings. (Using existing settings section in `dashboard.ejs`)
+[X] Allow users to edit: Name, Email Notification toggle, Notification Email Address. (Thank You URL, Thank You Message, Allowed Domains are placeholders for now as per 2.1.5).
+_ UI elements added to `dashboard.ejs` in the `form_settings` view.
+_ `GET /dashboard/forms/:formUuid/settings` in `src/routes/dashboard.js` updated to fetch and pass these settings. \* `POST /dashboard/forms/:formUuid/settings/update-notifications` in `src/routes/dashboard.js` created to save these settings.
+
+### Subtask 2.2.3: Backend to Save and Apply Settings:
+
+- Objective: Ensure the backend API endpoints correctly save and the submission logic uses these settings.
+- API endpoints to update settings for a specific form (owned by user):
+ - `POST .../update-name` (Done in 2.1.5)
+ - `POST .../update-notifications` (Done in 2.2.2)
+ - Future: endpoints for Thank You URL, Message, Allowed Domains.
+- Logic in `/submit/:formUuid` to use these form-specific settings:
+ - When a form is submitted to `/submit/:formUuid`:
+ - Fetch the form's settings from the DB, including `email_notifications_enabled` and `notification_email_address`.
+ - This logic is now implemented in `src/routes/public.js` as part of Task 2.3.2 integration.
+
+[X] API endpoints to update these settings for a specific form (owned by user). (Name and Email Notification settings covered so far)
+[X] Logic in /submit/:formUuid to use these form-specific settings. (Addressed as part of 2.3.2)
+
+## Task 2.3: Email Notifications for Submissions (Core Feature)
+
+- Mindset Shift: Ntfy is cool for you. Users expect email.
+
+### Subtask 2.3.1: Integrate Transactional Email Service:
+
+- Objective: Set up a third-party email service to send submission notifications.
+- **Action for you (USER):**
+ - Choose a transactional email service (e.g., SendGrid, Mailgun, AWS SES). Many offer free tiers.
+ - Sign up for the service and obtain an API Key.
+ - Securely store this API Key as an environment variable in your `.env` file.
+ - For example, if you choose SendGrid, you might use `SENDGRID_API_KEY=your_actual_api_key`.
+ - Also, note the sender email address you configure with the service (e.g., `EMAIL_FROM_ADDRESS=notifications@yourdomain.com`).
+- Once you have these, let me know which service you've chosen so I can help with installing the correct SDK and writing the integration code.
+ - User selected: Resend
+ - API Key ENV Var: `RESEND_API_KEY`
+ - From Email ENV Var: `EMAIL_FROM_ADDRESS`
+
+[X] Sign up for SendGrid, Mailgun, AWS SES (free tiers available). (User selected Resend)
+[X] Install their SDK. (npm install resend done)
+[X] Store API key securely (env vars). (User confirmed `RESEND_API_KEY` and `EMAIL_FROM_ADDRESS` are set up)
+
+### Subtask 2.3.2: Email Sending Logic:
+
+- Objective: Create a reusable service/function to handle the sending of submission notification emails.
+- This service will use the Resend SDK and the configured API key.
+- **Create a new service file:** `src/services/emailService.js`
+ - It should export a function, e.g., `sendSubmissionNotification(form, submissionData, userEmail)`.
+ - `form`: An object containing form details (`name`, `email_notifications_enabled`, `notification_email_address`).
+ - `submissionData`: The actual data submitted to the form.
+ - `userEmail`: The email of the user who owns the form (to be used if `form.notification_email_address` is not set).
+ - Inside the function:
+ - Check if `form.email_notifications_enabled` is true.
+ - Determine the recipient: `form.notification_email_address` or `userEmail`.
+ - Construct the email subject and body (using a basic template for now - Subtask 2.3.3).
+ - Use the Resend SDK to send the email.
+ - Include error handling (Subtask 2.3.4).
+
+[X] Create a service/function sendSubmissionNotification(form, submissionData, userEmail) - (`src/services/emailService.js` created with this function).
+[X] If email_notifications_enabled for the form, send an email to notification_email_address (or user's email). - (Logic implemented in `emailService.js` and integrated into `/submit/:formUuid` route in `src/routes/public.js`).
+
+### Subtask 2.3.3: Basic Email Template:
+
+- Objective: Define a simple, clear email template for notifications.
+- The current `createEmailHtmlBody` function in `src/services/emailService.js` provides a very basic HTML template:
+ - Subject: "New Submission for [Form Name]"
+ - Body: Lists submitted data (key-value pairs).
+- This fulfills the MVP requirement.
+
+[X] Simple, clear email: "New Submission for [Form Name]", list submitted data. (Implemented in `emailService.js`)
+
+### Subtask 2.3.4: Error Handling for Email Sending:
+
+- Objective: Ensure email sending failures don't break the submission flow and are logged.
+- In `src/services/emailService.js`, within `sendSubmissionNotification`:
+ - Errors from `resend.emails.send()` are caught and logged.
+ - The function does not throw an error that would halt the caller, allowing the submission to be considered successful even if the email fails.
+- In `src/routes/public.js` (`/submit/:formUuid` route):
+ - The call to `sendSubmissionNotification` is followed by `.catch()` to log any unexpected errors from the email sending promise itself, ensuring the main response to the user is not blocked.
+
+[X] Log errors if email fails to send; don't let it break the submission flow. (Implemented in `emailService.js` and `public.js` route)
+
+## Task 2.4: Enhanced Spam Protection (Beyond Basic Honeypot)
+
+- Mindset Shift: Your honeypot is step 1. Real services need more.
+
+### Subtask 2.4.1: Integrate CAPTCHA (e.g., Google reCAPTCHA):
+
+- Objective: Add server-side CAPTCHA validation to the form submission process.
+- We'll use Google reCAPTCHA v2 ("I'm not a robot" checkbox) for this MVP.
+- **Action for you (USER):**
+ - Go to the [Google reCAPTCHA admin console](https://www.google.com/recaptcha/admin/create).
+ - Register your site: Choose reCAPTCHA v2, then "I'm not a robot" Checkbox.
+ - Add your domain(s) (e.g., `localhost` for development, and your production domain).
+ - Accept the terms of service.
+ - You will receive a **Site Key** and a **Secret Key**.
+ - Store these securely in your `.env` file:
+ - `RECAPTCHA_V2_SITE_KEY=your_site_key`
+ - `RECAPTCHA_V2_SECRET_KEY=your_secret_key`
+- Let me know once you have these keys set up in your `.env` file.
+
+- **Frontend Changes (Illustrative - User will implement on their actual forms):**
+ - User needs to include the reCAPTCHA API script in their HTML form page: ``
+ - User needs to add the reCAPTCHA widget div where the checkbox should appear: `` (replacing with the actual site key, possibly passed from server or configured client-side if site key is public).
+- **Backend Changes (`/submit/:formUuid` route in `src/routes/public.js`):**
+ - When a submission is received, it should include a `g-recaptcha-response` field from the reCAPTCHA widget.
+ - Create a new middleware or a helper function `verifyRecaptcha(recaptchaResponse, clientIp)`.
+ - This function will make a POST request to Google's verification URL: `https://www.google.com/recaptcha/api/siteverify`.
+ - Parameters: `secret` (your `RECAPTCHA_V2_SECRET_KEY`), `response` (the `g-recaptcha-response` value), `remoteip` (optional, user's IP).
+ - The response from Google will be JSON indicating success or failure.
+ - In the `/submit` route, call this verification function. If verification fails, reject the submission with an appropriate error.
+
+[X] Sign up for reCAPTCHA (v2 "I'm not a robot" or v3 invisible). Get site/secret keys. (User action) - _User confirmed keys are in .env_
+[ ] Frontend: Add reCAPTCHA widget/JS to user's HTML form example. (User responsibility for their forms)
+[X] Backend: /submit/:formUuid endpoint must verify reCAPTCHA token with Google. (_Already implemented in `src/routes/public.js` using `src/utils/recaptchaHelper.js`_)
+
+### Subtask 2.4.2: User Configuration for Spam Protection:
+
+- [x] Database Schema: Add `recaptcha_enabled` (BOOLEAN, DEFAULT FALSE) to `forms` table. (_Done in `init.sql`_)
+- [x] UI: Added reCAPTCHA toggle to Form Settings page (`dashboard.ejs`) and consolidated settings form to POST to `/dashboard/forms/:formUuid/settings/update`. (_Done_)
+- [x] Backend:
+ - [x] `GET /dashboard/forms/:formUuid/settings` fetches and passes `recaptcha_enabled`. (_Done_)
+ - [x] Consolidated `POST /dashboard/forms/:formUuid/settings/update` saves `recaptcha_enabled` and other settings (formName, emailNotificationsEnabled, notificationEmailAddress). (_Done_)
+ - [x] `/submit/:formUuid` in `public.js` now checks form's `recaptcha_enabled` flag: if true, token is required & verified; if false, check is skipped. (_Done_)
+- [x] Allow users to enable/disable reCAPTCHA for their forms (and input their own site key if they want, or use a global one you provide). - _Implemented using global keys for MVP._
+
+- Subtask 2.4.3: (Future Consideration) Akismet / Content Analysis.
+
+## Task 2.5: Basic API for Users to Access Their Data
+
+- Mindset Shift: Power users and integrations need an API.
+
+### Subtask 2.5.1: API Key Generation & Management:
+
+- Objective: Allow users to generate/revoke API keys from their dashboard.
+- **Action for you (USER):**
+ - Choose a RESTful API framework (e.g., Express, Fastify).
+ - Implement the API endpoints to allow users to access their data.
+ - Ensure the API is secure and uses authentication.
+- Let me know once you have the API implemented and tested.
+
+[X] Database Schema: Create `api_keys` table (user*id, key_name, api_key_identifier, hashed_api_key_secret, etc.). (\_Done in `init.sql` with refined structure*)
+[X] Helper Utilities: Created `src/utils/apiKeyHelper.js` with `generateApiKeyParts`, `hashApiKeySecret`, `compareApiKeySecret`. (_Done_)
+[X] Backend Routes: Added `GET /dashboard/api-keys` (list), `POST /dashboard/api-keys/generate` (create), `POST /dashboard/api-keys/:apiKeyUuid/revoke` (delete) to `src/routes/dashboard.js`. (_Done_)
+[X] UI in Dashboard: Added "API Keys" section to `dashboard.ejs` for generating, listing (name, identifier, created/last*used), and revoking keys. Displays newly generated key once via session. (\_Done*)
+[X] Allow users to generate/revoke API keys from their dashboard. (_Done_)
+[X] Store hashed API keys in DB, associated with user. (_Done via backend routes and helpers_)
+
+### Subtask 2.5.2: Secure API Endpoints:
+
+- Objective: Ensure the API is secure and uses authentication.
+- **Action for you (USER):**
+ - Choose a RESTful API framework (e.g., Express, Fastify).
+ - Implement the API endpoints to allow users to access their data.
+ - Ensure the API is secure and uses authentication.
+- Let me know once you have the API implemented and tested.
+
+[X] Created `src/middleware/apiAuthMiddleware.js` for Bearer token authentication (checks signature, expiry, active user, updates last*used). (\_Done*)
+[X] Created `src/routes/api_v1.js` and mounted it at `/api/v1` in `server.js`. (_Done_)
+[X] Added `GET /api/v1/forms` (list user's forms) and `GET /api/v1/forms/:formUuid/submissions` (list form submissions, paginated), both protected by the API auth middleware. (_Done_)
+[X] Create new API routes (e.g., /api/v1/forms, /api/v1/forms/:uuid/submissions). (_Covered by above point_)
+[X] Authenticate using API keys (e.g., Bearer token). (_Done_)
+
+### Subtask 2.5.3: Basic API Documentation:
+
+- Objective: Provide basic documentation for the API.
+- **Action for you (USER):**
+ - Choose a documentation format (e.g., Swagger, Postman, Markdown).
+ - Implement the documentation for the API endpoints.
+- Let me know once you have the API documentation implemented.
+
+[ ] Simple Markdown file explaining authentication and available endpoints.
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c65ff7d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "formies",
+ "version": "1.0.0",
+ "main": "server.js",
+ "scripts": {
+ "test": "NODE_ENV=test jest --runInBand --detectOpenHandles --forceExit",
+ "test:watch": "NODE_ENV=test jest --watch",
+ "start": "node server.js",
+ "dev": "nodemon server.js"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "dependencies": {
+ "basic-auth": "^2.0.1",
+ "bcryptjs": "^2.4.3",
+ "dotenv": "^16.5.0",
+ "ejs": "^3.1.10",
+ "express": "^5.1.0",
+ "express-rate-limit": "^7.1.5",
+ "express-session": "^1.17.3",
+ "express-validator": "^7.0.1",
+ "helmet": "^8.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "nodemailer": "^6.9.8",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "passport-local": "^1.0.0",
+ "rate-limit-redis": "^4.2.0",
+ "redis": "^4.7.0",
+ "resend": "^4.5.1",
+ "sqlite3": "^5.1.7",
+ "uuid": "^11.1.0",
+ "winston": "^3.17.0"
+ },
+ "devDependencies": {
+ "nodemon": "^3.0.2",
+ "jest": "^29.7.0",
+ "supertest": "^7.0.0"
+ }
+}
diff --git a/repomix-output.xml b/repomix-output.xml
index a9f29db..e47ced5 100644
--- a/repomix-output.xml
+++ b/repomix-output.xml
@@ -43,4552 +43,6579 @@ The content is organized as follows:
-.gitea/workflows/docker-build.yml
+.cursor/rules/mvp-scope.mdc
+.env.test
.gitignore
-Cargo.toml
-config/default.toml
-design.html
+API_DOCUMENTATION.md
+AUTHENTICATION_SETUP.md
+config/logger.js
+docker-compose.prod.yml
+docker-compose.yml
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
+init.sql
+jest.config.js
+middleware/errorHandler.js
+notes.md
+package.json
+RATE_LIMITING.md
+server.js
+src/config/database.js
+src/config/passport.js
+src/config/redis.js
+src/middleware/apiAuthMiddleware.js
+src/middleware/authMiddleware.js
+src/middleware/domainChecker.js
+src/middleware/redisRateLimiter.js
+src/middleware/validation.js
+src/models/User.js
+src/routes/api_v1.js
+src/routes/auth.js
+src/routes/dashboard.js
+src/routes/public.js
+src/services/emailService.js
+src/services/jwtService.js
+src/services/notification.js
+src/utils/apiKeyHelper.js
+src/utils/recaptchaHelper.js
+views/dashboard.ejs
+views/partials/_forms_table.ejs
+views/partials/_submissions_view.ejs
This section contains the contents of the repository's files.
+
+---
+description:
+globs:
+alwaysApply: false
+---
+Objective: Deliver the minimum set of features a user would expect from a basic form backend service.
+
+use notes.md to track progress!
+
+Task 2.1: User Dashboard & Form Management UI (Replacing current "admin")
+* Mindset Shift: This is no longer your admin panel. It's the user's control center.
+* Subtask 2.1.1: Design User Dashboard Layout:
+* [ ] Wireframe basic layout: List forms, create form, account settings (placeholder).
+* [ ] Decide on frontend tech (EJS is fine for MVP if you make it dynamic with client-side JS, or consider a simple SPA framework if comfortable).
+* Subtask 2.1.2: "My Forms" View:
+* [ ] Fetch and display forms owned by the logged-in user.
+* [ ] Show key info: name, submission count, endpoint URL, created date.
+* [ ] Links to: view submissions, edit settings, delete.
+* Subtask 2.1.3: "Create New Form" Functionality (for logged-in user):
+* [ ] UI and backend logic. Associates form with req.user.id.
+* Subtask 2.1.4: "View Form Submissions" (Scoped & Paginated):
+* [ ] UI and backend for a user to view submissions for their specific form.
+* [ ] Pagination is critical here (as you have).
+* Subtask 2.1.5: Form Settings UI (Basic):
+* [ ] Allow users to update form name.
+* [ ] Placeholder for future settings (thank you URL, notifications).
+* Subtask 2.1.6: Delete Form/Submission (with soft delete/archival consideration):
+* [ ] You have is_archived. Solidify this. Users should be able to archive/unarchive.
+* [ ] True delete should be a confirmed, rare operation.
+
+Task 2.2: Per-Form Configuration by User
+* Mindset Shift: Empower users to customize their form behavior.
+* Subtask 2.2.1: Database Schema Updates for forms Table:
+* [ ] Review existing fields (thank_you_url, thank_you_message, ntfy_enabled, allowed_domains). These are good.
+* [ ] Add email_notifications_enabled (boolean).
+* [ ] Add notification_email_address (string, defaults to user's email, but allow override).
+* Subtask 2.2.2: UI for Form Settings Page:
+* [ ] Create a dedicated page/modal for each form's settings.
+* [ ] Allow users to edit: Name, Thank You URL, Thank You Message, Allowed Domains, Email Notification toggle, Notification Email Address.
+* Subtask 2.2.3: Backend to Save and Apply Settings:
+* [ ] API endpoints to update these settings for a specific form (owned by user).
+* [ ] Logic in /submit/:formUuid to use these form-specific settings.
+
+Task 2.3: Email Notifications for Submissions (Core Feature)
+* Mindset Shift: Ntfy is cool for you. Users expect email.
+* Subtask 2.3.1: Integrate Transactional Email Service:
+* [ ] Sign up for SendGrid, Mailgun, AWS SES (free tiers available).
+* [ ] Install their SDK. Store API key securely (env vars).
+* Subtask 2.3.2: Email Sending Logic:
+* [ ] Create a service/function sendSubmissionNotification(form, submissionData).
+* [ ] If email_notifications_enabled for the form, send an email to notification_email_address.
+* Subtask 2.3.3: Basic Email Template:
+* [ ] Simple, clear email: "New Submission for [Form Name]", list submitted data.
+* Subtask 2.3.4: Error Handling for Email Sending:
+* [ ] Log errors if email fails to send; don't let it break the submission flow.
+
+Task 2.4: Enhanced Spam Protection (Beyond Basic Honeypot)
+* Mindset Shift: Your honeypot is step 1. Real services need more.
+* Subtask 2.4.1: Integrate CAPTCHA (e.g., Google reCAPTCHA):
+* [ ] Sign up for reCAPTCHA (v2 "I'm not a robot" or v3 invisible). Get site/secret keys.
+* [ ] Frontend: Add reCAPTCHA widget/JS to user's HTML form example.
+* [ ] Backend: /submit/:formUuid endpoint must verify reCAPTCHA token with Google.
+* Subtask 2.4.2: User Configuration for Spam Protection:
+* [ ] Allow users to enable/disable reCAPTCHA for their forms (and input their own site key if they want, or use a global one you provide).
+* Subtask 2.4.3: (Future Consideration) Akismet / Content Analysis.
+
+Task 2.5: Basic API for Users to Access Their Data
+* Mindset Shift: Power users and integrations need an API.
+* Subtask 2.5.1: API Key Generation & Management:
+* [ ] Allow users to generate/revoke API keys from their dashboard.
+* [ ] Store hashed API keys in DB, associated with user.
+* Subtask 2.5.2: Secure API Endpoints:
+* [ ] Create new API routes (e.g., /api/v1/forms, /api/v1/forms/:uuid/submissions).
+* [ ] Authenticate using API keys (e.g., Bearer token).
+* Subtask 2.5.3: Basic API Documentation:
+
+* [ ] Simple Markdown file explaining authentication and available endpoints.
+
+
+
+# .env.test
+NODE_ENV=test
+PORT=3001 # Use a different port for testing if your main app might be running
+
+# Test Database Configuration (use a SEPARATE database for testing)
+DB_HOST=localhost # Or your test DB host
+DB_USER=your_test_db_user
+DB_PASSWORD=your_test_db_password
+DB_NAME=forms_db_test # CRITICAL: Use a different database name
+
+# JWT Configuration (can be the same as dev, or specific test secrets)
+JWT_SECRET=your-super-secret-jwt-key-for-tests-only-make-it-different
+JWT_ISSUER=formies-test
+JWT_AUDIENCE=formies-users-test
+JWT_ACCESS_EXPIRY=5s # Short expiry for testing expiration
+JWT_REFRESH_EXPIRY=10s
+
+# Session Configuration
+SESSION_SECRET=your-test-session-secret-key
+
+# Application Configuration
+APP_URL=http://localhost:3001
+
+# Email Configuration (mocked or use a test service like Mailtrap.io)
+SMTP_HOST=
+SMTP_PORT=
+SMTP_SECURE=
+SMTP_USER=
+SMTP_PASS=
+SMTP_FROM_EMAIL=
+RESEND_API_KEY=test_resend_key # So it doesn't try to send real emails
+EMAIL_FROM_ADDRESS=test@formies.local
+
+# Notification Configuration
+NTFY_ENABLED=false # Disable for tests unless specifically testing ntfy
+
+# reCAPTCHA (use test keys or disable for most tests)
+RECAPTCHA_V2_SITE_KEY=your_test_recaptcha_site_key
+RECAPTCHA_V2_SECRET_KEY=your_test_recaptcha_secret_key # Google provides test keys that always pass/fail
+
+# Legacy Admin (if still relevant)
+ADMIN_USER=testadmin
+ADMIN_PASSWORD=testpassword
+
+
-/target
+.env
+package-lock.json
+node_modules
-
-[server]
-bind_address = "127.0.0.1:8080"
-workers = 4
-keep_alive = 60
-client_timeout = 5000
-client_shutdown = 5000
+
+# Formies API Documentation (v1)
-[database]
-url = "form_data.db"
-pool_size = 5
-connection_timeout = 30
+This document provides instructions on how to use the Formies API to access your forms and submission data programmatically.
-[security]
-rate_limit_requests = 100
-rate_limit_interval = 60
-allowed_origins = ["http://localhost:5173"]
-jwt_secret = "your-secret-key"
-jwt_expiration = 3600
+## Authentication
-[logging]
-level = "info"
-format = "json"
-file = "logs/app.log"
-max_size = 10485760 # 10MB
-max_files = 5
+All API requests must be authenticated using an API Key.
-[monitoring]
-sentry_dsn = ""
-enable_metrics = true
-metrics_port = 9090
+1. **Generate an API Key**: You can generate and manage your API keys from your user dashboard under the "API Keys" section.
+2. **Pass the API Key**: The API key must be included in the `Authorization` header of your HTTP requests, using the `Bearer` scheme.
+
+ Example:
+
+ ```
+ Authorization: Bearer YOUR_FULL_API_KEY_HERE
+ ```
+
+ Replace `YOUR_FULL_API_KEY_HERE` with the actual API key you generated (e.g., `fsk_xxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy`).
+
+If authentication fails (e.g., missing key, invalid key, expired key), the API will respond with a `401 Unauthorized` or `403 Forbidden` status code and a JSON error message.
+
+## Endpoints
+
+All API endpoints are prefixed with `/api/v1`.
+
+### 1. List Your Forms
+
+- **Endpoint**: `GET /api/v1/forms`
+- **Method**: `GET`
+- **Authentication**: Required (Bearer Token)
+- **Description**: Retrieves a list of all forms owned by the authenticated user.
+- **Successful Response (200 OK)**:
+ ```json
+ {
+ "success": true,
+ "forms": [
+ {
+ "uuid": "form-uuid-123",
+ "name": "My Contact Form",
+ "created_at": "2023-10-26T10:00:00.000Z",
+ "is_archived": false,
+ "submission_count": 150
+ }
+ // ... other forms
+ ]
+ }
+ ```
+- **Error Responses**:
+ - `401 Unauthorized`: Authentication failed.
+ - `500 Internal Server Error`: If there was an issue fetching the forms.
+
+### 2. List Submissions for a Form
+
+- **Endpoint**: `GET /api/v1/forms/:formUuid/submissions`
+- **Method**: `GET`
+- **Authentication**: Required (Bearer Token)
+- **Path Parameters**:
+ - `formUuid` (string, required): The UUID of the form for which to retrieve submissions.
+- **Query Parameters (for pagination)**:
+ - `page` (integer, optional, default: `1`): The page number of submissions to retrieve.
+ - `limit` (integer, optional, default: `25`): The number of submissions to retrieve per page.
+- **Description**: Retrieves a paginated list of submissions for a specific form owned by the authenticated user.
+- **Successful Response (200 OK)**:
+ ```json
+ {
+ "success": true,
+ "formName": "My Contact Form",
+ "formUuid": "form-uuid-123",
+ "pagination": {
+ "currentPage": 1,
+ "totalPages": 3,
+ "totalSubmissions": 65,
+ "limit": 25,
+ "perPage": 25,
+ "count": 25
+ },
+ "submissions": [
+ {
+ "id": 1,
+ "data": { "email": "test@example.com", "message": "Hello!" },
+ "ip_address": "123.123.123.123",
+ "submitted_at": "2023-10-27T14:30:00.000Z"
+ }
+ // ... other submissions for the current page
+ ]
+ }
+ ```
+- **Error Responses**:
+ - `401 Unauthorized`: Authentication failed.
+ - `403 Forbidden`: If the authenticated user does not own the specified form.
+ - `404 Not Found`: If the specified `formUuid` does not exist.
+ - `500 Internal Server Error`: If there was an issue fetching the submissions.
+
+## General Notes
+
+- All API responses are in JSON format.
+- Successful responses will generally include a `success: true` field.
+- Error responses will include `success: false` and an `error` field (string or object) with details.
-
-
-
-
-
-
- FormCraft - Scandinavian Industrial Form Management
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+# Authentication System Setup Guide
+
+## Overview
+
+This guide will help you set up the robust user authentication and authorization system for your Formies SaaS application. The system includes:
+
+- **JWT-based authentication** with access and refresh tokens
+- **Email verification** with automated emails
+- **Password reset** functionality
+- **Role-based authorization** (user, admin, super_admin)
+- **Account security** features (failed login tracking, account locking)
+- **Rate limiting** to prevent abuse
+- **Session management** with token blacklisting
+
+## Required Dependencies
+
+The following packages have been added to your `package.json`:
+
+```json
+{
+ "bcryptjs": "^2.4.3",
+ "express-rate-limit": "^7.1.5",
+ "express-session": "^1.17.3",
+ "express-validator": "^7.0.1",
+ "jsonwebtoken": "^9.0.2",
+ "nodemailer": "^6.9.8",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "passport-local": "^1.0.0"
+}
+```
+
+## Environment Variables
+
+Create a `.env` file with the following variables:
+
+```env
+# Database Configuration
+DB_HOST=localhost
+DB_USER=your_db_user
+DB_PASSWORD=your_db_password
+DB_NAME=forms_db
+
+# JWT Configuration (REQUIRED)
+JWT_SECRET=your-super-secret-jwt-key-at-least-32-characters-long
+JWT_ISSUER=formies
+JWT_AUDIENCE=formies-users
+JWT_ACCESS_EXPIRY=15m
+JWT_REFRESH_EXPIRY=7d
+
+# Session Configuration
+SESSION_SECRET=your-session-secret-key-change-this-in-production
+
+# Application Configuration
+APP_URL=http://localhost:3000
+NODE_ENV=development
+PORT=3000
+
+# SMTP Email Configuration (Optional but recommended)
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=your-email@gmail.com
+SMTP_PASS=your-app-password
+SMTP_FROM_EMAIL=noreply@yourdomain.com
+
+# Notification Configuration
+NTFY_ENABLED=true
+NTFY_TOPIC_URL=https://ntfy.sh/your-topic
+```
+
+## Database Setup
+
+1. **Install dependencies:**
+
+ ```bash
+ npm install
+ ```
+
+2. **Update your database** by running the updated `init.sql`:
+
+ This script will create all necessary tables, including the `users` table with a default `super_admin` account (`admin@formies.local`).
+ The initial password for this `super_admin` is NOT set in the `init.sql` script. The `must_change_password` flag will be set to `TRUE`.
+
+ ```bash
+ # If using Docker
+ docker-compose down
+ docker-compose up -d
+
+ # Or manually run the SQL file in your MySQL database
+ mysql -u your_user -p your_database < init.sql
+ ```
+
+ If the login is for the `super_admin` (`admin@formies.local`) and it's their first login (`must_change_password` is `TRUE` on the user object returned from the `/login` attempt, even if successful), the API might return a successful login response but the client should check for this flag. Alternatively, the `/login` endpoint itself has been modified to return a `403 Forbidden` response with `code: "MUST_CHANGE_PASSWORD"` directly if this condition is met. The client application should handle this response and prompt the user to use the `/force-change-password` endpoint.
+
+## API Endpoints
+
+### Authentication Endpoints
+
+All authentication endpoints are prefixed with `/api/auth`:
+
+#### Registration
+
+```http
+POST /api/auth/register
+Content-Type: application/json
+
+{
+ "email": "user@example.com",
+ "password": "SecurePass123!",
+ "first_name": "John",
+ "last_name": "Doe"
+}
+```
+
+#### Login
+
+```http
+POST /api/auth/login
+Content-Type: application/json
+
+{
+ "email": "user@example.com",
+ "password": "SecurePass123!"
+}
+
+Response:
+{
+ "success": true,
+ "message": "Login successful",
+ "data": {
+ "user": { ... },
+ "accessToken": "eyJ...",
+ "refreshToken": "eyJ...",
+ "accessTokenExpiresAt": "2024-01-01T00:00:00.000Z",
+ "refreshTokenExpiresAt": "2024-01-07T00:00:00.000Z",
+ "tokenType": "Bearer"
+ }
+}
+```
+
+**Super Admin First Login:**
+
+If the login attempt is for the `super_admin` (`admin@formies.local`) and the `must_change_password` flag is `TRUE` for this user, the `/api/auth/login` endpoint will return a `403 Forbidden` response with the following structure:
+
+```json
+{
+ "success": false,
+ "message": "Password change required.",
+ "code": "MUST_CHANGE_PASSWORD",
+ "data": {
+ "user": {
+ "id": "user_id",
+ "uuid": "user_uuid",
+ "email": "admin@formies.local",
+ "role": "super_admin"
+ }
+ }
+}
+```
+
+The client application should detect this `code: "MUST_CHANGE_PASSWORD"` and guide the user to set a new password using the endpoint below. The `accessToken` and `refreshToken` will NOT be issued in this case. The client will need to make a subsequent call to `/api/auth/force-change-password` using a temporary mechanism if required, or by having the user log in, get the 403, then use a password change form that calls the next endpoint. For the current implementation, the super_admin will receive a standard JWT upon providing correct credentials (even if `must_change_password` is true), and this token should be used for the `/force-change-password` call.
+
+#### Force Password Change
+
+This endpoint is used when a user, particularly the initial `super_admin`, needs to set their password for the first time or has been flagged for a mandatory password update.
+
+```http
+POST /api/auth/force-change-password
+Authorization: Bearer your-access-token-from-login-attempt
+Content-Type: application/json
+
+{
+ "newPassword": "ANewStrongPassword123!"
+}
+
+Response (on success):
+{
+ "success": true,
+ "message": "Password changed successfully. Please log in again with your new password."
+}
+```
+
+After a successful password change using this endpoint:
+
+- The user's password is updated.
+- The `must_change_password` flag is set to `FALSE`.
+- All other active sessions for this user are invalidated for security.
+- The user will need to log in again with their new password to obtain new session tokens.
+
+#### Token Refresh
+
+```http
+POST /api/auth/refresh
+Content-Type: application/json
+
+{
+ "refreshToken": "eyJ..."
+}
+```
+
+#### Logout
+
+```http
+POST /api/auth/logout
+Authorization: Bearer your-access-token
+```
+
+#### Email Verification
+
+```http
+GET /api/auth/verify-email?token=verification_token
+```
+
+#### Profile Management
+
+```http
+GET /api/auth/profile
+Authorization: Bearer your-access-token
+
+PUT /api/auth/profile
+Authorization: Bearer your-access-token
+Content-Type: application/json
+
+{
+ "first_name": "John",
+ "last_name": "Doe",
+ "email": "newemail@example.com"
+}
+```
+
+## Security Features
+
+### Password Requirements
+
+- Minimum 8 characters
+- At least one lowercase letter
+- At least one uppercase letter
+- At least one number
+- At least one special character (@$!%\*?&)
+
+### Account Security
+
+- Failed login attempts are tracked
+- Account locks after 5 failed attempts for 30 minutes
+- Email verification required for new accounts
+- JWT tokens are tracked and can be revoked
+
+### Rate Limiting
+
+- **Login attempts:** 5 per 15 minutes per IP/email
+- **Registration:** 3 per hour per IP
+- **Password reset:** 3 per hour per IP/email
+
+## Using the Authentication System
+
+### Frontend Integration
+
+1. **Store tokens securely:**
+
+ ```javascript
+ // Store in secure httpOnly cookies or localStorage (less secure)
+ localStorage.setItem("accessToken", response.data.accessToken);
+ localStorage.setItem("refreshToken", response.data.refreshToken);
+ ```
+
+2. **Include token in requests:**
+
+ ```javascript
+ fetch("/api/protected-endpoint", {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ });
+ ```
+
+3. **Handle token refresh:**
+
+ ```javascript
+ async function refreshToken() {
+ const refreshToken = localStorage.getItem("refreshToken");
+ const response = await fetch("/api/auth/refresh", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ refreshToken }),
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ localStorage.setItem("accessToken", data.data.accessToken);
+ return data.data.accessToken;
+ } else {
+ // Redirect to login
+ localStorage.removeItem("accessToken");
+ localStorage.removeItem("refreshToken");
+ window.location.href = "/login";
+ }
+ }
+ ```
+
+### Backend Integration
+
+1. **Protect routes with authentication:**
+
+ ```javascript
+ const {
+ requireAuth,
+ requireAdmin,
+ } = require("./src/middleware/authMiddleware");
+
+ // Require authentication
+ router.get("/protected", requireAuth, (req, res) => {
+ res.json({ user: req.user });
+ });
+
+ // Require admin role
+ router.get("/admin-only", requireAdmin, (req, res) => {
+ res.json({ message: "Admin access granted" });
+ });
+ ```
+
+2. **Check resource ownership:**
+
+ ```javascript
+ const {
+ requireOwnershipOrAdmin,
+ } = require("./src/middleware/authMiddleware");
+
+ router.get(
+ "/forms/:id",
+ requireOwnershipOrAdmin(async (req) => {
+ const form = await Form.findById(req.params.id);
+ return form.user_id;
+ }),
+ (req, res) => {
+ // User can only access their own forms or admin can access all
+ }
+ );
+ ```
+
+## Migration from Basic Auth
+
+The system maintains backward compatibility with your existing basic auth. To fully migrate:
+
+1. **Update admin routes** to use the new authentication system
+2. **Create admin users** in the database with appropriate roles
+3. **Remove basic auth middleware** once migration is complete
+
+## Default Admin Account
+
+A default super admin account is created automatically:
+
+- **Email:** admin@formies.local
+- **Password:** admin123 (change immediately!)
+
+## Email Configuration
+
+For email verification and password reset to work, configure SMTP settings:
+
+### Gmail Setup
+
+1. Enable 2-factor authentication
+2. Generate an app password
+3. Use the app password in `SMTP_PASS`
+
+### Other Providers
+
+- **Outlook:** smtp-mail.outlook.com:587
+- **SendGrid:** smtp.sendgrid.net:587
+- **Mailgun:** smtp.mailgun.org:587
+
+## Production Considerations
+
+1. **Use strong secrets:** Generate random JWT_SECRET and SESSION_SECRET
+2. **Enable HTTPS:** Set `NODE_ENV=production` and use SSL certificates
+3. **Use Redis for sessions:** Replace memory sessions with Redis
+4. **Monitor rate limits:** Adjust rate limiting based on usage patterns
+5. **Backup token sessions:** Consider database-backed session storage
+
+## Troubleshooting
+
+### Common Issues
+
+1. **JWT_SECRET not set:**
+
+ ```
+ WARNING: JWT_SECRET not set. Authentication will not work properly.
+ ```
+
+ Solution: Add JWT_SECRET to your .env file
+
+2. **Email service not working:**
+
+ ```
+ Email service not configured. Set SMTP environment variables.
+ ```
+
+ Solution: Configure SMTP settings in .env file
+
+3. **Database connection errors:**
+
+ - Verify database credentials
+ - Ensure database exists
+ - Check if init.sql has been run
+
+4. **Token validation errors:**
+ - Check if JWT_SECRET matches between requests
+ - Verify token hasn't expired
+ - Ensure token is properly formatted in Authorization header
+
+## Testing the System
+
+Use these curl commands to test the authentication endpoints:
+
+```bash
+# Register a new user
+curl -X POST http://localhost:3000/api/auth/register \
+ -H "Content-Type: application/json" \
+ -d '{"email":"test@example.com","password":"TestPass123!","first_name":"Test","last_name":"User"}'
+
+# Login
+curl -X POST http://localhost:3000/api/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"email":"test@example.com","password":"TestPass123!"}'
+
+# Access protected endpoint
+curl -X GET http://localhost:3000/api/auth/profile \
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
+```
+
+This authentication system provides enterprise-grade security for your SaaS application while maintaining flexibility and ease of use.
-
-
-
-
-
-
- Formies
-
-
-
-
-
-
-
-
-
-
-
Formies - Simple Form Manager
-
-
-
-
Login
-
-
-
-
-
-
-
- Welcome, Admin!
-
-
-
-
-
-
-
-
Admin Panel
-
-
-
-
Create New Form
-
-
-
-
-
-
Existing Forms
-
-
-
-
-
-
-
-
-
-
- Submissions for
-
-
-
-
-
-
-
-
-
-
-
Submit to a Form
-
Enter a Form ID to load and submit:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Manage Forms
-
-
-
-
-
-
-
-
-
-
- ×
-
Notification Settings for
-
-
-
-
-
-
-
-
-
-
-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 = "
";
- }
- } 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
- }
+
+const winston = require("winston");
+
+const logger = winston.createLogger({
+ level: "info",
+ format: winston.format.json(),
+ defaultMeta: { service: "user-service" },
+ transports: [
+ //
+ // - Write all logs with importance level of `error` or less to `error.log`
+ // - Write all logs with importance level of `info` or less to `combined.log`
+ //
+ new winston.transports.File({ filename: "error.log", level: "error" }),
+ new winston.transports.File({ filename: "combined.log" }),
+ ],
});
+
+//
+// If we're not in production then log to the `console` with the format:
+// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
+//
+if (process.env.NODE_ENV !== "production") {
+ logger.add(
+ new winston.transports.Console({
+ format: winston.format.simple(),
+ })
+ );
+}
+
+module.exports = logger;
-
-/* --- 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;
+
+version: "3.8"
- --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;
-}
+services:
+ app:
+ build: .
+ ports:
+ - "3000:3000" # Expose app on host port 3000
+ depends_on:
+ db:
+ condition: service_healthy # Wait for DB to be healthy
+ redis:
+ condition: service_started # Wait for Redis to start
+ environment:
+ - DB_HOST=${DB_HOST}
+ - DB_USER=${DB_USER}
+ - DB_PASSWORD=${DB_PASSWORD}
+ - DB_NAME=${DB_NAME}
+ - PORT=${PORT}
+ - REDIS_HOST=${REDIS_HOST:-redis}
+ - REDIS_PORT=${REDIS_PORT:-6379}
+ - REDIS_PASSWORD=${REDIS_PASSWORD:-}
+ restart: unless-stopped
-/* --- Global Reset & Body Styles --- */
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
-}
+ db:
+ image: mysql:8.0
+ ports:
+ - "3307:3306" # Expose DB on host port 3307 (to avoid conflict if you have local MySQL on 3306)
+ environment:
+ MYSQL_ROOT_PASSWORD: your_root_password # Change this
+ MYSQL_DATABASE: ${DB_NAME}
+ MYSQL_USER: ${DB_USER}
+ MYSQL_PASSWORD: ${DB_PASSWORD}
+ volumes:
+ - mysql_data:/var/lib/mysql # Persist database data
+ - ./init.sql:/docker-entrypoint-initdb.d/init.sql # Run init script on startup
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "mysqladmin",
+ "ping",
+ "-h",
+ "localhost",
+ "-u$$MYSQL_USER",
+ "-p$$MYSQL_PASSWORD",
+ ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
-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;
-}
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6380:6379" # Expose Redis on host port 6380 (to avoid conflict if you have local Redis on 6379)
+ command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-}
+ volumes:
+ - redis_data:/data # Persist Redis data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
-/* --- 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;
- }
-}
+volumes:
+ mysql_data:
+ redis_data:
-
-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
-
-
-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
-
-
-// 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
+
+version: "3.8"
+
+services:
+ app:
+ build: .
+ ports:
+ - "3000:3000"
+ environment:
+ - NODE_ENV=development
+ - DB_HOST=mysql
+ - DB_USER=${DB_USER}
+ - DB_PASSWORD=${DB_PASSWORD}
+ - DB_NAME=${DB_NAME}
+ - REDIS_HOST=redis
+ - REDIS_PORT=6379
+ volumes:
+ - .:/usr/src/app
+ - /usr/src/app/node_modules
+ depends_on:
+ - mysql
+ - redis
+
+ mysql:
+ image: mysql:8.0
+ ports:
+ - "3306:3306"
+ environment:
+ - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
+ - MYSQL_DATABASE=${DB_NAME}
+ - MYSQL_USER=${DB_USER}
+ - MYSQL_PASSWORD=${DB_PASSWORD}
+ volumes:
+ - mysql_data:/var/lib/mysql
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+ command: redis-server --appendonly yes
+ volumes:
+ - redis_data:/data
+
+volumes:
+ mysql_data:
+ redis_data:
-# Build stage
-FROM rust:1.70-slim as builder
+FROM node:18.19-alpine AS builder
-WORKDIR /app
+WORKDIR /usr/src/app
-# Install build dependencies
-RUN apt-get update && apt-get install -y \
- pkg-config \
- libsqlite3-dev \
- && rm -rf /var/lib/apt/lists/*
+COPY package*.json ./
+RUN npm ci
-# Copy source code
COPY . .
-# Build the application
-RUN cargo build --release
+FROM node:18.19-alpine
-# Runtime stage
-FROM debian:bullseye-slim
+WORKDIR /usr/src/app
-WORKDIR /app
+# Create a non-root user
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
-# Install runtime dependencies
-RUN apt-get update && apt-get install -y \
- libsqlite3-0 \
- ca-certificates \
- && rm -rf /var/lib/apt/lists/*
+COPY --from=builder /usr/src/app/node_modules ./node_modules
+COPY --from=builder /usr/src/app/package*.json ./
+COPY --from=builder /usr/src/app/ ./
-# Create necessary directories
-RUN mkdir -p /app/data /app/logs
+# Set ownership to non-root user
+RUN chown -R appuser:appgroup /usr/src/app
-# Copy the binary from builder
-COPY --from=builder /app/target/release/formies-be /app/
+USER appuser
-# Copy configuration
-COPY config/default.toml /app/config/default.toml
+EXPOSE 3000
-# Set environment variables
-ENV RUST_LOG=info
-ENV DATABASE_URL=/app/data/form_data.db
-ENV BIND_ADDRESS=0.0.0.0:8080
+CMD ["node", "server.js"]
+
-# Expose port
-EXPOSE 8080
+
+-- init.sql
+CREATE DATABASE IF NOT EXISTS forms_db;
+USE forms_db;
-# Set proper permissions
-RUN chown -R nobody:nogroup /app
-USER nobody
+-- Users table for authentication and authorization
+CREATE TABLE IF NOT EXISTS `users` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `uuid` TEXT NOT NULL UNIQUE,
+ `email` TEXT NOT NULL UNIQUE,
+ `password_hash` TEXT NOT NULL,
+ `first_name` TEXT DEFAULT NULL,
+ `last_name` TEXT DEFAULT NULL,
+ `role` TEXT DEFAULT 'user' CHECK(`role` IN ('user', 'admin', 'super_admin')),
+ `is_verified` INTEGER DEFAULT 0,
+ `is_active` INTEGER DEFAULT 1,
+ `verification_token` TEXT DEFAULT NULL,
+ `password_reset_token` TEXT DEFAULT NULL,
+ `password_reset_expires` DATETIME NULL DEFAULT NULL,
+ `last_login` DATETIME NULL DEFAULT NULL,
+ `failed_login_attempts` INTEGER DEFAULT 0,
+ `account_locked_until` DATETIME NULL DEFAULT NULL,
+ `must_change_password` INTEGER DEFAULT 0,
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (`email`),
+ UNIQUE (`uuid`)
+);
+CREATE INDEX IF NOT EXISTS `idx_email` ON `users` (`email`);
+CREATE INDEX IF NOT EXISTS `idx_verification_token` ON `users` (`verification_token`);
+CREATE INDEX IF NOT EXISTS `idx_password_reset_token` ON `users` (`password_reset_token`);
+CREATE INDEX IF NOT EXISTS `idx_uuid_users` ON `users` (`uuid`);
-# Run the application
-CMD ["./formies-be"]
+-- User sessions table for JWT blacklisting and session management
+CREATE TABLE IF NOT EXISTS `user_sessions` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `user_id` INTEGER NOT NULL,
+ `token_jti` TEXT NOT NULL UNIQUE,
+ `expires_at` DATETIME NOT NULL,
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `user_agent` TEXT DEFAULT NULL,
+ `ip_address` TEXT DEFAULT NULL,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS `idx_token_jti` ON `user_sessions` (`token_jti`);
+CREATE INDEX IF NOT EXISTS `idx_user_id_sessions` ON `user_sessions` (`user_id`);
+CREATE INDEX IF NOT EXISTS `idx_expires_at_sessions` ON `user_sessions` (`expires_at`);
+
+-- Update forms table to associate with users
+CREATE TABLE IF NOT EXISTS `forms` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `uuid` TEXT NOT NULL UNIQUE,
+ `user_id` INTEGER NOT NULL,
+ `name` TEXT DEFAULT 'My Form',
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `thank_you_url` TEXT DEFAULT NULL,
+ `thank_you_message` TEXT DEFAULT NULL,
+ `ntfy_enabled` INTEGER DEFAULT 1,
+ `is_archived` INTEGER DEFAULT 0,
+ `allowed_domains` TEXT DEFAULT NULL,
+ `email_notifications_enabled` INTEGER NOT NULL DEFAULT 0,
+ `notification_email_address` TEXT DEFAULT NULL,
+ `recaptcha_enabled` INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS `idx_user_id_forms` ON `forms` (`user_id`);
+CREATE INDEX IF NOT EXISTS `idx_uuid_forms` ON `forms` (`uuid`);
+
+CREATE TABLE IF NOT EXISTS `submissions` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `form_uuid` TEXT NOT NULL,
+ `user_id` INTEGER NOT NULL,
+ `data` TEXT NOT NULL, -- Storing JSON as TEXT
+ `ip_address` TEXT NULL,
+ `submitted_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (`form_uuid`) REFERENCES `forms`(`uuid`) ON DELETE CASCADE,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS `idx_form_uuid_submissions` ON `submissions` (`form_uuid`);
+CREATE INDEX IF NOT EXISTS `idx_user_id_submissions` ON `submissions` (`user_id`);
+CREATE INDEX IF NOT EXISTS `idx_submitted_at_submissions` ON `submissions` (`submitted_at`);
+
+-- Rate limiting table for enhanced security (Simplified for SQLite)
+-- Note: TIMESTAMP logic for window_start and expires_at might need application-level management
+-- depending on how it was used with MySQL.
+CREATE TABLE IF NOT EXISTS `rate_limits` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `identifier` TEXT NOT NULL,
+ `action` TEXT NOT NULL,
+ `count` INTEGER DEFAULT 1,
+ `window_start` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `expires_at` DATETIME NOT NULL,
+ UNIQUE (`identifier`, `action`)
+);
+CREATE INDEX IF NOT EXISTS `idx_identifier_action_rate_limits` ON `rate_limits` (`identifier`, `action`);
+CREATE INDEX IF NOT EXISTS `idx_expires_at_rate_limits` ON `rate_limits` (`expires_at`);
+
+-- Create default admin user (password will be set on first login)
+-- You should change this immediately after first login
+INSERT OR IGNORE INTO users (email, password_hash, first_name, last_name, role, is_verified, is_active, must_change_password, uuid)
+VALUES ('admin@formies.local', 'NEEDS_TO_BE_SET_ON_FIRST_LOGIN', 'Admin', 'User', 'super_admin', 1, 1, 1, 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'); -- Placeholder UUID, generate dynamically in app if needed
+
+-- API Keys table for user-generated API access
+CREATE TABLE IF NOT EXISTS `api_keys` (
+ `id` INTEGER PRIMARY KEY AUTOINCREMENT,
+ `uuid` TEXT NOT NULL UNIQUE,
+ `user_id` INTEGER NOT NULL,
+ `key_name` TEXT DEFAULT NULL,
+ `api_key_identifier` TEXT NOT NULL UNIQUE, -- Public, non-secret identifier for lookup
+ `hashed_api_key_secret` TEXT NOT NULL, -- Hashed version of the secret part of the API key
+ `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `last_used_at` DATETIME NULL DEFAULT NULL,
+ `expires_at` DATETIME NULL DEFAULT NULL, -- For future use
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS `idx_user_id_api_keys` ON `api_keys` (`user_id`);
+CREATE INDEX IF NOT EXISTS `idx_api_key_identifier_api_keys` ON `api_keys` (`api_key_identifier`);
+
+-- Trigger to update 'updated_at' timestamp on users table (optional, can be handled in app code)
+CREATE TRIGGER IF NOT EXISTS update_users_updated_at
+AFTER UPDATE ON users
+FOR EACH ROW
+BEGIN
+ UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
+END;
+
+-- Trigger to update 'updated_at' timestamp on forms table (optional, can be handled in app code)
+CREATE TRIGGER IF NOT EXISTS update_forms_updated_at
+AFTER UPDATE ON forms
+FOR EACH ROW
+BEGIN
+ UPDATE forms SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
+END;
+
+
+
+// jest.config.js
+module.exports = {
+ testEnvironment: "node",
+ verbose: true,
+ coveragePathIgnorePatterns: [
+ "/node_modules/",
+ "/__tests__/setup/", // Ignore setup files from coverage
+ "/src/config/", // Often configuration files don't need testing
+ "/config/", // logger config
+ ],
+ // Automatically clear mock calls and instances between every test
+ clearMocks: true,
+ // A path to a module which exports an async function that is triggered once before all test suites
+ // globalSetup: './__tests__/setup/globalSetup.js', // Optional: If you need global setup
+ // A path to a module which exports an async function that is triggered once after all test suites
+ // globalTeardown: './__tests__/setup/globalTeardown.js', // Optional: If you need global teardown
+ // The directory where Jest should output its coverage files
+ coverageDirectory: "coverage",
+ // Indicates whether the coverage information should be collected while executing the test
+ collectCoverage: true,
+ // An array of glob patterns indicating a set of files for which coverage information should be collected
+ collectCoverageFrom: [
+ "src/**/*.js",
+ "!server.js", // Usually the main server start file is hard to unit test directly
+ "!src/app.js", // If you extract Express app setup to app.js for testability
+ ],
+ setupFilesAfterEnv: ["./__tests__/setup/jest.setup.js"], // For things like extending expect
+};
+
+
+
+const logger = require("../config/logger");
+
+const errorHandler = (err, req, res, next) => {
+ logger.error(err.message, {
+ stack: err.stack,
+ path: req.path,
+ method: req.method,
+ });
+
+ // If the error is a known type, customize the response
+ // Otherwise, send a generic server error
+ if (err.isOperational) {
+ // You can add an 'isOperational' property to your custom errors
+ res.status(err.statusCode || 500).json({
+ error: {
+ message: err.message,
+ code: err.errorCode || "INTERNAL_SERVER_ERROR",
+ },
+ });
+ } else {
+ // For unexpected errors, don't leak details to the client
+ res.status(500).json({
+ error: {
+ message: "An unexpected error occurred.",
+ code: "INTERNAL_SERVER_ERROR",
+ },
+ });
+ }
+};
+
+module.exports = errorHandler;
+
+
+
+## Task 2.1: User Dashboard & Form Management UI (Replacing current "admin")
+
+- Mindset Shift: This is no longer an admin panel. It's the user's control center.
+
+### Subtask 2.1.1: Design User Dashboard Layout
+
+- **Wireframe basic layout:**
+ - **Navigation Bar:**
+ - Logo/App Name (e.g., "Formies")
+ - My Forms (Active Link)
+ - Create New Form
+ - Account Settings (e.g., "Hi, [User Name]" dropdown with "Settings", "Logout")
+ - **Main Content Area (for "My Forms" view):**
+ - Header: "My Forms"
+ - Button: "+ Create New Form"
+ - Forms List Table:
+ - Columns: Form Name, Submissions (count), Endpoint URL, Created Date, Actions
+ - Actions per row: View Submissions, Settings, Archive/Delete
+ - Pagination for the forms list if it becomes long.
+ - **Main Content Area (for "Create New Form" view - initial thought, might be a separate page/modal):**
+ - Header: "Create New Form"
+ - Form fields: Form Name
+ - Button: "Create Form"
+ - **Main Content Area (for "Account Settings" - placeholder for now):**
+ - Header: "Account Settings"
+ - Placeholder content.
+- **Frontend Tech Decision:**
+ - EJS for templating, made dynamic with client-side JavaScript. This aligns with the existing structure and MVP scope. We will enhance EJS views to be more interactive.
+
+[X] Wireframe basic layout: List forms, create form, account settings (placeholder). - _Textual wireframe defined above_
+[X] Decide on frontend tech (EJS is fine for MVP if you make it dynamic with client-side JS, or consider a simple SPA framework if comfortable). - _Decision made: EJS with client-side JS_
+
+- Created `views/dashboard.ejs` as the main layout.
+- Created `views/partials/_forms_table.ejs` for displaying the list of forms.
+
+### Subtask 2.1.2: "My Forms" View:
+
+- Objective: Fetch and display forms owned by the logged-in user.
+- Show key info: name, submission count, endpoint URL, created date, status (Active/Archived).
+- Links/Actions: View Submissions, Settings, Archive/Unarchive, Delete.
+- Frontend: `views/dashboard.ejs` with `view = 'my_forms'` and `views/partials/_forms_table.ejs` will handle this.
+- Backend:
+ - Need a new route, e.g., `GET /dashboard`, protected by authentication (e.g., `requireAuth` from `authMiddleware.js`).
+ - This route handler will:
+ - Fetch forms for `req.user.id` from the database.
+ - Query should include `name`, `uuid`, `created_at`, `is_archived`, and `submission_count`.
+ - Render `views/dashboard.ejs` passing the forms data, `user` object, `appUrl`, and `view = 'my_forms'`.
+ - Implemented in `src/routes/dashboard.js` via GET `/`.
+
+[X] Fetch and display forms owned by the logged-in user.
+[X] Show key info: name, submission count, endpoint URL, created date.
+[X] Links to: view submissions, edit settings, delete. (Links are present in `_forms_table.ejs`, functionality for all to be built out in subsequent tasks)
+
+### Subtask 2.1.3: "Create New Form" Functionality (for logged-in user):
+
+- UI: `dashboard.ejs` (with `view = 'create_form'`) provides the form input.
+ - Route `GET /dashboard/create-form` in `src/routes/dashboard.js` renders this view.
+- Backend: `POST /dashboard/forms/create` route in `src/routes/dashboard.js` handles form submission.
+ - Associates form with `req.user.id`.
+ - Redirects to `/dashboard` on success.
+ - Handles errors and re-renders create form view with an error message.
+
+[X] UI and backend logic. Associates form with req.user.id.
+
+### Subtask 2.1.4: "View Form Submissions" (Scoped & Paginated):
+
+- Objective: Allow users to view submissions for their specific forms, with pagination.
+- UI:
+ - `views/partials/_submissions_view.ejs` created to display submissions list and pagination.
+ - `views/dashboard.ejs` updated to include this partial when `view = 'form_submissions'`.
+- Backend:
+ - Route: `GET /dashboard/submissions/:formUuid` added to `src/routes/dashboard.js`.
+ - Verifies that `req.user.id` owns the `formUuid`.
+ - Fetches paginated submissions for the given `formUuid`.
+ - Renders `dashboard.ejs` with `view = 'form_submissions'`, passing submissions data, form details, and pagination info.
+ - Error handling improved to render user-friendly messages within the dashboard view.
+
+[X] UI and backend for a user to view submissions for their specific form.
+[X] Pagination is critical here (as you have).
+
+### Subtask 2.1.5: Form Settings UI (Basic):
+
+- Objective: Allow users to update basic form settings, starting with the form name.
+- UI:
+ - A new view/section in `dashboard.ejs` (e.g., when `view = 'form_settings'`).
+ - This view will display a form with an input for the form name.
+ - It will also be a placeholder for future settings (thank you URL, notifications).
+- Backend:
+ - Route: `GET /dashboard/forms/:formUuid/settings` to display the settings page.
+ - Implemented in `src/routes/dashboard.js`.
+ - Verifies form ownership by `req.user.id`.
+ - Fetches current form details (name).
+ - Renders the `form_settings` view in `dashboard.ejs`.
+ - Route: `POST /dashboard/forms/:formUuid/settings/update-name` to handle the update.
+ - Implemented in `src/routes/dashboard.js`.
+ - Verifies form ownership.
+ - Updates the form name in the database.
+ - Redirects back to form settings page with a success/error message via query parameters.
+
+[X] Allow users to update form name.
+[X] Placeholder for future settings (thank you URL, notifications) - (Placeholders added in EJS).
+
+### Subtask 2.1.6: Delete Form/Submission (with soft delete/archival consideration):
+
+- Objective: Implement form archival (soft delete) and permanent deletion for users.
+- Users should be able to archive/unarchive their forms.
+- True delete should be a confirmed, rare operation.
+- The `is_archived` field in the `forms` table will be used.
+- Submissions deletion is already partially handled in `_submissions_view.ejs` via a POST to `/dashboard/submissions/delete/:submissionId`. We need to implement this backend route.
+
+- **Form Archival/Unarchival:**
+ - UI: Buttons for "Archive" / "Unarchive" are already in `views/partials/_forms_table.ejs`.
+ - Archive action: `POST /dashboard/forms/archive/:formUuid`
+ - Unarchive action: `POST /dashboard/forms/unarchive/:formUuid`
+ - Backend:
+ - Create these two POST routes in `src/routes/dashboard.js`.
+ - Must verify form ownership by `req.user.id`.
+ - Fetch current form details (name).
+ - Render the settings view.
+ - Route: `POST /dashboard/forms/:formUuid/settings` (or `/dashboard/forms/:formUuid/update-name`) to handle the update.
+ - Must verify form ownership.
+ - Update the form name in the database.
+ - Redirect back to form settings page or main dashboard with a success message.
+
+* **Submission Deletion (User-scoped):**
+ - UI: "Delete" button per submission in `views/partials/_submissions_view.ejs` (with `confirm()` dialog).
+ - Action: `POST /dashboard/submissions/delete/:submissionId`
+ - Backend (in `src/routes/dashboard.js`):
+ - Implemented `POST /dashboard/submissions/delete/:submissionId`:
+ - Verifies the `req.user.id` owns the form to which the submission belongs.
+ - Deletes the specific submission.
+ - Redirects back to the form's submissions view (`/dashboard/submissions/:formUuid`) with message.
+
+[X] You have is_archived. Solidify this. Users should be able to archive/unarchive.
+[X] True delete should be a confirmed, rare operation.
+[X] Implement user-scoped submission deletion.
+
+## Task 2.2: Per-Form Configuration by User
+
+- Mindset Shift: Empower users to customize their form behavior.
+
+### Subtask 2.2.1: Database Schema Updates for forms Table:
+
+- Objective: Add new fields to the `forms` table to support per-form email notification settings.
+- Review existing fields (`thank_you_url`, `thank_you_message`, `ntfy_enabled`, `allowed_domains`) - these are good as per plan.
+- **New fields to add:**
+ - `email_notifications_enabled` (BOOLEAN, DEFAULT FALSE, NOT NULL)
+ - `notification_email_address` (VARCHAR(255), NULL) - This will store an override email address. If NULL, the user's primary email will be used.
+
+[X] Review existing fields (thank_you_url, thank_you_message, ntfy_enabled, allowed_domains). These are good.
+[X] Add email_notifications_enabled (boolean). (Added to `init.sql`)
+[X] Add notification_email_address (string, defaults to user's email, but allow override). (Added to `init.sql`)
+
+### Subtask 2.2.2: UI for Form Settings Page:
+
+- Objective: Enhance the form settings page to allow users to configure these new email notification options.
+- The existing form settings page is `dashboard.ejs` with `view = 'form_settings'` (created in Subtask 2.1.5).
+- **UI Elements to add to this page:**
+ - **Email Notifications Section:**
+ - Checkbox/Toggle: "Enable Email Notifications for new submissions"
+ - Controls `email_notifications_enabled`.
+ - Input Field (text, email type): "Notification Email Address"
+ - Controls `notification_email_address`.
+ - Should be pre-filled with the user's primary email if `notification_email_address` is NULL/empty in the DB.
+ - Label should indicate that if left blank, notifications will go to the account email.
+- The `GET /dashboard/forms/:formUuid/settings` route will need to fetch these new fields.
+- The form on this page will need to be updated to submit these new fields. The POST route will likely be `/dashboard/forms/:formUuid/settings/update-notifications` or similar, or a general update to the existing `/dashboard/forms/:formUuid/settings/update-name` to become a general settings update route.
+
+[X] Create a dedicated page/modal for each form's settings. (Using existing settings section in `dashboard.ejs`)
+[X] Allow users to edit: Name, Email Notification toggle, Notification Email Address. (Thank You URL, Thank You Message, Allowed Domains are placeholders for now as per 2.1.5).
+_ UI elements added to `dashboard.ejs` in the `form_settings` view.
+_ `GET /dashboard/forms/:formUuid/settings` in `src/routes/dashboard.js` updated to fetch and pass these settings. \* `POST /dashboard/forms/:formUuid/settings/update-notifications` in `src/routes/dashboard.js` created to save these settings.
+
+### Subtask 2.2.3: Backend to Save and Apply Settings:
+
+- Objective: Ensure the backend API endpoints correctly save and the submission logic uses these settings.
+- API endpoints to update settings for a specific form (owned by user):
+ - `POST .../update-name` (Done in 2.1.5)
+ - `POST .../update-notifications` (Done in 2.2.2)
+ - Future: endpoints for Thank You URL, Message, Allowed Domains.
+- Logic in `/submit/:formUuid` to use these form-specific settings:
+ - When a form is submitted to `/submit/:formUuid`:
+ - Fetch the form's settings from the DB, including `email_notifications_enabled` and `notification_email_address`.
+ - This logic is now implemented in `src/routes/public.js` as part of Task 2.3.2 integration.
+
+[X] API endpoints to update these settings for a specific form (owned by user). (Name and Email Notification settings covered so far)
+[X] Logic in /submit/:formUuid to use these form-specific settings. (Addressed as part of 2.3.2)
+
+## Task 2.3: Email Notifications for Submissions (Core Feature)
+
+- Mindset Shift: Ntfy is cool for you. Users expect email.
+
+### Subtask 2.3.1: Integrate Transactional Email Service:
+
+- Objective: Set up a third-party email service to send submission notifications.
+- **Action for you (USER):**
+ - Choose a transactional email service (e.g., SendGrid, Mailgun, AWS SES). Many offer free tiers.
+ - Sign up for the service and obtain an API Key.
+ - Securely store this API Key as an environment variable in your `.env` file.
+ - For example, if you choose SendGrid, you might use `SENDGRID_API_KEY=your_actual_api_key`.
+ - Also, note the sender email address you configure with the service (e.g., `EMAIL_FROM_ADDRESS=notifications@yourdomain.com`).
+- Once you have these, let me know which service you've chosen so I can help with installing the correct SDK and writing the integration code.
+ - User selected: Resend
+ - API Key ENV Var: `RESEND_API_KEY`
+ - From Email ENV Var: `EMAIL_FROM_ADDRESS`
+
+[X] Sign up for SendGrid, Mailgun, AWS SES (free tiers available). (User selected Resend)
+[X] Install their SDK. (npm install resend done)
+[X] Store API key securely (env vars). (User confirmed `RESEND_API_KEY` and `EMAIL_FROM_ADDRESS` are set up)
+
+### Subtask 2.3.2: Email Sending Logic:
+
+- Objective: Create a reusable service/function to handle the sending of submission notification emails.
+- This service will use the Resend SDK and the configured API key.
+- **Create a new service file:** `src/services/emailService.js`
+ - It should export a function, e.g., `sendSubmissionNotification(form, submissionData, userEmail)`.
+ - `form`: An object containing form details (`name`, `email_notifications_enabled`, `notification_email_address`).
+ - `submissionData`: The actual data submitted to the form.
+ - `userEmail`: The email of the user who owns the form (to be used if `form.notification_email_address` is not set).
+ - Inside the function:
+ - Check if `form.email_notifications_enabled` is true.
+ - Determine the recipient: `form.notification_email_address` or `userEmail`.
+ - Construct the email subject and body (using a basic template for now - Subtask 2.3.3).
+ - Use the Resend SDK to send the email.
+ - Include error handling (Subtask 2.3.4).
+
+[X] Create a service/function sendSubmissionNotification(form, submissionData, userEmail) - (`src/services/emailService.js` created with this function).
+[X] If email_notifications_enabled for the form, send an email to notification_email_address (or user's email). - (Logic implemented in `emailService.js` and integrated into `/submit/:formUuid` route in `src/routes/public.js`).
+
+### Subtask 2.3.3: Basic Email Template:
+
+- Objective: Define a simple, clear email template for notifications.
+- The current `createEmailHtmlBody` function in `src/services/emailService.js` provides a very basic HTML template:
+ - Subject: "New Submission for [Form Name]"
+ - Body: Lists submitted data (key-value pairs).
+- This fulfills the MVP requirement.
+
+[X] Simple, clear email: "New Submission for [Form Name]", list submitted data. (Implemented in `emailService.js`)
+
+### Subtask 2.3.4: Error Handling for Email Sending:
+
+- Objective: Ensure email sending failures don't break the submission flow and are logged.
+- In `src/services/emailService.js`, within `sendSubmissionNotification`:
+ - Errors from `resend.emails.send()` are caught and logged.
+ - The function does not throw an error that would halt the caller, allowing the submission to be considered successful even if the email fails.
+- In `src/routes/public.js` (`/submit/:formUuid` route):
+ - The call to `sendSubmissionNotification` is followed by `.catch()` to log any unexpected errors from the email sending promise itself, ensuring the main response to the user is not blocked.
+
+[X] Log errors if email fails to send; don't let it break the submission flow. (Implemented in `emailService.js` and `public.js` route)
+
+## Task 2.4: Enhanced Spam Protection (Beyond Basic Honeypot)
+
+- Mindset Shift: Your honeypot is step 1. Real services need more.
+
+### Subtask 2.4.1: Integrate CAPTCHA (e.g., Google reCAPTCHA):
+
+- Objective: Add server-side CAPTCHA validation to the form submission process.
+- We'll use Google reCAPTCHA v2 ("I'm not a robot" checkbox) for this MVP.
+- **Action for you (USER):**
+ - Go to the [Google reCAPTCHA admin console](https://www.google.com/recaptcha/admin/create).
+ - Register your site: Choose reCAPTCHA v2, then "I'm not a robot" Checkbox.
+ - Add your domain(s) (e.g., `localhost` for development, and your production domain).
+ - Accept the terms of service.
+ - You will receive a **Site Key** and a **Secret Key**.
+ - Store these securely in your `.env` file:
+ - `RECAPTCHA_V2_SITE_KEY=your_site_key`
+ - `RECAPTCHA_V2_SECRET_KEY=your_secret_key`
+- Let me know once you have these keys set up in your `.env` file.
+
+- **Frontend Changes (Illustrative - User will implement on their actual forms):**
+ - User needs to include the reCAPTCHA API script in their HTML form page: ``
+ - User needs to add the reCAPTCHA widget div where the checkbox should appear: `` (replacing with the actual site key, possibly passed from server or configured client-side if site key is public).
+- **Backend Changes (`/submit/:formUuid` route in `src/routes/public.js`):**
+ - When a submission is received, it should include a `g-recaptcha-response` field from the reCAPTCHA widget.
+ - Create a new middleware or a helper function `verifyRecaptcha(recaptchaResponse, clientIp)`.
+ - This function will make a POST request to Google's verification URL: `https://www.google.com/recaptcha/api/siteverify`.
+ - Parameters: `secret` (your `RECAPTCHA_V2_SECRET_KEY`), `response` (the `g-recaptcha-response` value), `remoteip` (optional, user's IP).
+ - The response from Google will be JSON indicating success or failure.
+ - In the `/submit` route, call this verification function. If verification fails, reject the submission with an appropriate error.
+
+[X] Sign up for reCAPTCHA (v2 "I'm not a robot" or v3 invisible). Get site/secret keys. (User action) - _User confirmed keys are in .env_
+[ ] Frontend: Add reCAPTCHA widget/JS to user's HTML form example. (User responsibility for their forms)
+[X] Backend: /submit/:formUuid endpoint must verify reCAPTCHA token with Google. (_Already implemented in `src/routes/public.js` using `src/utils/recaptchaHelper.js`_)
+
+### Subtask 2.4.2: User Configuration for Spam Protection:
+
+- [x] Database Schema: Add `recaptcha_enabled` (BOOLEAN, DEFAULT FALSE) to `forms` table. (_Done in `init.sql`_)
+- [x] UI: Added reCAPTCHA toggle to Form Settings page (`dashboard.ejs`) and consolidated settings form to POST to `/dashboard/forms/:formUuid/settings/update`. (_Done_)
+- [x] Backend:
+ - [x] `GET /dashboard/forms/:formUuid/settings` fetches and passes `recaptcha_enabled`. (_Done_)
+ - [x] Consolidated `POST /dashboard/forms/:formUuid/settings/update` saves `recaptcha_enabled` and other settings (formName, emailNotificationsEnabled, notificationEmailAddress). (_Done_)
+ - [x] `/submit/:formUuid` in `public.js` now checks form's `recaptcha_enabled` flag: if true, token is required & verified; if false, check is skipped. (_Done_)
+- [x] Allow users to enable/disable reCAPTCHA for their forms (and input their own site key if they want, or use a global one you provide). - _Implemented using global keys for MVP._
+
+- Subtask 2.4.3: (Future Consideration) Akismet / Content Analysis.
+
+## Task 2.5: Basic API for Users to Access Their Data
+
+- Mindset Shift: Power users and integrations need an API.
+
+### Subtask 2.5.1: API Key Generation & Management:
+
+- Objective: Allow users to generate/revoke API keys from their dashboard.
+- **Action for you (USER):**
+ - Choose a RESTful API framework (e.g., Express, Fastify).
+ - Implement the API endpoints to allow users to access their data.
+ - Ensure the API is secure and uses authentication.
+- Let me know once you have the API implemented and tested.
+
+[X] Database Schema: Create `api_keys` table (user*id, key_name, api_key_identifier, hashed_api_key_secret, etc.). (\_Done in `init.sql` with refined structure*)
+[X] Helper Utilities: Created `src/utils/apiKeyHelper.js` with `generateApiKeyParts`, `hashApiKeySecret`, `compareApiKeySecret`. (_Done_)
+[X] Backend Routes: Added `GET /dashboard/api-keys` (list), `POST /dashboard/api-keys/generate` (create), `POST /dashboard/api-keys/:apiKeyUuid/revoke` (delete) to `src/routes/dashboard.js`. (_Done_)
+[X] UI in Dashboard: Added "API Keys" section to `dashboard.ejs` for generating, listing (name, identifier, created/last*used), and revoking keys. Displays newly generated key once via session. (\_Done*)
+[X] Allow users to generate/revoke API keys from their dashboard. (_Done_)
+[X] Store hashed API keys in DB, associated with user. (_Done via backend routes and helpers_)
+
+### Subtask 2.5.2: Secure API Endpoints:
+
+- Objective: Ensure the API is secure and uses authentication.
+- **Action for you (USER):**
+ - Choose a RESTful API framework (e.g., Express, Fastify).
+ - Implement the API endpoints to allow users to access their data.
+ - Ensure the API is secure and uses authentication.
+- Let me know once you have the API implemented and tested.
+
+[X] Created `src/middleware/apiAuthMiddleware.js` for Bearer token authentication (checks signature, expiry, active user, updates last*used). (\_Done*)
+[X] Created `src/routes/api_v1.js` and mounted it at `/api/v1` in `server.js`. (_Done_)
+[X] Added `GET /api/v1/forms` (list user's forms) and `GET /api/v1/forms/:formUuid/submissions` (list form submissions, paginated), both protected by the API auth middleware. (_Done_)
+[X] Create new API routes (e.g., /api/v1/forms, /api/v1/forms/:uuid/submissions). (_Covered by above point_)
+[X] Authenticate using API keys (e.g., Bearer token). (_Done_)
+
+### Subtask 2.5.3: Basic API Documentation:
+
+- Objective: Provide basic documentation for the API.
+- **Action for you (USER):**
+ - Choose a documentation format (e.g., Swagger, Postman, Markdown).
+ - Implement the documentation for the API endpoints.
+- Let me know once you have the API documentation implemented.
+
+[ ] Simple Markdown file explaining authentication and available endpoints.
+
+
+
+{
+ "name": "formies",
+ "version": "1.0.0",
+ "main": "server.js",
+ "scripts": {
+ "test": "NODE_ENV=test jest --runInBand --detectOpenHandles --forceExit",
+ "test:watch": "NODE_ENV=test jest --watch",
+ "start": "node server.js",
+ "dev": "nodemon server.js"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "dependencies": {
+ "basic-auth": "^2.0.1",
+ "bcryptjs": "^2.4.3",
+ "dotenv": "^16.5.0",
+ "ejs": "^3.1.10",
+ "express": "^5.1.0",
+ "express-rate-limit": "^7.1.5",
+ "express-session": "^1.17.3",
+ "express-validator": "^7.0.1",
+ "helmet": "^8.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "nodemailer": "^6.9.8",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "passport-local": "^1.0.0",
+ "rate-limit-redis": "^4.2.0",
+ "redis": "^4.7.0",
+ "resend": "^4.5.1",
+ "sqlite3": "^5.1.7",
+ "uuid": "^11.1.0",
+ "winston": "^3.17.0"
+ },
+ "devDependencies": {
+ "nodemon": "^3.0.2",
+ "jest": "^29.7.0",
+ "supertest": "^7.0.0"
+ }
+}
+
+
+
+# Rate Limiting Documentation
+
+## Overview
+
+This application now implements a scalable Redis-backed rate limiting system to protect against abuse and ensure fair usage of the form submission endpoints.
+
+## Rate Limiting Strategy
+
+The `/submit/:formUuid` endpoint is protected by three layers of rate limiting:
+
+### 1. Strict Rate Limiter (First Layer)
+
+- **Window**: 1 hour
+- **Limit**: 50 requests per IP address across all forms
+- **Purpose**: Prevents aggressive abuse from single IP addresses
+- **Key**: `strict_ip:{ip_address}`
+
+### 2. General Submission Rate Limiter (Second Layer)
+
+- **Window**: 15 minutes
+- **Limit**: 10 requests per IP address for any form submissions
+- **Purpose**: Prevents rapid-fire submissions from legitimate users
+- **Key**: `submit_ip:{ip_address}`
+
+### 3. Form-Specific Rate Limiter (Third Layer)
+
+- **Window**: 5 minutes
+- **Limit**: 3 requests per IP address per specific form
+- **Purpose**: Prevents spam on individual forms
+- **Key**: `submit_form:{formUuid}:{ip_address}`
+
+## Infrastructure
+
+### Redis Configuration
+
+#### Development Environment
+
+- **Service**: `redis:7-alpine`
+- **Port**: `6379`
+- **Data Persistence**: Yes (Redis AOF)
+- **Volume**: `redis_data:/data`
+
+#### Production Environment
+
+- **Service**: `redis:7-alpine`
+- **Port**: `6380` (external, to avoid conflicts)
+- **Data Persistence**: Yes (Redis AOF)
+- **Volume**: `redis_data:/data`
+- **Password Protection**: Configurable via `REDIS_PASSWORD`
+- **Health Checks**: Enabled
+
+### Environment Variables
+
+```env
+# Redis Configuration
+REDIS_HOST=redis # Redis hostname (default: redis in Docker, localhost otherwise)
+REDIS_PORT=6379 # Redis port (default: 6379)
+REDIS_PASSWORD= # Optional Redis password (production recommended)
+```
+
+## Fallback Mechanism
+
+If Redis is unavailable, the system automatically falls back to an in-memory rate limiter:
+
+- **Graceful Degradation**: Application continues to function without Redis
+- **Automatic Detection**: Detects Redis availability and switches accordingly
+- **Logging**: Warns when falling back to memory store
+- **Same Limits**: Maintains the same rate limiting rules
+
+## Rate Limit Headers
+
+When rate limits are applied, the following headers are returned:
+
+- `RateLimit-Limit`: Maximum number of requests allowed
+- `RateLimit-Remaining`: Number of requests remaining in window
+- `RateLimit-Reset`: Time when the rate limit window resets
+
+## Error Responses
+
+When rate limits are exceeded, the API returns:
+
+```json
+{
+ "error": "Too many requests from this IP address. Please try again later."
+}
+```
+
+The specific error message varies by rate limiter:
+
+- **Strict**: "Too many requests from this IP address. Please try again later."
+- **General**: "Too many form submissions from this IP address. Please try again later."
+- **Form-Specific**: "Too many submissions for this form from your IP address. Please try again later."
+
+## Deployment
+
+### Starting Services
+
+#### Development
+
+```bash
+docker-compose up -d
+```
+
+#### Production
+
+```bash
+docker-compose -f docker-compose.prod.yml up -d
+```
+
+### Monitoring Redis
+
+Check Redis connection:
+
+```bash
+docker exec -it formies-redis-1 redis-cli ping
+```
+
+View rate limiting keys:
+
+```bash
+docker exec -it formies-redis-1 redis-cli --scan --pattern "submit_*"
+```
+
+## Security Considerations
+
+1. **Redis Security**: In production, always use password authentication
+2. **Network Security**: Redis should not be exposed to public networks
+3. **Data Persistence**: Redis data is persisted to handle container restarts
+4. **Graceful Shutdown**: Application properly closes Redis connections on exit
+
+## Performance
+
+- **Scalability**: Redis-backed rate limiting scales across multiple application instances
+- **Efficiency**: O(1) operations for rate limit checks
+- **Memory Usage**: Efficient key expiration prevents memory leaks
+- **High Availability**: Can be configured with Redis clustering for production
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Redis Connection Failed**
+
+ - Check if Redis container is running
+ - Verify environment variables
+ - Check Docker network connectivity
+
+2. **Rate Limiting Not Working**
+
+ - Verify Redis connection in application logs
+ - Check if fallback to memory store is occurring
+ - Ensure proper IP address detection
+
+3. **Performance Issues**
+ - Monitor Redis memory usage
+ - Check for connection pooling configuration
+ - Verify network latency between app and Redis
+
+### Logs to Monitor
+
+- Redis connection status
+- Rate limiter fallback warnings
+- Rate limit exceeded events
+- Redis error messages
+
+
+
+require("dotenv").config();
+const express = require("express");
+const path = require("path");
+const fs = require("fs"); // Added for fs operations
+const db = require("./src/config/database"); // SQLite db instance
+const helmet = require("helmet");
+const session = require("express-session");
+const passport = require("./src/config/passport");
+const logger = require("./config/logger");
+const errorHandler = require("./middleware/errorHandler");
+const { connectRedis, closeRedis } = require("./src/config/redis");
+
+// Import routes
+const publicRoutes = require("./src/routes/public");
+const authRoutes = require("./src/routes/auth");
+const dashboardRoutes = require("./src/routes/dashboard");
+const apiV1Routes = require("./src/routes/api_v1");
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+
+// Function to initialize the database
+async function initializeDatabase() {
+ const dbPath = path.resolve(__dirname, "formies.sqlite");
+ const dbExists = fs.existsSync(dbPath);
+
+ if (!dbExists) {
+ logger.info("Database file not found, creating and initializing...");
+ try {
+ // The 'db' instance from './src/config/database' should already create the file.
+ // Now, run the init.sql script.
+ const initSql = fs.readFileSync(
+ path.resolve(__dirname, "init.sql"),
+ "utf8"
+ );
+ // SQLite driver's `exec` method can run multiple statements
+ await new Promise((resolve, reject) => {
+ db.exec(initSql, (err) => {
+ if (err) {
+ logger.error("Failed to initialize database:", err);
+ return reject(err);
+ }
+ logger.info("Database initialized successfully.");
+ resolve();
+ });
+ });
+ } catch (error) {
+ logger.error("Error during database initialization:", error);
+ process.exit(1); // Exit if DB initialization fails
+ }
+ } else {
+ logger.info("Database file found.");
+ }
+}
+
+// Initialize Redis connection and Database
+async function initializeApp() {
+ // Initialize Redis first, but don't block on failure
+ connectRedis().catch(() => {
+ logger.warn(
+ "Redis connection failed, continuing with in-memory rate limiting"
+ );
+ });
+
+ try {
+ await initializeDatabase(); // Initialize SQLite database
+ } catch (error) {
+ logger.error("Failed to initialize database:", error);
+ process.exit(1); // Exit if DB initialization fails
+ }
+
+ // Middleware
+ app.use(
+ helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ styleSrc: ["'self'", "'unsafe-inline'"],
+ scriptSrc: ["'self'"],
+ imgSrc: ["'self'", "data:", "https:"],
+ },
+ },
+ })
+ );
+
+ app.use(express.json({ limit: "10mb" }));
+ app.use(express.urlencoded({ extended: true, limit: "10mb" }));
+
+ // Session configuration (for development only, use Redis in production)
+ app.use(
+ session({
+ secret:
+ process.env.SESSION_SECRET || "fallback-secret-change-in-production",
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ secure: process.env.NODE_ENV === "production",
+ httpOnly: true,
+ maxAge: 24 * 60 * 60 * 1000, // 24 hours
+ },
+ })
+ );
+
+ // Initialize Passport
+ app.use(passport.initialize());
+ app.use(passport.session());
+
+ // Set view engine
+ app.set("view engine", "ejs");
+
+ // API Routes
+ app.use("/api/auth", authRoutes);
+
+ // API V1 Routes
+ app.use("/api/v1", apiV1Routes);
+
+ // User Dashboard Routes
+ app.use("/dashboard", dashboardRoutes);
+
+ // Existing routes (maintaining backward compatibility)
+ app.use("/", publicRoutes);
+
+ // Health check endpoint
+ app.get("/health", (req, res) => {
+ res.json({
+ status: "healthy",
+ timestamp: new Date().toISOString(),
+ version: "1.0.0",
+ });
+ });
+
+ // Global error handler - should be the last middleware
+ app.use(errorHandler);
+
+ // 404 handler
+ app.use((req, res) => {
+ logger.warn(
+ `404 - Endpoint not found: ${req.originalUrl} - Method: ${req.method} - IP: ${req.ip}`
+ );
+ res.status(404).json({
+ error: {
+ message: "Endpoint not found",
+ code: "NOT_FOUND",
+ },
+ });
+ });
+
+ // Start server
+ app.listen(PORT, () => {
+ logger.info(`Server running on http://localhost:${PORT}`);
+
+ // Environment checks
+ if (!process.env.JWT_SECRET) {
+ logger.warn(
+ "WARNING: JWT_SECRET not set. Authentication will not work properly."
+ );
+ }
+
+ if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
+ logger.info(
+ `Ntfy notifications enabled for topic: ${process.env.NTFY_TOPIC_URL}`
+ );
+ } else {
+ logger.info("Ntfy notifications disabled or topic not configured.");
+ }
+
+ // Start cleanup of expired sessions every hour
+ setInterval(
+ () => {
+ const jwtService = require("./src/services/jwtService");
+ jwtService.cleanupExpiredSessions();
+ },
+ 60 * 60 * 1000
+ );
+ });
+
+ // Graceful shutdown
+ process.on("SIGINT", async () => {
+ logger.info("Received SIGINT, shutting down gracefully...");
+ await closeRedis();
+ process.exit(0);
+ });
+
+ process.on("SIGTERM", async () => {
+ logger.info("Received SIGTERM, shutting down gracefully...");
+ await closeRedis();
+ process.exit(0);
+ });
+}
+
+// Initialize the application
+initializeApp().catch((error) => {
+ logger.error("Failed to initialize application:", error);
+ process.exit(1);
+});
+
+
+
+const sqlite3 = require("sqlite3").verbose();
+const path = require("path");
+
+const dbPath = path.resolve(__dirname, "../../formies.sqlite");
+
+const db = new sqlite3.Database(dbPath, (err) => {
+ if (err) {
+ console.error("Error opening database", err.message);
+ } else {
+ console.log("Connected to the SQLite database.");
+ // Enable foreign key support
+ db.run("PRAGMA foreign_keys = ON;", (pragmaErr) => {
+ if (pragmaErr) {
+ console.error("Failed to enable foreign keys:", pragmaErr.message);
+ }
+ });
+ }
+});
+
+module.exports = db;
+
+
+
+const passport = require("passport");
+const LocalStrategy = require("passport-local").Strategy;
+const JwtStrategy = require("passport-jwt").Strategy;
+const ExtractJwt = require("passport-jwt").ExtractJwt;
+const bcrypt = require("bcryptjs");
+const User = require("../models/User");
+
+// Local Strategy for email/password authentication
+passport.use(
+ new LocalStrategy(
+ {
+ usernameField: "email",
+ passwordField: "password",
+ },
+ async (email, password, done) => {
+ try {
+ // Find user by email
+ const user = await User.findByEmail(email);
+
+ if (!user) {
+ return done(null, false, { message: "Invalid email or password" });
+ }
+
+ // Check if account is locked
+ if (
+ user.account_locked_until &&
+ new Date() < user.account_locked_until
+ ) {
+ return done(null, false, {
+ message:
+ "Account temporarily locked due to multiple failed login attempts",
+ });
+ }
+
+ // Check if account is active
+ if (!user.is_active) {
+ return done(null, false, { message: "Account has been deactivated" });
+ }
+
+ // Check if email is verified (for non-admin users)
+ if (!user.is_verified && user.role !== "super_admin") {
+ return done(null, false, {
+ message: "Please verify your email address before logging in",
+ });
+ }
+
+ // Verify password
+ const isValidPassword = await bcrypt.compare(
+ password,
+ user.password_hash
+ );
+
+ if (!isValidPassword) {
+ // Increment failed login attempts
+ await User.incrementFailedLoginAttempts(user.id);
+ return done(null, false, { message: "Invalid email or password" });
+ }
+
+ // Reset failed login attempts and update last login
+ await User.resetFailedLoginAttempts(user.id);
+ await User.updateLastLogin(user.id);
+
+ // Remove sensitive information before returning user
+ const userSafe = {
+ id: user.id,
+ uuid: user.uuid,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ role: user.role,
+ is_verified: user.is_verified,
+ is_active: user.is_active,
+ created_at: user.created_at,
+ last_login: user.last_login,
+ must_change_password: user.must_change_password,
+ };
+
+ return done(null, userSafe);
+ } catch (error) {
+ return done(error);
+ }
+ }
+ )
+);
+
+// JWT Strategy for token-based authentication
+passport.use(
+ new JwtStrategy(
+ {
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ secretOrKey: process.env.JWT_SECRET || "trhrtjtzmkjt56fgdfg3tcvv",
+ issuer: process.env.JWT_ISSUER || "formies",
+ audience: process.env.JWT_AUDIENCE || "formies-users",
+ },
+ async (payload, done) => {
+ try {
+ // Check if token is blacklisted
+ const isBlacklisted = await User.isTokenBlacklisted(payload.jti);
+ if (isBlacklisted) {
+ return done(null, false, { message: "Token has been revoked" });
+ }
+
+ // Find user by ID
+ const user = await User.findById(payload.sub);
+
+ if (!user) {
+ return done(null, false, { message: "User not found" });
+ }
+
+ // Check if account is active
+ if (!user.is_active) {
+ return done(null, false, { message: "Account has been deactivated" });
+ }
+
+ // Remove sensitive information before returning user
+ const userSafe = {
+ id: user.id,
+ uuid: user.uuid,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ role: user.role,
+ is_verified: user.is_verified,
+ is_active: user.is_active,
+ created_at: user.created_at,
+ last_login: user.last_login,
+ must_change_password: user.must_change_password,
+ };
+
+ return done(null, userSafe);
+ } catch (error) {
+ return done(error);
+ }
+ }
+ )
+);
+
+// Serialize user for session
+passport.serializeUser((user, done) => {
+ done(null, user.id);
+});
+
+// Deserialize user from session
+passport.deserializeUser(async (id, done) => {
+ try {
+ const user = await User.findById(id);
+ if (user) {
+ const userSafe = {
+ id: user.id,
+ uuid: user.uuid,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ role: user.role,
+ is_verified: user.is_verified,
+ is_active: user.is_active,
+ created_at: user.created_at,
+ last_login: user.last_login,
+ must_change_password: user.must_change_password,
+ };
+ done(null, userSafe);
+ } else {
+ done(null, false);
+ }
+ } catch (error) {
+ done(error);
+ }
+});
+
+module.exports = passport;
+
+
+
+const { createClient } = require("redis");
+
+let redisClient = null;
+let connectionAttempted = false;
+let isRedisAvailable = false;
+
+const connectRedis = async () => {
+ if (redisClient) {
+ return redisClient;
+ }
+
+ // If we already tried and failed, don't keep trying
+ if (connectionAttempted && !isRedisAvailable) {
+ return null;
+ }
+
+ connectionAttempted = true;
+
+ const redisHost = process.env.REDIS_HOST || "localhost";
+ const redisPort = process.env.REDIS_PORT || 6379;
+ const redisPassword = process.env.REDIS_PASSWORD || "";
+
+ const config = {
+ socket: {
+ host: redisHost,
+ port: redisPort,
+ connectTimeout: 1000, // Reduced timeout to 1 second
+ lazyConnect: true,
+ },
+ // Disable automatic reconnection to prevent spam
+ retry_unfulfilled_commands: false,
+ enable_offline_queue: false,
+ };
+
+ // Add password if provided
+ if (redisPassword) {
+ config.password = redisPassword;
+ }
+
+ redisClient = createClient(config);
+
+ // Only log the first error, not subsequent ones
+ let errorLogged = false;
+ redisClient.on("error", (err) => {
+ if (!errorLogged) {
+ console.warn("Redis connection failed:", err.code || err.message);
+ console.warn("Falling back to in-memory rate limiting");
+ errorLogged = true;
+ }
+ isRedisAvailable = false;
+ });
+
+ redisClient.on("connect", () => {
+ console.log("Connected to Redis");
+ isRedisAvailable = true;
+ });
+
+ redisClient.on("disconnect", () => {
+ if (isRedisAvailable) {
+ console.log("Disconnected from Redis");
+ }
+ isRedisAvailable = false;
+ });
+
+ try {
+ await redisClient.connect();
+ console.log("Redis client connected successfully");
+ isRedisAvailable = true;
+ } catch (error) {
+ console.warn("Failed to connect to Redis:", error.code || error.message);
+ console.warn("Continuing with in-memory rate limiting");
+ isRedisAvailable = false;
+ redisClient = null;
+ return null;
+ }
+
+ return redisClient;
+};
+
+const getRedisClient = () => {
+ if (!redisClient || !isRedisAvailable) {
+ throw new Error("Redis client not available");
+ }
+ return redisClient;
+};
+
+const closeRedis = async () => {
+ if (redisClient && isRedisAvailable) {
+ try {
+ await redisClient.quit();
+ console.log("Redis connection closed");
+ } catch (error) {
+ // Ignore errors during shutdown
+ }
+ }
+ redisClient = null;
+ isRedisAvailable = false;
+ connectionAttempted = false;
+};
+
+const isRedisConnected = () => {
+ return isRedisAvailable && redisClient && redisClient.isOpen;
+};
+
+module.exports = {
+ connectRedis,
+ getRedisClient,
+ closeRedis,
+ isRedisConnected,
+};
+
+
+
+const pool = require("../config/database");
+const { compareApiKeySecret } = require("../utils/apiKeyHelper");
+
+async function apiAuthMiddleware(req, res, next) {
+ const authHeader = req.headers.authorization;
+
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
+ return res
+ .status(401)
+ .json({
+ error:
+ "Unauthorized: Missing or malformed API key. Expected Bearer token.",
+ });
+ }
+
+ const fullApiKey = authHeader.substring(7); // Remove "Bearer "
+ const parts = fullApiKey.split("_");
+
+ // Expects key format: prefix_identifierRandomPart_secretPart
+ // So, identifier is parts[0] + '_' + parts[1]
+ // And secret is parts[2]
+ if (parts.length < 3) {
+ // Basic check for fsk_random_secret format
+ return res
+ .status(401)
+ .json({ error: "Unauthorized: Invalid API key format." });
+ }
+
+ // Reconstruct identifier: e.g., parts[0] = 'fsk', parts[1] = 'randompart' -> 'fsk_randompart'
+ const apiKeyIdentifier = `${parts[0]}_${parts[1]}`;
+ const providedSecret = parts.slice(2).join("_"); // secret part could contain underscores if generated differently, though unlikely with current helper
+
+ if (!apiKeyIdentifier || !providedSecret) {
+ return res
+ .status(401)
+ .json({ error: "Unauthorized: Invalid API key structure." });
+ }
+
+ try {
+ const [apiKeyRecords] = await pool.query(
+ "SELECT ak.id, ak.user_id, ak.hashed_api_key_secret, ak.expires_at, u.is_active as user_is_active, u.role as user_role FROM api_keys ak JOIN users u ON ak.user_id = u.id WHERE ak.api_key_identifier = ?",
+ [apiKeyIdentifier]
+ );
+
+ if (apiKeyRecords.length === 0) {
+ return res.status(401).json({ error: "Unauthorized: Invalid API key." });
+ }
+
+ const apiKeyRecord = apiKeyRecords[0];
+
+ if (!apiKeyRecord.user_is_active) {
+ return res
+ .status(403)
+ .json({ error: "Forbidden: User account is inactive." });
+ }
+
+ // Check for expiration (if implemented and expires_at is not null)
+ if (
+ apiKeyRecord.expires_at &&
+ new Date(apiKeyRecord.expires_at) < new Date()
+ ) {
+ return res.status(403).json({ error: "Forbidden: API key has expired." });
+ }
+
+ const isValid = await compareApiKeySecret(
+ providedSecret,
+ apiKeyRecord.hashed_api_key_secret
+ );
+
+ if (!isValid) {
+ return res.status(401).json({ error: "Unauthorized: Invalid API key." });
+ }
+
+ // Attach user information and API key ID to request for use in controllers/routes
+ req.user = {
+ id: apiKeyRecord.user_id,
+ role: apiKeyRecord.user_role, // Add other relevant user fields if needed
+ // Potentially add more fields from the user table if fetched in the JOIN
+ };
+ req.apiKeyId = apiKeyRecord.id;
+
+ // Update last_used_at (fire and forget, no need to await or block)
+ pool
+ .query(
+ "UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
+ [apiKeyRecord.id]
+ )
+ .catch((err) =>
+ console.error("Failed to update API key last_used_at:", err)
+ );
+
+ next();
+ } catch (error) {
+ console.error("API Authentication error:", error);
+ return res
+ .status(500)
+ .json({ error: "Internal Server Error during API authentication." });
+ }
+}
+
+module.exports = apiAuthMiddleware;
+
+
+
+const passport = require("../config/passport");
+const jwtService = require("../services/jwtService");
+const rateLimit = require("express-rate-limit");
+
+// JWT Authentication middleware
+const authenticateJWT = (req, res, next) => {
+ passport.authenticate("jwt", { session: false }, (err, user, info) => {
+ if (err) {
+ return res.status(500).json({
+ success: false,
+ message: "Authentication error",
+ error: err.message,
+ });
+ }
+
+ if (!user) {
+ return res.status(401).json({
+ success: false,
+ message: info?.message || "Authentication required",
+ });
+ }
+
+ req.user = user;
+ next();
+ })(req, res, next);
+};
+
+// Optional JWT Authentication (doesn't fail if no token)
+const authenticateJWTOptional = (req, res, next) => {
+ const authHeader = req.headers.authorization;
+ const token = jwtService.extractTokenFromHeader(authHeader);
+
+ if (!token) {
+ return next(); // No token provided, continue without user
+ }
+
+ passport.authenticate("jwt", { session: false }, (err, user, info) => {
+ if (!err && user) {
+ req.user = user;
+ }
+ // Continue regardless of authentication result
+ next();
+ })(req, res, next);
+};
+
+// Role-based authorization middleware
+const requireRole = (roles) => {
+ if (typeof roles === "string") {
+ roles = [roles];
+ }
+
+ return (req, res, next) => {
+ if (!req.user) {
+ return res.status(401).json({
+ success: false,
+ message: "Authentication required",
+ });
+ }
+
+ if (!roles.includes(req.user.role)) {
+ return res.status(403).json({
+ success: false,
+ message: "Insufficient permissions",
+ });
+ }
+
+ next();
+ };
+};
+
+// Check if user is admin or super admin
+const requireAdmin = requireRole(["admin", "super_admin"]);
+
+// Check if user is super admin
+const requireSuperAdmin = requireRole(["super_admin"]);
+
+// Check if user owns the resource or is admin
+const requireOwnershipOrAdmin = (getResourceUserId) => {
+ return async (req, res, next) => {
+ try {
+ if (!req.user) {
+ return res.status(401).json({
+ success: false,
+ message: "Authentication required",
+ });
+ }
+
+ // Super admins can access everything
+ if (req.user.role === "super_admin") {
+ return next();
+ }
+
+ // Get the user ID that owns the resource
+ const resourceUserId = await getResourceUserId(req);
+
+ // Check if user owns the resource or is admin
+ if (
+ req.user.id === resourceUserId ||
+ ["admin", "super_admin"].includes(req.user.role)
+ ) {
+ return next();
+ }
+
+ return res.status(403).json({
+ success: false,
+ message: "Access denied. You can only access your own resources.",
+ });
+ } catch (error) {
+ return res.status(500).json({
+ success: false,
+ message: "Authorization error",
+ error: error.message,
+ });
+ }
+ };
+};
+
+// Check if account is verified
+const requireVerifiedAccount = (req, res, next) => {
+ if (!req.user) {
+ return res.status(401).json({
+ success: false,
+ message: "Authentication required",
+ });
+ }
+
+ // Super admins don't need verification
+ if (req.user.role === "super_admin") {
+ return next();
+ }
+
+ if (!req.user.is_verified) {
+ return res.status(403).json({
+ success: false,
+ message: "Please verify your email address to access this resource",
+ requiresVerification: true,
+ });
+ }
+
+ next();
+};
+
+// Rate limiting middleware for authentication endpoints
+const authRateLimit = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 5, // Limit each IP to 5 requests per windowMs
+ message: {
+ success: false,
+ message: "Too many authentication attempts, please try again later",
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ // Use IP and email if available for more granular rate limiting
+ return req.ip + (req.body?.email || "");
+ },
+});
+
+// Rate limiting for password reset
+const passwordResetRateLimit = rateLimit({
+ windowMs: 60 * 60 * 1000, // 1 hour
+ max: 3, // Limit each IP to 3 password reset attempts per hour
+ message: {
+ success: false,
+ message: "Too many password reset attempts, please try again later",
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ return req.ip + (req.body?.email || "");
+ },
+});
+
+// Rate limiting for registration
+const registrationRateLimit = rateLimit({
+ windowMs: 60 * 60 * 1000, // 1 hour
+ max: 3, // Limit each IP to 3 registrations per hour
+ message: {
+ success: false,
+ message: "Too many registration attempts, please try again later",
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ return req.ip;
+ },
+});
+
+// Middleware to check if user is active
+const requireActiveAccount = (req, res, next) => {
+ if (!req.user) {
+ return res.status(401).json({
+ success: false,
+ message: "Authentication required",
+ });
+ }
+
+ if (!req.user.is_active) {
+ return res.status(403).json({
+ success: false,
+ message: "Your account has been deactivated. Please contact support.",
+ });
+ }
+
+ next();
+};
+
+// Combine common authentication checks
+const requireAuth = [authenticateJWT, requireActiveAccount];
+const requireVerifiedAuth = [
+ authenticateJWT,
+ requireActiveAccount,
+ requireVerifiedAccount,
+];
+
+// Legacy basic auth middleware (for backward compatibility during transition)
+const basicAuth = require("basic-auth");
+
+const httpAuthMiddleware = (req, res, next) => {
+ if (!process.env.ADMIN_USER || !process.env.ADMIN_PASSWORD) {
+ console.warn(
+ "ADMIN_USER or ADMIN_PASSWORD not set. Admin routes are unprotected."
+ );
+ return next();
+ }
+
+ const user = basicAuth(req);
+ if (
+ !user ||
+ user.name !== process.env.ADMIN_USER ||
+ user.pass !== process.env.ADMIN_PASSWORD
+ ) {
+ res.set("WWW-Authenticate", 'Basic realm="Admin Area"');
+ return res.status(401).send("Authentication required.");
+ }
+ return next();
+};
+
+module.exports = {
+ // JWT Authentication
+ authenticateJWT,
+ authenticateJWTOptional,
+
+ // Authorization
+ requireRole,
+ requireAdmin,
+ requireSuperAdmin,
+ requireOwnershipOrAdmin,
+ requireVerifiedAccount,
+ requireActiveAccount,
+
+ // Combined middleware
+ requireAuth,
+ requireVerifiedAuth,
+
+ // Rate limiting
+ authRateLimit,
+ passwordResetRateLimit,
+ registrationRateLimit,
+
+ // Legacy (for backward compatibility)
+ httpAuthMiddleware,
+};
+
+
+
+const domainChecker = async (req, res, next) => {
+ const formUuid = req.params.formUuid;
+ const referer = req.headers.referer || req.headers.origin;
+
+ try {
+ const [rows] = await req.db.query(
+ "SELECT allowed_domains FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+
+ if (rows.length === 0) {
+ return res.status(404).json({ error: "Form not found" });
+ }
+
+ const form = rows[0];
+
+ // If no domains are specified or it's empty/null, allow all
+ if (!form.allowed_domains || form.allowed_domains.trim() === "") {
+ return next();
+ }
+
+ const allowedDomains = form.allowed_domains.split(",").map((d) => d.trim());
+
+ if (!referer) {
+ return res.status(403).json({ error: "Referer header is required" });
+ }
+
+ const refererUrl = new URL(referer);
+ const isAllowed = allowedDomains.some(
+ (domain) =>
+ refererUrl.hostname === domain ||
+ refererUrl.hostname.endsWith("." + domain)
+ );
+
+ if (!isAllowed) {
+ return res
+ .status(403)
+ .json({ error: "Submission not allowed from this domain" });
+ }
+
+ next();
+ } catch (error) {
+ console.error("Domain check error:", error);
+ res.status(500).json({ error: "Internal server error" });
+ }
+};
+
+module.exports = domainChecker;
+
+
+
+const rateLimit = require("express-rate-limit");
+const RedisStore = require("rate-limit-redis").default;
+const { getRedisClient, isRedisConnected } = require("../config/redis");
+
+// Track if we've already logged the fallback warning
+let fallbackWarningLogged = false;
+
+// Simple in-memory store as fallback when Redis is not available
+class MemoryStore {
+ constructor() {
+ this.hits = new Map();
+ this.resetTime = new Map();
+
+ // Clean up old entries periodically to prevent memory leaks
+ this.cleanupInterval = setInterval(
+ () => {
+ const now = Date.now();
+ for (const [key, resetTime] of this.resetTime.entries()) {
+ if (now > resetTime) {
+ this.hits.delete(key);
+ this.resetTime.delete(key);
+ }
+ }
+ },
+ 5 * 60 * 1000
+ ); // Clean up every 5 minutes
+ }
+
+ async increment(key, windowMs) {
+ const now = Date.now();
+ const resetTime = this.resetTime.get(key);
+
+ if (!resetTime || now > resetTime) {
+ this.hits.set(key, 1);
+ this.resetTime.set(key, now + windowMs);
+ return { totalHits: 1, timeToExpire: windowMs };
+ }
+
+ const hits = (this.hits.get(key) || 0) + 1;
+ this.hits.set(key, hits);
+ return { totalHits: hits, timeToExpire: resetTime - now };
+ }
+
+ async decrement(key) {
+ const hits = this.hits.get(key) || 0;
+ if (hits > 0) {
+ this.hits.set(key, hits - 1);
+ }
+ }
+
+ async resetKey(key) {
+ this.hits.delete(key);
+ this.resetTime.delete(key);
+ }
+}
+
+// Create store based on Redis availability
+const createStore = () => {
+ try {
+ if (isRedisConnected()) {
+ const redisClient = getRedisClient();
+ return new RedisStore({
+ sendCommand: (...args) => redisClient.sendCommand(args),
+ });
+ } else {
+ throw new Error("Redis not connected");
+ }
+ } catch (error) {
+ // Only log the warning once to avoid spam
+ if (!fallbackWarningLogged) {
+ console.warn("Rate limiting: Using in-memory store (Redis unavailable)");
+ fallbackWarningLogged = true;
+ }
+ return new MemoryStore();
+ }
+};
+
+// Create rate limiter for form submissions
+const createSubmissionRateLimiter = () => {
+ return rateLimit({
+ store: createStore(),
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 10, // Limit each IP to 10 requests per windowMs for any form
+ message: {
+ error:
+ "Too many form submissions from this IP address. Please try again later.",
+ },
+ standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
+ legacyHeaders: false, // Disable the `X-RateLimit-*` headers
+ keyGenerator: (req) => {
+ // Generate unique key per IP
+ return `submit_ip:${req.ip}`;
+ },
+ skip: (req) => {
+ // Skip rate limiting for specific conditions if needed
+ return false;
+ },
+ });
+};
+
+// Create more restrictive rate limiter for specific form+IP combinations
+const createFormSpecificRateLimiter = () => {
+ return rateLimit({
+ store: createStore(),
+ windowMs: 5 * 60 * 1000, // 5 minutes
+ max: 3, // Limit each IP to 3 requests per 5 minutes per specific form
+ message: {
+ error:
+ "Too many submissions for this form from your IP address. Please try again later.",
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ // Generate unique key per form+IP combination
+ const formUuid = req.params.formUuid;
+ return `submit_form:${formUuid}:${req.ip}`;
+ },
+ skip: (req) => {
+ // Skip rate limiting for specific conditions if needed
+ return false;
+ },
+ });
+};
+
+// Create a more aggressive rate limiter for potential abuse
+const createStrictRateLimiter = () => {
+ return rateLimit({
+ store: createStore(),
+ windowMs: 60 * 60 * 1000, // 1 hour
+ max: 50, // Limit each IP to 50 requests per hour across all forms
+ message: {
+ error: "Too many requests from this IP address. Please try again later.",
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ return `strict_ip:${req.ip}`;
+ },
+ });
+};
+
+module.exports = {
+ createSubmissionRateLimiter,
+ createFormSpecificRateLimiter,
+ createStrictRateLimiter,
+};
+
+
+
+const { body, param, query, validationResult } = require("express-validator");
+
+// Validation error handler
+const handleValidationErrors = (req, res, next) => {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({
+ success: false,
+ message: "Validation failed",
+ errors: errors.array().map((error) => ({
+ field: error.path,
+ message: error.msg,
+ value: error.value,
+ })),
+ });
+ }
+ next();
+};
+
+// Password validation
+const passwordValidation = body("password")
+ .isLength({ min: 8 })
+ .withMessage("Password must be at least 8 characters long")
+ .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
+ .withMessage(
+ "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"
+ );
+
+// Email validation
+const emailValidation = body("email")
+ .isEmail()
+ .withMessage("Please provide a valid email address")
+ .normalizeEmail()
+ .isLength({ max: 255 })
+ .withMessage("Email address is too long");
+
+// Registration validation
+const validateRegistration = [
+ emailValidation,
+ passwordValidation,
+ body("first_name")
+ .optional()
+ .trim()
+ .isLength({ min: 1, max: 100 })
+ .withMessage("First name must be between 1 and 100 characters"),
+ body("last_name")
+ .optional()
+ .trim()
+ .isLength({ min: 1, max: 100 })
+ .withMessage("Last name must be between 1 and 100 characters"),
+ handleValidationErrors,
+];
+
+// Login validation
+const validateLogin = [
+ body("email")
+ .isEmail()
+ .withMessage("Please provide a valid email address")
+ .normalizeEmail(),
+ body("password").notEmpty().withMessage("Password is required"),
+ handleValidationErrors,
+];
+
+// Forgot password validation
+const validateForgotPassword = [emailValidation, handleValidationErrors];
+
+// Reset password validation
+const validateResetPassword = [
+ body("token")
+ .notEmpty()
+ .withMessage("Reset token is required")
+ .isLength({ min: 64, max: 64 })
+ .withMessage("Invalid reset token format"),
+ passwordValidation,
+ body("confirmPassword").custom((value, { req }) => {
+ if (value !== req.body.password) {
+ throw new Error("Password confirmation does not match password");
+ }
+ return true;
+ }),
+ handleValidationErrors,
+];
+
+// Profile update validation
+const validateProfileUpdate = [
+ body("first_name")
+ .optional()
+ .trim()
+ .isLength({ min: 1, max: 100 })
+ .withMessage("First name must be between 1 and 100 characters"),
+ body("last_name")
+ .optional()
+ .trim()
+ .isLength({ min: 1, max: 100 })
+ .withMessage("Last name must be between 1 and 100 characters"),
+ body("email")
+ .optional()
+ .isEmail()
+ .withMessage("Please provide a valid email address")
+ .normalizeEmail()
+ .isLength({ max: 255 })
+ .withMessage("Email address is too long"),
+ handleValidationErrors,
+];
+
+module.exports = {
+ validateRegistration,
+ validateLogin,
+ validateForgotPassword,
+ validateResetPassword,
+ validateProfileUpdate,
+ handleValidationErrors,
+ passwordValidation,
+ emailValidation,
+};
+
+
+
+const bcrypt = require("bcryptjs");
+const crypto = require("crypto");
+const { v4: uuidv4 } = require("uuid");
+const db = require("../config/database"); // db is now an instance of sqlite3.Database
+
+class User {
+ // Helper to run queries with promises
+ static _run(query, params = []) {
+ return new Promise((resolve, reject) => {
+ db.run(query, params, function (err) {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(this); // { lastID, changes }
+ }
+ });
+ });
+ }
+
+ static _get(query, params = []) {
+ return new Promise((resolve, reject) => {
+ db.get(query, params, (err, row) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(row);
+ }
+ });
+ });
+ }
+
+ static _all(query, params = []) {
+ return new Promise((resolve, reject) => {
+ db.all(query, params, (err, rows) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(rows);
+ }
+ });
+ });
+ }
+
+ // Create a new user
+ static async create(userData) {
+ const {
+ email,
+ password,
+ first_name,
+ last_name,
+ role = "user",
+ is_verified = 0, // SQLite uses 0 for false
+ } = userData;
+
+ const saltRounds = 12;
+ const password_hash = await bcrypt.hash(password, saltRounds);
+ const verification_token = crypto.randomBytes(32).toString("hex");
+ const uuid = uuidv4();
+
+ const query = `
+ INSERT INTO users (uuid, email, password_hash, first_name, last_name, role, is_verified, verification_token, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
+ `;
+ const values = [
+ uuid,
+ email,
+ password_hash,
+ first_name,
+ last_name,
+ role,
+ is_verified,
+ verification_token,
+ ];
+
+ try {
+ const result = await User._run(query, values);
+ return {
+ id: result.lastID,
+ uuid,
+ email,
+ first_name,
+ last_name,
+ role,
+ is_verified,
+ verification_token,
+ };
+ } catch (error) {
+ if (error.message && error.message.includes("UNIQUE constraint failed")) {
+ // Check for specific constraint if possible, e.g., error.message.includes("users.email")
+ throw new Error("Email already exists");
+ }
+ throw error;
+ }
+ }
+
+ // Find user by email
+ static async findByEmail(email) {
+ const query = "SELECT * FROM users WHERE email = ? AND is_active = 1";
+ return User._get(query, [email]);
+ }
+
+ // Find user by ID
+ static async findById(id) {
+ const query = "SELECT * FROM users WHERE id = ? AND is_active = 1";
+ return User._get(query, [id]);
+ }
+
+ // Find user by UUID
+ static async findByUuid(uuid) {
+ const query = "SELECT * FROM users WHERE uuid = ? AND is_active = 1";
+ return User._get(query, [uuid]);
+ }
+
+ // Find user by verification token
+ static async findByVerificationToken(token) {
+ const query = "SELECT * FROM users WHERE verification_token = ?";
+ return User._get(query, [token]);
+ }
+
+ // Find user by password reset token
+ static async findByPasswordResetToken(token) {
+ const query = `
+ SELECT * FROM users
+ WHERE password_reset_token = ?
+ AND password_reset_expires > datetime('now')
+ AND is_active = 1
+ `;
+ return User._get(query, [token]);
+ }
+
+ // Verify email
+ static async verifyEmail(token) {
+ const query = `
+ UPDATE users
+ SET is_verified = 1, verification_token = NULL, updated_at = datetime('now')
+ WHERE verification_token = ?
+ `;
+ const result = await User._run(query, [token]);
+ return result.changes > 0;
+ }
+
+ // Update password
+ static async updatePassword(id, newPassword) {
+ const saltRounds = 12;
+ const password_hash = await bcrypt.hash(newPassword, saltRounds);
+ const query = `
+ UPDATE users
+ SET password_hash = ?, password_reset_token = NULL, password_reset_expires = NULL, updated_at = datetime('now')
+ WHERE id = ?
+ `;
+ const result = await User._run(query, [password_hash, id]);
+ return result.changes > 0;
+ }
+
+ // Update password and clear must_change_password flag
+ static async updatePasswordAndClearChangeFlag(id, newPassword) {
+ const saltRounds = 12;
+ const password_hash = await bcrypt.hash(newPassword, saltRounds);
+ const query = `
+ UPDATE users
+ SET password_hash = ?,
+ must_change_password = 0,
+ password_reset_token = NULL,
+ password_reset_expires = NULL,
+ updated_at = datetime('now')
+ WHERE id = ?
+ `;
+ const result = await User._run(query, [password_hash, id]);
+ return result.changes > 0;
+ }
+
+ // Set password reset token
+ static async setPasswordResetToken(email) {
+ const token = crypto.randomBytes(32).toString("hex");
+ // SQLite expects DATETIME strings, ISO 8601 format is good
+ const expires = new Date(Date.now() + 3600000).toISOString();
+
+ const query = `
+ UPDATE users
+ SET password_reset_token = ?, password_reset_expires = ?, updated_at = datetime('now')
+ WHERE email = ? AND is_active = 1
+ `;
+ const result = await User._run(query, [token, expires, email]);
+ if (result.changes > 0) {
+ return { token, expires };
+ }
+ return null;
+ }
+
+ // Increment failed login attempts
+ static async incrementFailedLoginAttempts(id) {
+ // Note: SQLite's CASE WHEN THEN ELSE END syntax is similar to MySQL
+ // Locking for 30 minutes
+ const query = `
+ UPDATE users
+ SET failed_login_attempts = failed_login_attempts + 1,
+ account_locked_until = CASE
+ WHEN failed_login_attempts >= 4 THEN datetime('now', '+30 minutes')
+ ELSE account_locked_until
+ END,
+ updated_at = datetime('now')
+ WHERE id = ?
+ `;
+ await User._run(query, [id]);
+ }
+
+ // Reset failed login attempts
+ static async resetFailedLoginAttempts(id) {
+ const query = `
+ UPDATE users
+ SET failed_login_attempts = 0, account_locked_until = NULL, updated_at = datetime('now')
+ WHERE id = ?
+ `;
+ await User._run(query, [id]);
+ }
+
+ // Update last login
+ static async updateLastLogin(id) {
+ const query =
+ "UPDATE users SET last_login = datetime('now'), updated_at = datetime('now') WHERE id = ?";
+ await User._run(query, [id]);
+ }
+
+ // Deactivate user account
+ static async deactivateUser(id) {
+ const query =
+ "UPDATE users SET is_active = 0, updated_at = datetime('now') WHERE id = ?";
+ const result = await User._run(query, [id]);
+ return result.changes > 0;
+ }
+
+ // Activate user account
+ static async activateUser(id) {
+ const query =
+ "UPDATE users SET is_active = 1, updated_at = datetime('now') WHERE id = ?";
+ const result = await User._run(query, [id]);
+ return result.changes > 0;
+ }
+
+ // Update user profile
+ static async updateProfile(id, updates) {
+ const allowedFields = ["first_name", "last_name", "email"];
+ const fieldsToUpdate = [];
+ const values = [];
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (allowedFields.includes(key) && value !== undefined) {
+ fieldsToUpdate.push(`\`${key}\` = ?`); // Use backticks for field names just in case
+ values.push(value);
+ }
+ }
+
+ if (fieldsToUpdate.length === 0) {
+ throw new Error("No valid fields to update");
+ }
+
+ values.push(id); // for the WHERE clause
+ const query = `UPDATE users SET ${fieldsToUpdate.join(
+ ", "
+ )}, updated_at = datetime('now') WHERE id = ?`;
+
+ try {
+ const result = await User._run(query, values);
+ return result.changes > 0;
+ } catch (error) {
+ if (error.message && error.message.includes("UNIQUE constraint failed")) {
+ // Check for specific constraint if possible, e.g., error.message.includes("users.email")
+ throw new Error("Email already exists");
+ }
+ throw error;
+ }
+ }
+
+ // Session management for JWT tokens
+ static async saveSession(
+ userId,
+ tokenJti,
+ expiresAt, // Should be an ISO string or Unix timestamp
+ userAgent = null,
+ ipAddress = null
+ ) {
+ const query = `
+ INSERT INTO user_sessions (user_id, token_jti, expires_at, user_agent, ip_address, created_at)
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
+ `;
+ // Ensure expiresAt is in a format SQLite understands (e.g., ISO string)
+ const expiresAtFormatted = new Date(expiresAt).toISOString();
+ const values = [userId, tokenJti, expiresAtFormatted, userAgent, ipAddress];
+ const result = await User._run(query, values);
+ return result.lastID;
+ }
+
+ static async isTokenBlacklisted(tokenJti) {
+ const query =
+ "SELECT 1 FROM user_sessions WHERE token_jti = ? AND expires_at > datetime('now')";
+ const row = await User._get(query, [tokenJti]);
+ return !!row; // True if a non-expired session with this JTI exists
+ }
+
+ static async revokeSession(tokenJti) {
+ // Instead of deleting, we can mark as expired or delete. Deleting is simpler.
+ const query = "DELETE FROM user_sessions WHERE token_jti = ?";
+ const result = await User._run(query, [tokenJti]);
+ return result.changes > 0;
+ }
+
+ static async revokeAllUserSessions(userId) {
+ const query = "DELETE FROM user_sessions WHERE user_id = ?";
+ const result = await User._run(query, [userId]);
+ return result.changes > 0;
+ }
+
+ static async revokeAllUserSessionsExcept(userId, exceptJti) {
+ const query =
+ "DELETE FROM user_sessions WHERE user_id = ? AND token_jti != ?";
+ const result = await User._run(query, [userId, exceptJti]);
+ return result.changes > 0;
+ }
+
+ static async getUserActiveSessions(userId) {
+ const query =
+ "SELECT id, token_jti, user_agent, ip_address, created_at, expires_at FROM user_sessions WHERE user_id = ? AND expires_at > datetime('now') ORDER BY created_at DESC";
+ return User._all(query, [userId]);
+ }
+
+ static async getSessionByJti(jti) {
+ const query = "SELECT * FROM user_sessions WHERE token_jti = ?";
+ return User._get(query, [jti]);
+ }
+
+ // Cleanup expired sessions (can be run periodically)
+ static async cleanupExpiredSessions() {
+ const query =
+ "DELETE FROM user_sessions WHERE expires_at <= datetime('now')";
+ const result = await User._run(query);
+ console.log("Cleaned up " + result.changes + " expired sessions.");
+ return result.changes;
+ }
+
+ // Get user statistics (example, adapt as needed)
+ static async getUserStats(userId) {
+ // This is a placeholder. You'll need to adjust based on actual needs and tables.
+ // For example, count forms or submissions associated with the user.
+ // const formsQuery = "SELECT COUNT(*) as form_count FROM forms WHERE user_id = ?";
+ // const submissionsQuery = "SELECT COUNT(*) as submission_count FROM submissions WHERE user_id = ?";
+
+ // const [formsResult] = await User._all(formsQuery, [userId]);
+ // const [submissionsResult] = await User._all(submissionsQuery, [userId]);
+
+ return {
+ // form_count: formsResult ? formsResult.form_count : 0,
+ // submission_count: submissionsResult ? submissionsResult.submission_count : 0,
+ // Add other relevant stats
+ };
+ }
+
+ // Find all users with pagination and filtering (example)
+ static async findAll(page = 1, limit = 20, filters = {}) {
+ let query =
+ "SELECT id, uuid, email, first_name, last_name, role, is_verified, is_active, created_at, last_login FROM users";
+ const queryParams = [];
+ const whereClauses = [];
+
+ if (filters.role) {
+ whereClauses.push("role = ?");
+ queryParams.push(filters.role);
+ }
+ if (filters.is_active !== undefined) {
+ whereClauses.push("is_active = ?");
+ queryParams.push(filters.is_active ? 1 : 0);
+ }
+ // Add more filters as needed
+
+ if (whereClauses.length > 0) {
+ query += " WHERE " + whereClauses.join(" AND ");
+ }
+
+ query += " ORDER BY created_at DESC LIMIT ? OFFSET ?";
+ queryParams.push(limit, (page - 1) * limit);
+
+ const users = await User._all(query, queryParams);
+
+ // For total count, need a separate query without limit/offset
+ let countQuery = "SELECT COUNT(*) as total FROM users";
+ if (whereClauses.length > 0) {
+ // Reuse queryParams for filters, but not for limit/offset
+ const filterParams = queryParams.slice(0, whereClauses.length);
+ countQuery += " WHERE " + whereClauses.join(" AND ");
+ const countResult = await User._get(countQuery, filterParams);
+ return { users, total: countResult.total, page, limit };
+ } else {
+ const countResult = await User._get(countQuery);
+ return { users, total: countResult.total, page, limit };
+ }
+ }
+
+ // Add other user methods as needed
+}
+
+module.exports = User;
+
+
+
+const express = require("express");
+const pool = require("../config/database");
+const apiAuthMiddleware = require("../middleware/apiAuthMiddleware");
+
+const router = express.Router();
+
+// All routes in this file will be protected by API key authentication
+router.use(apiAuthMiddleware);
+
+// GET /api/v1/forms - List forms for the authenticated user
+router.get("/forms", async (req, res) => {
+ try {
+ const [forms] = await pool.query(
+ `SELECT uuid, name, created_at, is_archived,
+ (SELECT COUNT(*) FROM submissions WHERE form_uuid = f.uuid) as submission_count
+ FROM forms f
+ WHERE f.user_id = ?
+ ORDER BY f.created_at DESC`,
+ [req.user.id] // req.user.id is attached by apiAuthMiddleware
+ );
+ res.json({ success: true, forms });
+ } catch (error) {
+ console.error("API Error fetching forms for user:", req.user.id, error);
+ res.status(500).json({ success: false, error: "Failed to fetch forms." });
+ }
+});
+
+// GET /api/v1/forms/:formUuid/submissions - List submissions for a specific form
+router.get("/forms/:formUuid/submissions", async (req, res) => {
+ const { formUuid } = req.params;
+ const page = parseInt(req.query.page) || 1;
+ const limit = parseInt(req.query.limit) || 25; // Default 25 submissions per page for API
+ const offset = (page - 1) * limit;
+
+ try {
+ // First, verify the user (from API key) owns the form
+ const [formDetails] = await pool.query(
+ "SELECT user_id, name FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+
+ if (formDetails.length === 0) {
+ return res.status(404).json({ success: false, error: "Form not found." });
+ }
+
+ if (formDetails[0].user_id !== req.user.id) {
+ return res
+ .status(403)
+ .json({
+ success: false,
+ error: "Access denied. You do not own this form.",
+ });
+ }
+
+ // Get total count of submissions for pagination
+ const [countResult] = await pool.query(
+ "SELECT COUNT(*) as total FROM submissions WHERE form_uuid = ?",
+ [formUuid]
+ );
+ const totalSubmissions = countResult[0].total;
+ const totalPages = Math.ceil(totalSubmissions / limit);
+
+ // Fetch paginated submissions
+ const [submissions] = await pool.query(
+ "SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
+ [formUuid, limit, offset]
+ );
+
+ res.json({
+ success: true,
+ formName: formDetails[0].name,
+ formUuid,
+ pagination: {
+ currentPage: page,
+ totalPages: totalPages,
+ totalSubmissions: totalSubmissions,
+ limit: limit,
+ perPage: limit, // Alias for limit
+ count: submissions.length,
+ },
+ submissions,
+ });
+ } catch (error) {
+ console.error(
+ "API Error fetching submissions for form:",
+ formUuid,
+ "user:",
+ req.user.id,
+ error
+ );
+ res
+ .status(500)
+ .json({ success: false, error: "Failed to fetch submissions." });
+ }
+});
+
+module.exports = router;
+
+
+
+const express = require("express");
+const passport = require("../config/passport");
+const User = require("../models/User");
+const jwtService = require("../services/jwtService");
+const emailService = require("../services/emailService");
+const { body } = require("express-validator");
+const {
+ validateRegistration,
+ validateLogin,
+ validateForgotPassword,
+ validateResetPassword,
+ validateProfileUpdate,
+ handleValidationErrors,
+} = require("../middleware/validation");
+const {
+ authRateLimit,
+ passwordResetRateLimit,
+ registrationRateLimit,
+ requireAuth,
+ requireVerifiedAuth,
+} = require("../middleware/authMiddleware");
+
+const router = express.Router();
+
+// Register new user
+router.post(
+ "/register",
+ registrationRateLimit,
+ validateRegistration,
+ async (req, res) => {
+ try {
+ const { email, password, first_name, last_name } = req.body;
+
+ // Check if user already exists
+ const existingUser = await User.findByEmail(email);
+ if (existingUser) {
+ return res.status(409).json({
+ success: false,
+ message: "An account with this email address already exists",
+ });
+ }
+
+ // Create new user
+ const newUser = await User.create({
+ email,
+ password,
+ first_name,
+ last_name,
+ role: "user",
+ is_verified: false,
+ });
+
+ // Send verification email
+ if (emailService.isAvailable()) {
+ await emailService.sendVerificationEmail(
+ newUser.email,
+ newUser.first_name,
+ newUser.verification_token
+ );
+ }
+
+ res.status(201).json({
+ success: true,
+ message:
+ "Account created successfully. Please check your email to verify your account.",
+ data: {
+ user: {
+ id: newUser.id,
+ uuid: newUser.uuid,
+ email: newUser.email,
+ first_name: newUser.first_name,
+ last_name: newUser.last_name,
+ is_verified: newUser.is_verified,
+ },
+ },
+ });
+ } catch (error) {
+ console.error("Registration error:", error);
+ res.status(500).json({
+ success: false,
+ message: error.message || "Registration failed",
+ });
+ }
+ }
+);
+
+// Login user
+router.post("/login", authRateLimit, validateLogin, (req, res, next) => {
+ passport.authenticate(
+ "local",
+ { session: false },
+ async (err, user, info) => {
+ try {
+ if (err) {
+ return res.status(500).json({
+ success: false,
+ message: "Authentication error",
+ error: err.message,
+ });
+ }
+
+ if (!user) {
+ return res.status(401).json({
+ success: false,
+ message: info.message || "Invalid credentials",
+ });
+ }
+
+ // Check if password change is required
+ if (user.must_change_password) {
+ // Generate a temporary token that's only valid for password change
+ // This step depends on how you want to handle the forced change flow.
+ // For now, we'll just send a specific response.
+ // A more robust solution might involve a temporary, restricted token.
+ return res.status(403).json({
+ // 403 Forbidden, but with a specific reason
+ success: false,
+ message: "Password change required.",
+ code: "MUST_CHANGE_PASSWORD",
+ data: {
+ user: {
+ // Send minimal user info
+ id: user.id,
+ uuid: user.uuid,
+ email: user.email,
+ role: user.role,
+ },
+ },
+ });
+ }
+
+ // Generate JWT tokens
+ const sessionInfo = {
+ userAgent: req.get("User-Agent"),
+ ipAddress: req.ip,
+ };
+
+ const tokens = jwtService.generateTokenPair(user, sessionInfo);
+
+ res.json({
+ success: true,
+ message: "Login successful",
+ data: {
+ user: {
+ id: user.id,
+ uuid: user.uuid,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ role: user.role,
+ is_verified: user.is_verified,
+ last_login: user.last_login,
+ },
+ ...tokens,
+ },
+ });
+ } catch (error) {
+ console.error("Login error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Login failed",
+ });
+ }
+ }
+ )(req, res, next);
+});
+
+// Refresh access token
+router.post("/refresh", async (req, res) => {
+ try {
+ const { refreshToken } = req.body;
+
+ if (!refreshToken) {
+ return res.status(400).json({
+ success: false,
+ message: "Refresh token is required",
+ });
+ }
+
+ const sessionInfo = {
+ userAgent: req.get("User-Agent"),
+ ipAddress: req.ip,
+ };
+
+ const result = await jwtService.refreshAccessToken(
+ refreshToken,
+ sessionInfo
+ );
+
+ res.json({
+ success: true,
+ message: "Token refreshed successfully",
+ data: result,
+ });
+ } catch (error) {
+ console.error("Token refresh error:", error);
+ res.status(401).json({
+ success: false,
+ message: error.message || "Token refresh failed",
+ });
+ }
+});
+
+// Logout user
+router.post("/logout", requireAuth, async (req, res) => {
+ try {
+ const authHeader = req.headers.authorization;
+ const token = jwtService.extractTokenFromHeader(authHeader);
+
+ if (token) {
+ await jwtService.revokeToken(token);
+ }
+
+ res.json({
+ success: true,
+ message: "Logged out successfully",
+ });
+ } catch (error) {
+ console.error("Logout error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Logout failed",
+ });
+ }
+});
+
+// Logout from all devices
+router.post("/logout-all", requireAuth, async (req, res) => {
+ try {
+ const revokedCount = await jwtService.revokeAllUserTokens(req.user.id);
+
+ res.json({
+ success: true,
+ message: `Logged out from ${revokedCount} devices successfully`,
+ });
+ } catch (error) {
+ console.error("Logout all error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Logout from all devices failed",
+ });
+ }
+});
+
+// Verify email
+router.get("/verify-email", async (req, res) => {
+ try {
+ const { token } = req.query;
+
+ if (!token) {
+ return res.status(400).json({
+ success: false,
+ message: "Verification token is required",
+ });
+ }
+
+ const user = await User.findByVerificationToken(token);
+ if (!user) {
+ return res.status(400).json({
+ success: false,
+ message: "Invalid or expired verification token",
+ });
+ }
+
+ if (user.is_verified) {
+ return res.status(400).json({
+ success: false,
+ message: "Email is already verified",
+ });
+ }
+
+ const verified = await User.verifyEmail(token);
+ if (!verified) {
+ return res.status(400).json({
+ success: false,
+ message: "Email verification failed",
+ });
+ }
+
+ // Send welcome email
+ if (emailService.isAvailable()) {
+ await emailService.sendWelcomeEmail(user.email, user.first_name);
+ }
+
+ res.json({
+ success: true,
+ message: "Email verified successfully! You can now access all features.",
+ });
+ } catch (error) {
+ console.error("Email verification error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Email verification failed",
+ });
+ }
+});
+
+// Resend verification email
+router.post("/resend-verification", authRateLimit, async (req, res) => {
+ try {
+ const { email } = req.body;
+
+ if (!email) {
+ return res.status(400).json({
+ success: false,
+ message: "Email is required",
+ });
+ }
+
+ const user = await User.findByEmail(email);
+ if (!user) {
+ // Don't reveal if email exists or not
+ return res.json({
+ success: true,
+ message:
+ "If an account with this email exists and is not verified, a verification email has been sent.",
+ });
+ }
+
+ if (user.is_verified) {
+ return res.status(400).json({
+ success: false,
+ message: "Email is already verified",
+ });
+ }
+
+ // Send verification email
+ if (emailService.isAvailable() && user.verification_token) {
+ await emailService.sendVerificationEmail(
+ user.email,
+ user.first_name,
+ user.verification_token
+ );
+ }
+
+ res.json({
+ success: true,
+ message:
+ "If an account with this email exists and is not verified, a verification email has been sent.",
+ });
+ } catch (error) {
+ console.error("Resend verification error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to resend verification email",
+ });
+ }
+});
+
+// Forgot password - Request password reset
+router.post(
+ "/forgot-password",
+ passwordResetRateLimit,
+ validateForgotPassword,
+ async (req, res) => {
+ try {
+ const { email } = req.body;
+
+ // Don't reveal if email exists or not for security
+ const user = await User.findByEmail(email);
+
+ if (user) {
+ // Generate password reset token
+ const resetData = await User.setPasswordResetToken(email);
+
+ if (resetData && emailService.isAvailable()) {
+ await emailService.sendPasswordResetEmail(
+ user.email,
+ user.first_name,
+ resetData.token
+ );
+ }
+ }
+
+ // Always return success to prevent email enumeration
+ res.json({
+ success: true,
+ message:
+ "If an account with this email exists, a password reset email has been sent.",
+ });
+ } catch (error) {
+ console.error("Forgot password error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to process password reset request",
+ });
+ }
+ }
+);
+
+// Reset password - Change password using reset token
+router.post(
+ "/reset-password",
+ passwordResetRateLimit,
+ validateResetPassword,
+ async (req, res) => {
+ try {
+ const { token, password } = req.body;
+
+ // Find user by reset token
+ const user = await User.findByPasswordResetToken(token);
+ if (!user) {
+ return res.status(400).json({
+ success: false,
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ // Update password
+ const updated = await User.updatePassword(user.id, password);
+ if (!updated) {
+ return res.status(500).json({
+ success: false,
+ message: "Failed to update password",
+ });
+ }
+
+ // Send password changed notification
+ if (emailService.isAvailable()) {
+ await emailService.sendPasswordChangedEmail(
+ user.email,
+ user.first_name
+ );
+ }
+
+ // Revoke all existing sessions for security
+ await jwtService.revokeAllUserTokens(user.id);
+
+ res.json({
+ success: true,
+ message:
+ "Password has been reset successfully. Please log in with your new password.",
+ });
+ } catch (error) {
+ console.error("Reset password error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to reset password",
+ });
+ }
+ }
+);
+
+// Get current user profile
+router.get("/profile", requireAuth, async (req, res) => {
+ try {
+ const stats = await User.getUserStats(req.user.id);
+
+ res.json({
+ success: true,
+ data: {
+ user: {
+ ...req.user,
+ stats,
+ },
+ },
+ });
+ } catch (error) {
+ console.error("Profile fetch error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to fetch profile",
+ });
+ }
+});
+
+// Update user profile
+router.put("/profile", requireAuth, validateProfileUpdate, async (req, res) => {
+ try {
+ const { first_name, last_name, email } = req.body;
+ const updates = {};
+
+ if (first_name !== undefined) updates.first_name = first_name;
+ if (last_name !== undefined) updates.last_name = last_name;
+ if (email !== undefined && email !== req.user.email) {
+ updates.email = email;
+ // If email is being changed, user needs to verify the new email
+ // For now, we'll just update it directly
+ }
+
+ if (Object.keys(updates).length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: "No valid fields to update",
+ });
+ }
+
+ const updated = await User.updateProfile(req.user.id, updates);
+ if (!updated) {
+ return res.status(400).json({
+ success: false,
+ message: "Profile update failed",
+ });
+ }
+
+ // Get updated user data
+ const updatedUser = await User.findById(req.user.id);
+
+ res.json({
+ success: true,
+ message: "Profile updated successfully",
+ data: {
+ user: {
+ id: updatedUser.id,
+ uuid: updatedUser.uuid,
+ email: updatedUser.email,
+ first_name: updatedUser.first_name,
+ last_name: updatedUser.last_name,
+ role: updatedUser.role,
+ is_verified: updatedUser.is_verified,
+ is_active: updatedUser.is_active,
+ },
+ },
+ });
+ } catch (error) {
+ console.error("Profile update error:", error);
+ res.status(500).json({
+ success: false,
+ message: error.message || "Profile update failed",
+ });
+ }
+});
+
+// Get user's active sessions
+router.get("/sessions", requireAuth, async (req, res) => {
+ try {
+ const sessions = await User.getUserActiveSessions(req.user.id);
+
+ res.json({
+ success: true,
+ data: {
+ sessions,
+ },
+ });
+ } catch (error) {
+ console.error("Get sessions error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to fetch sessions",
+ });
+ }
+});
+
+// Revoke a specific session
+router.delete("/sessions/:jti", requireAuth, async (req, res) => {
+ try {
+ const { jti } = req.params;
+
+ // Verify the session belongs to the user
+ const session = await User.getSessionByJti(jti);
+ if (!session || session.user_id !== req.user.id) {
+ return res.status(404).json({
+ success: false,
+ message: "Session not found",
+ });
+ }
+
+ const revoked = await User.revokeSession(jti);
+ if (!revoked) {
+ return res.status(500).json({
+ success: false,
+ message: "Failed to revoke session",
+ });
+ }
+
+ res.json({
+ success: true,
+ message: "Session revoked successfully",
+ });
+ } catch (error) {
+ console.error("Revoke session error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to revoke session",
+ });
+ }
+});
+
+// Get current session information
+router.get("/current-session", requireAuth, async (req, res) => {
+ try {
+ const authHeader = req.headers.authorization;
+ const token = jwtService.extractTokenFromHeader(authHeader);
+
+ if (!token) {
+ return res.status(401).json({
+ success: false,
+ message: "No token provided",
+ });
+ }
+
+ const session = await jwtService.getCurrentSession(token);
+
+ res.json({
+ success: true,
+ data: {
+ session,
+ },
+ });
+ } catch (error) {
+ console.error("Get current session error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to get current session information",
+ });
+ }
+});
+
+// Change password for logged-in users
+router.put(
+ "/change-password",
+ requireAuth,
+ [
+ body("currentPassword")
+ .notEmpty()
+ .withMessage("Current password is required"),
+ body("newPassword")
+ .isLength({ min: 8 })
+ .withMessage("New password must be at least 8 characters long")
+ .matches(
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/
+ )
+ .withMessage(
+ "New password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"
+ ),
+ body("confirmNewPassword").custom((value, { req }) => {
+ if (value !== req.body.newPassword) {
+ throw new Error("Password confirmation does not match new password");
+ }
+ return true;
+ }),
+ handleValidationErrors,
+ ],
+ async (req, res) => {
+ try {
+ const { currentPassword, newPassword } = req.body;
+ const bcrypt = require("bcryptjs");
+
+ // Get user with password hash
+ const user = await User.findById(req.user.id);
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ message: "User not found",
+ });
+ }
+
+ // Verify current password
+ const isCurrentPasswordValid = await bcrypt.compare(
+ currentPassword,
+ user.password_hash
+ );
+ if (!isCurrentPasswordValid) {
+ return res.status(400).json({
+ success: false,
+ message: "Current password is incorrect",
+ });
+ }
+
+ // Update password
+ const updated = await User.updatePassword(user.id, newPassword);
+ if (!updated) {
+ return res.status(500).json({
+ success: false,
+ message: "Failed to update password",
+ });
+ }
+
+ // Send password changed notification
+ if (emailService.isAvailable()) {
+ await emailService.sendPasswordChangedEmail(
+ user.email,
+ user.first_name
+ );
+ }
+
+ // Revoke all other sessions (keep current session)
+ const authHeader = req.headers.authorization;
+ const currentToken = jwtService.extractTokenFromHeader(authHeader);
+ const decoded = jwtService.verifyToken(currentToken);
+
+ // Revoke all sessions except current one
+ await jwtService.revokeAllUserTokensExcept(user.id, decoded.jti);
+
+ res.json({
+ success: true,
+ message: "Password changed successfully",
+ });
+ } catch (error) {
+ console.error("Change password error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to change password",
+ });
+ }
+ }
+);
+
+// Force password change if must_change_password is true
+router.post(
+ "/force-change-password",
+ requireAuth, // Ensures user is logged in (even if with must_change_password = true)
+ [
+ body("newPassword")
+ .isLength({ min: 8 })
+ .withMessage("Password must be at least 8 characters long"),
+ ],
+ handleValidationErrors,
+ async (req, res) => {
+ try {
+ const { newPassword } = req.body;
+ const userId = req.user.id;
+
+ // Double check if user still needs to change password
+ // (req.user might be from a valid token but DB state could have changed)
+ const currentUser = await User.findById(userId);
+ if (!currentUser || !currentUser.must_change_password) {
+ return res.status(400).json({
+ success: false,
+ message: "Password change not required or user not found.",
+ });
+ }
+
+ // Update password and clear the flag
+ const updated = await User.updatePasswordAndClearChangeFlag(
+ userId,
+ newPassword
+ );
+
+ if (!updated) {
+ return res.status(500).json({
+ success: false,
+ message: "Failed to update password.",
+ });
+ }
+
+ // Log out all other sessions for this user for security
+ const authHeader = req.headers.authorization;
+ const currentToken = jwtService.extractTokenFromHeader(authHeader);
+ const decoded = jwtService.verifyToken(currentToken); // Make sure verifyToken doesn't throw on expired/invalid for this flow if needed or handle it
+
+ if (decoded && decoded.jti) {
+ // Ensure there is a jti in the current token
+ await jwtService.revokeAllUserTokensExcept(userId, decoded.jti);
+ } else {
+ // Fallback if current token has no jti, revoke all including current. User will need to log in again.
+ await jwtService.revokeAllUserTokens(userId);
+ }
+
+ res.json({
+ success: true,
+ message:
+ "Password changed successfully. Please log in again with your new password.",
+ });
+ } catch (error) {
+ console.error("Force change password error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to change password",
+ });
+ }
+ }
+);
+
+module.exports = router;
+
+
+
+const express = require("express");
+const pool = require("../config/database"); // Assuming database config is here
+const { requireAuth } = require("../middleware/authMiddleware"); // Assuming auth middleware
+const { v4: uuidv4 } = require("uuid"); // Make sure to require uuid
+const { sendNtfyNotification } = require("../services/notification"); // Fixed import path
+const {
+ generateApiKeyParts,
+ hashApiKeySecret,
+} = require("../utils/apiKeyHelper.js"); // Import API key helpers
+
+const router = express.Router();
+
+// All dashboard routes require authentication
+router.use(requireAuth);
+
+// GET /dashboard - Main dashboard view (My Forms)
+router.get("/", async (req, res) => {
+ try {
+ const [forms] = await pool.query(
+ `SELECT f.uuid, f.name, f.created_at, f.is_archived,
+ (SELECT COUNT(*) FROM submissions WHERE form_uuid = f.uuid) as submission_count
+ FROM forms f
+ WHERE f.user_id = ?
+ ORDER BY f.created_at DESC`,
+ [req.user.id]
+ );
+
+ res.render("dashboard", {
+ user: req.user,
+ forms: forms,
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ view: "my_forms", // To tell dashboard.ejs which section to show
+ pageTitle: "My Forms",
+ });
+ } catch (error) {
+ console.error("Error fetching user forms:", error);
+ // res.status(500).send("Error fetching forms"); // Or render an error page
+ res.render("dashboard", {
+ user: req.user,
+ forms: [],
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ view: "my_forms",
+ pageTitle: "My Forms",
+ error: "Could not load your forms at this time.",
+ });
+ }
+});
+
+// GET /dashboard/create-form - Display page to create a new form
+router.get("/create-form", (req, res) => {
+ res.render("dashboard", {
+ user: req.user,
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ view: "create_form", // To tell dashboard.ejs to show the create form section
+ pageTitle: "Create New Form",
+ });
+});
+
+// POST /dashboard/forms/create - Handle new form creation
+router.post("/forms/create", async (req, res) => {
+ const formName = req.body.formName || "Untitled Form";
+ const newUuid = uuidv4();
+ try {
+ await pool.query(
+ "INSERT INTO forms (uuid, name, user_id) VALUES (?, ?, ?)",
+ [newUuid, formName, req.user.id]
+ );
+ console.log(
+ `Form created: ${formName} with UUID: ${newUuid} for user: ${req.user.id}`
+ );
+
+ // Optional: Send a notification (if your ntfy setup is user-specific or global)
+ // Consider if this notification is still relevant or needs adjustment for user context
+ if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
+ try {
+ await sendNtfyNotification(
+ "New Form Created (User)",
+ `Form \"${formName}\" (UUID: ${newUuid}) was created by user ${req.user.email}.`,
+ "high"
+ );
+ } catch (ntfyError) {
+ console.error(
+ "Failed to send ntfy notification for new form creation:",
+ ntfyError
+ );
+ }
+ }
+
+ res.redirect("/dashboard"); // Redirect to the user's form list
+ } catch (error) {
+ console.error("Error creating form for user:", error);
+ // Render the create form page again with an error message
+ res.render("dashboard", {
+ user: req.user,
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ view: "create_form",
+ pageTitle: "Create New Form",
+ error: "Failed to create form. Please try again.",
+ formNameValue: formName, // Pass back the entered form name
+ });
+ }
+});
+
+// GET /dashboard/submissions/:formUuid - View submissions for a specific form
+router.get("/submissions/:formUuid", async (req, res) => {
+ const { formUuid } = req.params;
+ const page = parseInt(req.query.page) || 1;
+ const limit = parseInt(req.query.limit) || 10; // Default 10 submissions per page
+ const offset = (page - 1) * limit;
+
+ try {
+ // First, verify the user owns the form
+ const [formDetails] = await pool.query(
+ "SELECT name, user_id FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+
+ if (formDetails.length === 0) {
+ // return res.status(404).send("Form not found.");
+ return res.render("dashboard", {
+ user: req.user,
+ view: "my_forms", // Redirect to a safe place or show a specific error view
+ pageTitle: "Form Not Found",
+ error: "The form you are looking for does not exist.",
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ forms: [], // Provide empty forms array if redirecting to my_forms with an error
+ });
+ }
+
+ if (formDetails[0].user_id !== req.user.id) {
+ // return res.status(403).send("Access denied. You do not own this form.");
+ return res.render("dashboard", {
+ user: req.user,
+ view: "my_forms", // Redirect to a safe place or show a specific error view
+ pageTitle: "Access Denied",
+ error: "You do not have permission to view submissions for this form.",
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ forms: [], // Provide empty forms array
+ });
+ }
+
+ const formName = formDetails[0].name;
+
+ // Get total count of submissions for pagination
+ const [countResult] = await pool.query(
+ "SELECT COUNT(*) as total FROM submissions WHERE form_uuid = ?",
+ [formUuid]
+ );
+ const totalSubmissions = countResult[0].total;
+ const totalPages = Math.ceil(totalSubmissions / limit);
+
+ // Fetch paginated submissions
+ const [submissions] = await pool.query(
+ "SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
+ [formUuid, limit, offset]
+ );
+
+ res.render("dashboard", {
+ user: req.user,
+ view: "form_submissions",
+ pageTitle: `Submissions for ${formName}`,
+ submissions: submissions,
+ formUuid: formUuid,
+ formName: formName,
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ pagination: {
+ currentPage: page,
+ totalPages: totalPages,
+ totalSubmissions: totalSubmissions,
+ limit: limit,
+ },
+ });
+ } catch (error) {
+ console.error(
+ "Error fetching submissions for form:",
+ formUuid,
+ "user:",
+ req.user.id,
+ error
+ );
+ // Render an error state within the dashboard
+ res.render("dashboard", {
+ user: req.user,
+ view: "form_submissions", // Or a dedicated error view component
+ pageTitle: "Error Loading Submissions",
+ error:
+ "Could not load submissions for this form. Please try again later.",
+ formUuid: formUuid,
+ formName: "Error", // Placeholder for formName when an error occurs
+ submissions: [],
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ pagination: {
+ currentPage: 1,
+ totalPages: 1,
+ totalSubmissions: 0,
+ limit: limit,
+ },
+ });
+ }
+});
+
+// GET /dashboard/submissions/:formUuid/export - Export submissions to CSV
+router.get("/submissions/:formUuid/export", async (req, res) => {
+ const { formUuid } = req.params;
+ try {
+ // First, verify the user owns the form
+ const [formDetails] = await pool.query(
+ "SELECT name, user_id FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+
+ if (formDetails.length === 0) {
+ return res.status(404).send("Form not found.");
+ }
+
+ if (formDetails[0].user_id !== req.user.id) {
+ return res.status(403).send("Access denied. You do not own this form.");
+ }
+ const formName = formDetails[0].name;
+
+ const [submissions] = await pool.query(
+ "SELECT data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC",
+ [formUuid]
+ );
+
+ // Create CSV content
+ const headers = ["Submitted At", "IP Address"];
+ const rows = submissions.map((submission) => {
+ const data = JSON.parse(submission.data);
+ // Add all form fields as headers
+ Object.keys(data).forEach((key) => {
+ if (!headers.includes(key)) {
+ headers.push(key);
+ }
+ });
+ return {
+ submitted_at: new Date(submission.submitted_at).toISOString(),
+ ip_address: submission.ip_address,
+ ...data,
+ };
+ });
+
+ // Generate CSV content
+ let csvContent = headers.join(",") + "\n";
+ rows.forEach((row) => {
+ const values = headers.map((header) => {
+ const value = row[header] || "";
+ // Escape commas and quotes in values
+ return `"${String(value).replace(/"/g, '""')}"`;
+ });
+ csvContent += values.join(",") + "\n";
+ });
+
+ // Set response headers for CSV download
+ res.setHeader("Content-Type", "text/csv");
+ res.setHeader(
+ "Content-Disposition",
+ `attachment; filename="${formName}-submissions.csv"`
+ );
+ res.send(csvContent);
+ } catch (error) {
+ console.error(
+ "Error exporting submissions:",
+ formUuid,
+ "user:",
+ req.user.id,
+ error
+ );
+ res.status(500).send("Error exporting submissions");
+ }
+});
+
+// GET /dashboard/forms/:formUuid/settings - Display form settings page
+router.get("/forms/:formUuid/settings", async (req, res) => {
+ const { formUuid } = req.params;
+ try {
+ const [formDetailsArray] = await pool.query(
+ "SELECT name, user_id, email_notifications_enabled, notification_email_address, recaptcha_enabled, thank_you_url, thank_you_message, allowed_domains FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+
+ if (formDetailsArray.length === 0) {
+ return res.render("dashboard", {
+ user: req.user,
+ view: "my_forms",
+ pageTitle: "Form Not Found",
+ error: "The form you are trying to access settings for does not exist.",
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ forms: [],
+ });
+ }
+ const formDetails = formDetailsArray[0];
+
+ if (formDetails.user_id !== req.user.id) {
+ return res.render("dashboard", {
+ user: req.user,
+ view: "my_forms",
+ pageTitle: "Access Denied",
+ error: "You do not have permission to access settings for this form.",
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ forms: [],
+ });
+ }
+
+ res.render("dashboard", {
+ user: req.user,
+ view: "form_settings",
+ pageTitle: `Settings for ${formDetails.name}`,
+ formName: formDetails.name, // For the header
+ currentFormName: formDetails.name, // For the input field value
+ formUuid: formUuid,
+ currentEmailNotificationsEnabled: formDetails.email_notifications_enabled,
+ currentNotificationEmailAddress: formDetails.notification_email_address,
+ currentRecaptchaEnabled: formDetails.recaptcha_enabled,
+ currentThankYouUrl: formDetails.thank_you_url,
+ currentThankYouMessage: formDetails.thank_you_message,
+ currentAllowedDomains: formDetails.allowed_domains,
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ successMessage: req.query.successMessage,
+ errorMessage: req.query.errorMessage,
+ });
+ } catch (error) {
+ console.error(
+ "Error fetching form settings for form:",
+ formUuid,
+ "user:",
+ req.user.id,
+ error
+ );
+ res.render("dashboard", {
+ user: req.user,
+ view: "my_forms",
+ pageTitle: "Error",
+ error: "Could not load settings for this form. Please try again later.",
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ forms: [], // Go back to a safe page
+ });
+ }
+});
+
+// POST /dashboard/forms/:formUuid/settings/update - Update various form settings
+router.post("/forms/:formUuid/settings/update", async (req, res) => {
+ const { formUuid } = req.params;
+ const {
+ formName,
+ emailNotificationsEnabled,
+ notificationEmailAddress,
+ recaptchaEnabled,
+ thankYouUrl,
+ thankYouMessage,
+ allowedDomains,
+ } = req.body;
+
+ // Validate formName (must not be empty if provided)
+ if (formName !== undefined && formName.trim() === "") {
+ return res.redirect(
+ `/dashboard/forms/${formUuid}/settings?errorMessage=Form name cannot be empty.`
+ );
+ }
+
+ // Convert checkbox values which might come as 'on' or undefined
+ const finalEmailNotificationsEnabled =
+ emailNotificationsEnabled === "on" || emailNotificationsEnabled === true;
+ const finalRecaptchaEnabled =
+ recaptchaEnabled === "on" || recaptchaEnabled === true;
+
+ // If email notifications are enabled, but no specific address is provided,
+ // and there's no existing specific address, we might want to clear it or use user's default.
+ // For now, if it's blank, we'll store NULL or an empty string based on DB.
+ // Let's assume an empty string means "use user's default email" when sending.
+ const finalNotificationEmailAddress = notificationEmailAddress
+ ? notificationEmailAddress.trim()
+ : null;
+
+ try {
+ // First, verify the user owns the form
+ const [formOwnerCheck] = await pool.query(
+ "SELECT user_id FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+
+ if (
+ formOwnerCheck.length === 0 ||
+ formOwnerCheck[0].user_id !== req.user.id
+ ) {
+ // Security: Do not reveal if form exists or not, just deny.
+ // Or redirect to a generic error page/dashboard.
+ // For now, let's redirect with a generic error.
+ return res.redirect(
+ `/dashboard/forms/${formUuid}/settings?errorMessage=Access denied or form not found.`
+ );
+ }
+
+ // Build the update query dynamically based on which fields are provided
+ const updates = {};
+ if (formName !== undefined) updates.name = formName.trim();
+ if (emailNotificationsEnabled !== undefined)
+ updates.email_notifications_enabled = finalEmailNotificationsEnabled;
+ if (notificationEmailAddress !== undefined)
+ updates.notification_email_address = finalNotificationEmailAddress; // Allows clearing the address
+ if (recaptchaEnabled !== undefined)
+ updates.recaptcha_enabled = finalRecaptchaEnabled;
+ if (thankYouUrl !== undefined)
+ updates.thank_you_url = thankYouUrl.trim() || null;
+ if (thankYouMessage !== undefined)
+ updates.thank_you_message = thankYouMessage.trim() || null;
+ if (allowedDomains !== undefined)
+ updates.allowed_domains = allowedDomains.trim() || null;
+
+ if (Object.keys(updates).length === 0) {
+ // Nothing to update, redirect back, maybe with an info message
+ return res.redirect(
+ `/dashboard/forms/${formUuid}/settings?successMessage=No changes were made.`
+ );
+ }
+
+ updates.updated_at = new Date(); // Explicitly set updated_at
+
+ await pool.query("UPDATE forms SET ? WHERE uuid = ? AND user_id = ?", [
+ updates,
+ formUuid,
+ req.user.id, // Ensure user_id match as an extra precaution
+ ]);
+
+ console.log(
+ `Form settings updated for ${formUuid} by user ${req.user.id}:`,
+ updates
+ );
+ res.redirect(
+ `/dashboard/forms/${formUuid}/settings?successMessage=Settings updated successfully!`
+ );
+ } catch (error) {
+ console.error(
+ "Error updating form settings for form:",
+ formUuid,
+ "user:",
+ req.user.id,
+ error
+ );
+ res.redirect(
+ `/dashboard/forms/${formUuid}/settings?errorMessage=Error updating settings. Please try again.`
+ );
+ }
+});
+
+// POST /dashboard/forms/archive/:formUuid - Archive a form
+router.post("/forms/archive/:formUuid", async (req, res) => {
+ const { formUuid } = req.params;
+ try {
+ const [formDetails] = await pool.query(
+ "SELECT user_id FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+ if (formDetails.length === 0) {
+ return res.redirect(
+ "/dashboard?errorMessage=" + encodeURIComponent("Form not found.")
+ );
+ }
+ if (formDetails[0].user_id !== req.user.id) {
+ return res.redirect(
+ "/dashboard?errorMessage=" +
+ encodeURIComponent("You do not have permission to modify this form.")
+ );
+ }
+
+ await pool.query(
+ "UPDATE forms SET is_archived = true WHERE uuid = ? AND user_id = ?",
+ [formUuid, req.user.id]
+ );
+ res.redirect(
+ "/dashboard?successMessage=" +
+ encodeURIComponent("Form archived successfully.")
+ );
+ } catch (error) {
+ console.error("Error archiving form:", formUuid, error);
+ res.redirect(
+ "/dashboard?errorMessage=" + encodeURIComponent("Failed to archive form.")
+ );
+ }
+});
+
+// POST /dashboard/forms/unarchive/:formUuid - Unarchive a form
+router.post("/forms/unarchive/:formUuid", async (req, res) => {
+ const { formUuid } = req.params;
+ try {
+ const [formDetails] = await pool.query(
+ "SELECT user_id FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+ if (formDetails.length === 0) {
+ return res.redirect(
+ "/dashboard?errorMessage=" + encodeURIComponent("Form not found.")
+ );
+ }
+ if (formDetails[0].user_id !== req.user.id) {
+ return res.redirect(
+ "/dashboard?errorMessage=" +
+ encodeURIComponent("You do not have permission to modify this form.")
+ );
+ }
+
+ await pool.query(
+ "UPDATE forms SET is_archived = false WHERE uuid = ? AND user_id = ?",
+ [formUuid, req.user.id]
+ );
+ res.redirect(
+ "/dashboard?successMessage=" +
+ encodeURIComponent("Form unarchived successfully.")
+ );
+ } catch (error) {
+ console.error("Error unarchiving form:", formUuid, error);
+ res.redirect(
+ "/dashboard?errorMessage=" +
+ encodeURIComponent("Failed to unarchive form.")
+ );
+ }
+});
+
+// POST /dashboard/forms/delete/:formUuid - Permanently delete a form
+router.post("/forms/delete/:formUuid", async (req, res) => {
+ const { formUuid } = req.params;
+ try {
+ // Verify ownership first
+ const [formDetails] = await pool.query(
+ "SELECT user_id, name FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+ if (formDetails.length === 0) {
+ return res.redirect(
+ "/dashboard?errorMessage=" + encodeURIComponent("Form not found.")
+ );
+ }
+ if (formDetails[0].user_id !== req.user.id) {
+ return res.redirect(
+ "/dashboard?errorMessage=" +
+ encodeURIComponent("You do not have permission to delete this form.")
+ );
+ }
+
+ // Perform deletion. Assuming ON DELETE CASCADE is set up for submissions.
+ // If not, delete submissions explicitly first: await pool.query("DELETE FROM submissions WHERE form_uuid = ?", [formUuid]);
+ const [deleteResult] = await pool.query(
+ "DELETE FROM forms WHERE uuid = ? AND user_id = ?",
+ [formUuid, req.user.id]
+ );
+
+ if (deleteResult.affectedRows > 0) {
+ console.log(
+ `Form permanently deleted: ${formDetails[0].name} (UUID: ${formUuid}) by user ${req.user.id}`
+ );
+ res.redirect(
+ "/dashboard?successMessage=" +
+ encodeURIComponent(
+ `Form '${formDetails[0].name}' and its submissions deleted successfully.`
+ )
+ );
+ } else {
+ res.redirect(
+ "/dashboard?errorMessage=" +
+ encodeURIComponent(
+ "Failed to delete form. It might have already been deleted."
+ )
+ );
+ }
+ } catch (error) {
+ console.error("Error deleting form:", formUuid, error);
+ res.redirect(
+ "/dashboard?errorMessage=" +
+ encodeURIComponent("An error occurred while deleting the form.")
+ );
+ }
+});
+
+// POST /dashboard/submissions/delete/:submissionId - Delete a specific submission
+router.post("/submissions/delete/:submissionId", async (req, res) => {
+ const { submissionId } = req.params;
+ const { formUuidForRedirect } = req.body; // Get this from the form body for redirect
+
+ if (!formUuidForRedirect) {
+ console.error(
+ "formUuidForRedirect not provided for submission deletion redirect"
+ );
+ return res.redirect(
+ "/dashboard?errorMessage=" +
+ encodeURIComponent(
+ "Could not determine where to redirect after deletion."
+ )
+ );
+ }
+
+ try {
+ // First, verify the user owns the form to which the submission belongs
+ const [submissionDetails] = await pool.query(
+ `SELECT s.form_uuid, f.user_id
+ FROM submissions s
+ JOIN forms f ON s.form_uuid = f.uuid
+ WHERE s.id = ?`,
+ [submissionId]
+ );
+
+ if (submissionDetails.length === 0) {
+ return res.redirect(
+ `/dashboard/submissions/${formUuidForRedirect}?errorMessage=` +
+ encodeURIComponent("Submission not found.")
+ );
+ }
+
+ if (submissionDetails[0].user_id !== req.user.id) {
+ return res.redirect(
+ `/dashboard/submissions/${formUuidForRedirect}?errorMessage=` +
+ encodeURIComponent(
+ "You do not have permission to delete this submission."
+ )
+ );
+ }
+
+ // Actual deletion of the submission
+ const [deleteResult] = await pool.query(
+ "DELETE FROM submissions WHERE id = ?",
+ [submissionId]
+ );
+
+ if (deleteResult.affectedRows > 0) {
+ console.log(
+ `Submission ID ${submissionId} deleted by user ${req.user.id}`
+ );
+ res.redirect(
+ `/dashboard/submissions/${formUuidForRedirect}?successMessage=` +
+ encodeURIComponent("Submission deleted successfully.")
+ );
+ } else {
+ res.redirect(
+ `/dashboard/submissions/${formUuidForRedirect}?errorMessage=` +
+ encodeURIComponent(
+ "Failed to delete submission. It might have already been deleted."
+ )
+ );
+ }
+ } catch (error) {
+ console.error(
+ "Error deleting submission:",
+ submissionId,
+ "user:",
+ req.user.id,
+ error
+ );
+ res.redirect(
+ `/dashboard/submissions/${formUuidForRedirect}?errorMessage=` +
+ encodeURIComponent("An error occurred while deleting the submission.")
+ );
+ }
+});
+
+// GET /dashboard/api-keys - Display API key management page
+router.get("/api-keys", async (req, res) => {
+ try {
+ const [keys] = await pool.query(
+ "SELECT uuid, key_name, api_key_identifier, created_at, last_used_at, expires_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC",
+ [req.user.id]
+ );
+ res.render("dashboard", {
+ user: req.user,
+ view: "api_keys",
+ pageTitle: "API Keys",
+ apiKeys: keys,
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ // For displaying a newly generated key (one-time)
+ newlyGeneratedApiKey: req.session.newlyGeneratedApiKey,
+ newlyGeneratedApiKeyName: req.session.newlyGeneratedApiKeyName,
+ });
+ // Clear the newly generated key from session after displaying it once
+ if (req.session.newlyGeneratedApiKey) {
+ delete req.session.newlyGeneratedApiKey;
+ delete req.session.newlyGeneratedApiKeyName;
+ }
+ } catch (error) {
+ console.error("Error fetching API keys for user:", req.user.id, error);
+ res.render("dashboard", {
+ user: req.user,
+ view: "api_keys",
+ pageTitle: "API Keys",
+ apiKeys: [],
+ error: "Could not load your API keys at this time.",
+ appUrl: `${req.protocol}://${req.get("host")}`,
+ });
+ }
+});
+
+// POST /dashboard/api-keys/generate - Generate a new API key
+router.post("/api-keys/generate", async (req, res) => {
+ const { keyName } = req.body;
+ if (!keyName || keyName.trim() === "") {
+ return res.redirect(
+ "/dashboard/api-keys?errorMessage=Key name cannot be empty."
+ );
+ }
+
+ try {
+ const { fullApiKey, identifier, secret } = generateApiKeyParts();
+ const hashedSecret = await hashApiKeySecret(secret);
+ const newApiKeyUuid = uuidv4();
+
+ await pool.query(
+ "INSERT INTO api_keys (uuid, user_id, key_name, api_key_identifier, hashed_api_key_secret) VALUES (?, ?, ?, ?, ?)",
+ [newApiKeyUuid, req.user.id, keyName.trim(), identifier, hashedSecret]
+ );
+
+ console.log(
+ `API Key generated for user ${req.user.id}: Name: ${keyName.trim()}, Identifier: ${identifier}`
+ );
+
+ // Store the full API key in session to display it ONCE to the user
+ // This is a common pattern as the full key should not be retrievable again.
+ req.session.newlyGeneratedApiKey = fullApiKey;
+ req.session.newlyGeneratedApiKeyName = keyName.trim();
+
+ res.redirect(
+ "/dashboard/api-keys?successMessage=API Key generated successfully! Make sure to copy it now, you won\'t see it again."
+ );
+ } catch (error) {
+ console.error("Error generating API key for user:", req.user.id, error);
+ // Check for unique constraint violation on api_key_identifier (rare, but possible)
+ if (error.code === "ER_DUP_ENTRY") {
+ return res.redirect(
+ "/dashboard/api-keys?errorMessage=Failed to generate key due to a conflict. Please try again."
+ );
+ }
+ res.redirect(
+ "/dashboard/api-keys?errorMessage=Error generating API key. Please try again."
+ );
+ }
+});
+
+// POST /dashboard/api-keys/:apiKeyUuid/revoke - Revoke (delete) an API key
+router.post("/api-keys/:apiKeyUuid/revoke", async (req, res) => {
+ const { apiKeyUuid } = req.params;
+ try {
+ const [keyDetails] = await pool.query(
+ "SELECT user_id, key_name FROM api_keys WHERE uuid = ? AND user_id = ?",
+ [apiKeyUuid, req.user.id]
+ );
+
+ if (keyDetails.length === 0) {
+ return res.redirect(
+ "/dashboard/api-keys?errorMessage=API Key not found or you do not have permission to revoke it."
+ );
+ }
+
+ await pool.query("DELETE FROM api_keys WHERE uuid = ? AND user_id = ?", [
+ apiKeyUuid,
+ req.user.id,
+ ]);
+
+ console.log(
+ `API Key revoked: UUID ${apiKeyUuid}, Name: ${keyDetails[0].key_name} by user ${req.user.id}`
+ );
+ res.redirect(
+ "/dashboard/api-keys?successMessage=API Key revoked successfully."
+ );
+ } catch (error) {
+ console.error(
+ "Error revoking API key:",
+ apiKeyUuid,
+ "user:",
+ req.user.id,
+ error
+ );
+ res.redirect(
+ "/dashboard/api-keys?errorMessage=Error revoking API key. Please try again."
+ );
+ }
+});
+
+module.exports = router;
+
+
+
+const express = require("express");
+const pool = require("../config/database");
+const { sendNtfyNotification } = require("../services/notification");
+const { sendSubmissionNotification } = require("../services/emailService");
+const { verifyRecaptchaV2 } = require("../utils/recaptchaHelper");
+const {
+ createSubmissionRateLimiter,
+ createFormSpecificRateLimiter,
+ createStrictRateLimiter,
+} = require("../middleware/redisRateLimiter");
+const domainChecker = require("../middleware/domainChecker");
+
+const router = express.Router();
+
+// Initialize rate limiters
+const submissionRateLimit = createSubmissionRateLimiter();
+const formSpecificRateLimit = createFormSpecificRateLimiter();
+const strictRateLimit = createStrictRateLimiter();
+
+router.get("/health", (req, res) => res.status(200).json({ status: "ok" }));
+
+router.post(
+ "/submit/:formUuid",
+ strictRateLimit, // First layer: strict per-IP rate limit across all forms
+ submissionRateLimit, // Second layer: general submission rate limit per IP
+ formSpecificRateLimit, // Third layer: specific form+IP rate limit
+ domainChecker,
+ async (req, res) => {
+ const { formUuid } = req.params;
+ const submissionData = { ...req.body };
+ const ipAddress = req.ip;
+
+ // Extract reCAPTCHA response from submission data
+ const recaptchaToken = submissionData["g-recaptcha-response"];
+ // Clean it from submissionData so it's not stored in DB or shown in notifications
+ delete submissionData["g-recaptcha-response"];
+
+ // Honeypot check (early exit)
+ if (submissionData.honeypot_field && submissionData.honeypot_field !== "") {
+ console.log(
+ `Honeypot triggered for ${formUuid} by IP ${ipAddress}. Ignoring submission.`
+ );
+ if (submissionData._thankyou) {
+ return res.redirect(submissionData._thankyou);
+ }
+ return res.send(
+ "
Thank You!
Your submission has been received.
"
+ );
+ }
+
+ // Fetch form settings first to check for reCAPTCHA status and other details
+ let formSettings;
+ try {
+ const [forms] = await pool.query(
+ "SELECT id, user_id, name, thank_you_url, thank_you_message, ntfy_enabled, is_archived, email_notifications_enabled, notification_email_address, recaptcha_enabled FROM forms WHERE uuid = ?",
+ [formUuid]
+ );
+ if (forms.length === 0) {
+ return res.status(404).send("Form endpoint not found.");
+ }
+ formSettings = forms[0];
+
+ if (formSettings.is_archived) {
+ return res
+ .status(410)
+ .send(
+ "This form has been archived and is no longer accepting submissions."
+ );
+ }
+ } catch (dbError) {
+ console.error("Error fetching form settings during submission:", dbError);
+ return res
+ .status(500)
+ .send("Error processing submission due to database issue.");
+ }
+
+ // Perform reCAPTCHA verification if it's enabled for this form
+ if (formSettings.recaptcha_enabled) {
+ if (!recaptchaToken) {
+ console.warn(
+ `reCAPTCHA enabled for form ${formUuid} but no token provided by IP ${ipAddress}.`
+ );
+ return res
+ .status(403)
+ .send(
+ "reCAPTCHA is required for this form. Please complete the challenge."
+ );
+ }
+
+ const isRecaptchaValid = await verifyRecaptchaV2(
+ recaptchaToken,
+ ipAddress
+ );
+ if (!isRecaptchaValid) {
+ console.warn(
+ `reCAPTCHA verification failed for form ${formUuid} from IP ${ipAddress}.`
+ );
+ return res
+ .status(403)
+ .send("reCAPTCHA verification failed. Please try again.");
+ }
+ } // If reCAPTCHA is not enabled, or if it was enabled and passed, proceed.
+
+ // Main submission processing logic (moved DB query for form details up)
+ let formNameForNotification = formSettings.name || `Form ${formUuid}`;
+ try {
+ const ntfyEnabled = formSettings.ntfy_enabled;
+ const formOwnerUserId = formSettings.user_id;
+
+ // Prepare form object for email service
+ const formForEmail = {
+ name: formSettings.name,
+ email_notifications_enabled: formSettings.email_notifications_enabled,
+ notification_email_address: formSettings.notification_email_address,
+ };
+
+ // Fetch form owner's email for default notification recipient
+ let ownerEmail = null;
+ if (formOwnerUserId) {
+ const [users] = await pool.query(
+ "SELECT email FROM users WHERE id = ?",
+ [formOwnerUserId]
+ );
+ if (users.length > 0) {
+ ownerEmail = users[0].email;
+ } else {
+ console.warn(
+ `Owner user with ID ${formOwnerUserId} not found for form ${formUuid}.`
+ );
+ }
+ }
+
+ await pool.query(
+ "INSERT INTO submissions (form_uuid, user_id, data, ip_address) VALUES (?, ?, ?, ?)",
+ [formUuid, formOwnerUserId, JSON.stringify(submissionData), ipAddress]
+ );
+ console.log(
+ `Submission received for ${formUuid} (user: ${formOwnerUserId}):`,
+ submissionData
+ );
+
+ const submissionSummary = Object.entries(submissionData)
+ .filter(([key]) => key !== "_thankyou")
+ .map(([key, value]) => `${key}: ${value}`)
+ .join(", ");
+
+ if (ntfyEnabled) {
+ await sendNtfyNotification(
+ `New Submission: ${formNameForNotification}`,
+ `Data: ${
+ submissionSummary || "No data fields"
+ }\nFrom IP: ${ipAddress}`,
+ "high",
+ "incoming_form"
+ );
+ }
+
+ // Send email notification
+ if (ownerEmail) {
+ // Only attempt if we have an owner email (even if custom one is set, good to have fallback context)
+ sendSubmissionNotification(
+ formForEmail,
+ submissionData,
+ ownerEmail
+ ).catch((err) =>
+ console.error(
+ "Failed to send submission email directly in route:",
+ err
+ )
+ ); // Log error but don't block response
+ } else if (
+ formForEmail.email_notifications_enabled &&
+ !formForEmail.notification_email_address
+ ) {
+ console.warn(
+ `Email notification enabled for form ${formUuid} but owner email could not be determined and no custom address set.`
+ );
+ }
+
+ if (formSettings.thank_you_url) {
+ return res.redirect(formSettings.thank_you_url);
+ }
+
+ if (formSettings.thank_you_message) {
+ // Basic HTML escaping for safety
+ const safeMessage = formSettings.thank_you_message
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ return res.send(safeMessage);
+ }
+
+ if (submissionData._thankyou) {
+ return res.redirect(submissionData._thankyou);
+ }
+
+ res.send(
+ '
This email confirms that your password has been successfully changed for your Formies account.
+
If you didn't make this change, please contact our support team immediately.
+
For your security, here are some tips:
+
+
Use a strong, unique password
+
Don't share your password with anyone
+
Consider using a password manager
+
+
+
+
+
+
+ `;
+ }
+}
+
+/**
+ * Generates a simple HTML body for the submission notification email.
+ * @param {string} formName - The name of the form.
+ * @param {object} submissionData - The data submitted to the form.
+ * @returns {string} - HTML string for the email body.
+ */
+function createEmailHtmlBody(formName, submissionData) {
+ let body = `
You have a new submission for your form: ${formName}.
`;
+ body += "
Here are the details:
";
+ for (const [key, value] of Object.entries(submissionData)) {
+ // Exclude honeypot and other internal fields if necessary
+ if (key.toLowerCase() !== "honeypot_field" && key !== "_thankyou") {
+ body += `
${key}: ${value}
`;
+ }
+ }
+ body += "
Thank you for using Formies!
";
+ return body;
+}
+
+/**
+ * Sends a submission notification email.
+ * @param {object} form - Form details (name, email_notifications_enabled, notification_email_address).
+ * @param {object} submissionData - The actual data submitted to the form.
+ * @param {string} userOwnerEmail - The email of the user who owns the form.
+ */
+async function sendSubmissionNotification(
+ form,
+ submissionData,
+ userOwnerEmail
+) {
+ if (!resend) {
+ logger.warn(
+ "Resend SDK not initialized due to missing API key. Skipping email notification."
+ );
+ return;
+ }
+ if (!emailFromAddress) {
+ logger.warn(
+ "EMAIL_FROM_ADDRESS not configured. Skipping email notification."
+ );
+ return;
+ }
+
+ if (!form || !form.email_notifications_enabled) {
+ logger.info(
+ `Email notifications are disabled for form: ${form ? form.name : "Unknown Form"}. Skipping.`
+ );
+ return;
+ }
+
+ const recipientEmail = form.notification_email_address || userOwnerEmail;
+ if (!recipientEmail) {
+ logger.warn(
+ `No recipient email address found for form: ${form.name}. Skipping notification.`
+ );
+ return;
+ }
+
+ const subject = `New Submission for Form: ${form.name}`;
+ const htmlBody = createEmailHtmlBody(form.name, submissionData);
+
+ try {
+ const { data, error } = await resend.emails.send({
+ from: emailFromAddress,
+ to: recipientEmail,
+ subject: subject,
+ html: htmlBody,
+ });
+
+ if (error) {
+ logger.error("Error sending submission email via Resend:", error);
+ // Do not let email failure break the submission flow (as per 2.3.4)
+ return; // Or throw a specific error to be caught upstream if needed for more complex handling
+ }
+
+ logger.info(
+ `Submission email sent successfully to ${recipientEmail} for form ${form.name}. Message ID: ${data ? data.id : "N/A"}`
+ );
+ } catch (err) {
+ logger.error("Exception caught while sending submission email:", err);
+ // Do not let email failure break the submission flow
+ }
+}
+
+module.exports = {
+ sendSubmissionNotification,
+ // Potentially export createEmailHtmlBody if it needs to be used elsewhere or for testing
+};
+
+
+
+const jwt = require("jsonwebtoken");
+const { v4: uuidv4 } = require("uuid");
+const User = require("../models/User");
+
+class JWTService {
+ constructor() {
+ this.secret = process.env.JWT_SECRET;
+ this.issuer = process.env.JWT_ISSUER || "formies";
+ this.audience = process.env.JWT_AUDIENCE || "formies-users";
+ this.accessTokenExpiry = process.env.JWT_ACCESS_EXPIRY || "15m";
+ this.refreshTokenExpiry = process.env.JWT_REFRESH_EXPIRY || "7d";
+
+ if (!this.secret) {
+ throw new Error("JWT_SECRET environment variable is required");
+ }
+ }
+
+ // Generate access token
+ generateAccessToken(user, sessionInfo = {}) {
+ const jti = uuidv4(); // JWT ID for token tracking
+ const payload = {
+ sub: user.id, // Subject (user ID)
+ email: user.email,
+ role: user.role,
+ jti: jti,
+ type: "access",
+ };
+
+ const options = {
+ issuer: this.issuer,
+ audience: this.audience,
+ expiresIn: this.accessTokenExpiry,
+ };
+
+ const token = jwt.sign(payload, this.secret, options);
+ const decoded = jwt.decode(token);
+
+ // Save session for token tracking
+ const expiresAt = new Date(decoded.exp * 1000);
+ User.saveSession(
+ user.id,
+ jti,
+ expiresAt,
+ sessionInfo.userAgent,
+ sessionInfo.ipAddress
+ ).catch(console.error);
+
+ return {
+ token,
+ expiresAt,
+ jti,
+ };
+ }
+
+ // Generate refresh token
+ generateRefreshToken(user, sessionInfo = {}) {
+ const jti = uuidv4();
+ const payload = {
+ sub: user.id,
+ jti: jti,
+ type: "refresh",
+ };
+
+ const options = {
+ issuer: this.issuer,
+ audience: this.audience,
+ expiresIn: this.refreshTokenExpiry,
+ };
+
+ const token = jwt.sign(payload, this.secret, options);
+ const decoded = jwt.decode(token);
+
+ // Save session for token tracking
+ const expiresAt = new Date(decoded.exp * 1000);
+ User.saveSession(
+ user.id,
+ jti,
+ expiresAt,
+ sessionInfo.userAgent,
+ sessionInfo.ipAddress
+ ).catch(console.error);
+
+ return {
+ token,
+ expiresAt,
+ jti,
+ };
+ }
+
+ // Generate token pair (access + refresh)
+ generateTokenPair(user, sessionInfo = {}) {
+ const accessToken = this.generateAccessToken(user, sessionInfo);
+ const refreshToken = this.generateRefreshToken(user, sessionInfo);
+
+ return {
+ accessToken: accessToken.token,
+ refreshToken: refreshToken.token,
+ accessTokenExpiresAt: accessToken.expiresAt,
+ refreshTokenExpiresAt: refreshToken.expiresAt,
+ tokenType: "Bearer",
+ };
+ }
+
+ // Verify and decode token
+ verifyToken(token, tokenType = "access") {
+ try {
+ const options = {
+ issuer: this.issuer,
+ audience: this.audience,
+ };
+
+ const decoded = jwt.verify(token, this.secret, options);
+
+ // Check token type
+ if (decoded.type !== tokenType) {
+ throw new Error(`Invalid token type. Expected ${tokenType}`);
+ }
+
+ return decoded;
+ } catch (error) {
+ if (error.name === "TokenExpiredError") {
+ throw new Error("Token has expired");
+ } else if (error.name === "JsonWebTokenError") {
+ throw new Error("Invalid token");
+ } else if (error.name === "NotBeforeError") {
+ throw new Error("Token not active yet");
+ }
+ throw error;
+ }
+ }
+
+ // Refresh access token using refresh token
+ async refreshAccessToken(refreshToken, sessionInfo = {}) {
+ try {
+ // Verify refresh token
+ const decoded = this.verifyToken(refreshToken, "refresh");
+
+ // Check if token is blacklisted
+ const isBlacklisted = await User.isTokenBlacklisted(decoded.jti);
+ if (isBlacklisted) {
+ throw new Error("Refresh token has been revoked");
+ }
+
+ // Get user
+ const user = await User.findById(decoded.sub);
+ if (!user || !user.is_active) {
+ throw new Error("User not found or inactive");
+ }
+
+ // Generate new access token
+ const newAccessToken = this.generateAccessToken(user, sessionInfo);
+
+ return {
+ accessToken: newAccessToken.token,
+ accessTokenExpiresAt: newAccessToken.expiresAt,
+ tokenType: "Bearer",
+ };
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ // Revoke token (add to blacklist)
+ async revokeToken(token) {
+ try {
+ const decoded = jwt.decode(token);
+ if (!decoded || !decoded.jti) {
+ throw new Error("Invalid token format");
+ }
+
+ await User.revokeSession(decoded.jti);
+ return true;
+ } catch (error) {
+ console.error("Error revoking token:", error);
+ return false;
+ }
+ }
+
+ // Revoke all user tokens
+ async revokeAllUserTokens(userId) {
+ try {
+ const revokedCount = await User.revokeAllUserSessions(userId);
+ return revokedCount;
+ } catch (error) {
+ console.error("Error revoking all user tokens:", error);
+ return 0;
+ }
+ }
+
+ // Revoke all user tokens except one
+ async revokeAllUserTokensExcept(userId, exceptJti) {
+ try {
+ const revokedCount = await User.revokeAllUserSessionsExcept(
+ userId,
+ exceptJti
+ );
+ return revokedCount;
+ } catch (error) {
+ console.error("Error revoking user tokens:", error);
+ return 0;
+ }
+ }
+
+ // Extract token from Authorization header
+ extractTokenFromHeader(authHeader) {
+ if (!authHeader) {
+ return null;
+ }
+
+ const parts = authHeader.split(" ");
+ if (parts.length !== 2 || parts[0] !== "Bearer") {
+ return null;
+ }
+
+ return parts[1];
+ }
+
+ // Get token info without verification
+ getTokenInfo(token) {
+ try {
+ return jwt.decode(token);
+ } catch (error) {
+ return null;
+ }
+ }
+
+ // Check if token is expired (without verifying signature)
+ isTokenExpired(token) {
+ const decoded = this.getTokenInfo(token);
+ if (!decoded || !decoded.exp) {
+ return true;
+ }
+
+ return Date.now() >= decoded.exp * 1000;
+ }
+
+ // Cleanup expired sessions (call this periodically)
+ async cleanupExpiredSessions() {
+ try {
+ const cleanedCount = await User.cleanupExpiredSessions();
+ console.log(`Cleaned up ${cleanedCount} expired sessions`);
+ return cleanedCount;
+ } catch (error) {
+ console.error("Error cleaning up expired sessions:", error);
+ return 0;
+ }
+ }
+
+ // Get current session information
+ async getCurrentSession(token) {
+ try {
+ const decoded = this.verifyToken(token);
+ const session = await User.getSessionByJti(decoded.jti);
+
+ if (!session) {
+ throw new Error("Session not found");
+ }
+
+ return {
+ jti: session.token_jti,
+ userAgent: session.user_agent,
+ ipAddress: session.ip_address,
+ createdAt: session.created_at,
+ expiresAt: session.expires_at,
+ };
+ } catch (error) {
+ throw error;
+ }
+ }
+}
+
+module.exports = new JWTService();
+
+
+
+async function sendNtfyNotification(
+ title,
+ message,
+ priority = "default",
+ tags = ""
+) {
+ if (process.env.NTFY_ENABLED !== "true" || !process.env.NTFY_TOPIC_URL) {
+ return;
+ }
+ try {
+ const response = await fetch(process.env.NTFY_TOPIC_URL, {
+ method: "POST",
+ body: message,
+ headers: {
+ Title: title,
+ Priority: priority,
+ Tags: tags,
+ "Content-Type": "text/plain",
+ },
+ });
+ if (!response.ok) {
+ console.error(`Ntfy error: ${response.status} ${await response.text()}`);
+ } else {
+ console.log("Ntfy notification sent successfully.");
+ }
+ } catch (error) {
+ console.error("Failed to send Ntfy notification:", error);
+ }
+}
+
+module.exports = { sendNtfyNotification };
+
+
+
+const crypto = require("crypto");
+const bcrypt = require("bcryptjs");
+
+const API_KEY_IDENTIFIER_PREFIX = "fsk"; // Formies Secret Key
+const API_KEY_IDENTIFIER_LENGTH = 12; // Length of the random part of the identifier
+const API_KEY_SECRET_LENGTH = 32; // Length of the secret part in bytes, results in 2x hex string length
+
+/**
+ * Generates a new API key parts: the full key (to show to user once) and its components for storage.
+ * Identifier: A public, non-secret unique string for lookup (e.g., 'fsk_abcdef123').
+ * Secret: A cryptographically strong random string.
+ * Full Key: Identifier + '_' + Secret (this is what the user gets).
+ * @returns {{ fullApiKey: string, identifier: string, secret: string }}
+ */
+function generateApiKeyParts() {
+ const randomIdentifierPart = crypto
+ .randomBytes(Math.ceil(API_KEY_IDENTIFIER_LENGTH / 2))
+ .toString("hex")
+ .slice(0, API_KEY_IDENTIFIER_LENGTH);
+ const identifier = `${API_KEY_IDENTIFIER_PREFIX}_${randomIdentifierPart}`;
+ const secret = crypto.randomBytes(API_KEY_SECRET_LENGTH).toString("hex");
+ const fullApiKey = `${identifier}_${secret}`;
+ return { fullApiKey, identifier, secret };
+}
+
+/**
+ * Hashes an API key secret using bcrypt.
+ * @param {string} apiKeySecret - The secret part of the API key.
+ * @returns {Promise} - The hashed API key secret.
+ */
+async function hashApiKeySecret(apiKeySecret) {
+ const saltRounds = 10; // Standard practice
+ return bcrypt.hash(apiKeySecret, saltRounds);
+}
+
+/**
+ * Compares a plain text API key secret with a stored hashed secret.
+ * @param {string} plainTextSecret - The plain text secret part provided by the user.
+ * @param {string} hashedSecret - The stored hashed secret from the database.
+ * @returns {Promise} - True if the secrets match, false otherwise.
+ */
+async function compareApiKeySecret(plainTextSecret, hashedSecret) {
+ return bcrypt.compare(plainTextSecret, hashedSecret);
+}
+
+module.exports = {
+ generateApiKeyParts,
+ hashApiKeySecret,
+ compareApiKeySecret,
+ API_KEY_IDENTIFIER_PREFIX,
+};
+
+
+
+// Native fetch is available in Node.js 18+ and doesn't need to be imported
+// const logger = require("../../config/logger"); // Adjust path as needed
+
+const RECAPTCHA_V2_SECRET_KEY = process.env.RECAPTCHA_V2_SECRET_KEY;
+const GOOGLE_RECAPTCHA_VERIFY_URL =
+ "https://www.google.com/recaptcha/api/siteverify";
+
+/**
+ * Verifies a Google reCAPTCHA v2 response.
+ * @param {string} recaptchaToken - The g-recaptcha-response token from the client.
+ * @param {string} [clientIp] - Optional. The user's IP address.
+ * @returns {Promise} - True if verification is successful, false otherwise.
+ */
+async function verifyRecaptchaV2(recaptchaToken, clientIp) {
+ if (!RECAPTCHA_V2_SECRET_KEY) {
+ console.warn(
+ "RECAPTCHA_V2_SECRET_KEY is not set. Skipping reCAPTCHA verification. THIS IS INSECURE FOR PRODUCTION."
+ );
+ // In a real scenario, you might want to fail open or closed based on policy
+ // For now, let's assume if it's not set, we can't verify, so effectively it fails if meant to be checked.
+ // However, the calling route will decide if reCAPTCHA is mandatory.
+ return false; // Or true if you want to bypass if not configured, though less secure.
+ }
+
+ if (!recaptchaToken) {
+ console.warn("No reCAPTCHA token provided by client.");
+ return false;
+ }
+
+ const verificationUrl = `${GOOGLE_RECAPTCHA_VERIFY_URL}?secret=${RECAPTCHA_V2_SECRET_KEY}&response=${recaptchaToken}`;
+ // Add remoteip if provided
+ const finalUrl = clientIp
+ ? `${verificationUrl}&remoteip=${clientIp}`
+ : verificationUrl;
+
+ try {
+ const response = await fetch(finalUrl, { method: "POST" });
+ const data = await response.json();
+
+ if (data.success) {
+ console.info("reCAPTCHA verification successful.");
+ return true;
+ } else {
+ console.warn(
+ "reCAPTCHA verification failed.",
+ data["error-codes"] || "No error codes"
+ );
+ return false;
+ }
+ } catch (error) {
+ console.error("Error during reCAPTCHA verification request:", error);
+ return false;
+ }
+}
+
+module.exports = { verifyRecaptchaV2 };
+
+
+
+
+
+
+
+
+ User Dashboard - Formies
+
+
+
+
+
+
+
+<% } %> <% } %>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..95023c4
--- /dev/null
+++ b/server.js
@@ -0,0 +1,195 @@
+require("dotenv").config();
+const express = require("express");
+const path = require("path");
+const fs = require("fs"); // Added for fs operations
+const db = require("./src/config/database"); // SQLite db instance
+const helmet = require("helmet");
+const session = require("express-session");
+const passport = require("./src/config/passport");
+const logger = require("./config/logger");
+const errorHandler = require("./middleware/errorHandler");
+const { connectRedis, closeRedis } = require("./src/config/redis");
+
+// Import routes
+const publicRoutes = require("./src/routes/public");
+const authRoutes = require("./src/routes/auth");
+const dashboardRoutes = require("./src/routes/dashboard");
+const apiV1Routes = require("./src/routes/api_v1");
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+
+// Function to initialize the database
+async function initializeDatabase() {
+ const dbPath = path.resolve(__dirname, "formies.sqlite");
+ const dbExists = fs.existsSync(dbPath);
+
+ if (!dbExists) {
+ logger.info("Database file not found, creating and initializing...");
+ try {
+ // The 'db' instance from './src/config/database' should already create the file.
+ // Now, run the init.sql script.
+ const initSql = fs.readFileSync(
+ path.resolve(__dirname, "init.sql"),
+ "utf8"
+ );
+ // SQLite driver's `exec` method can run multiple statements
+ await new Promise((resolve, reject) => {
+ db.exec(initSql, (err) => {
+ if (err) {
+ logger.error("Failed to initialize database:", err);
+ return reject(err);
+ }
+ logger.info("Database initialized successfully.");
+ resolve();
+ });
+ });
+ } catch (error) {
+ logger.error("Error during database initialization:", error);
+ process.exit(1); // Exit if DB initialization fails
+ }
+ } else {
+ logger.info("Database file found.");
+ }
+}
+
+// Initialize Redis connection and Database
+async function initializeApp() {
+ // Initialize Redis first, but don't block on failure
+ connectRedis().catch(() => {
+ logger.warn(
+ "Redis connection failed, continuing with in-memory rate limiting"
+ );
+ });
+
+ try {
+ await initializeDatabase(); // Initialize SQLite database
+ } catch (error) {
+ logger.error("Failed to initialize database:", error);
+ process.exit(1); // Exit if DB initialization fails
+ }
+
+ // Middleware
+ app.use(
+ helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ styleSrc: ["'self'", "'unsafe-inline'"],
+ scriptSrc: ["'self'"],
+ imgSrc: ["'self'", "data:", "https:"],
+ },
+ },
+ })
+ );
+
+ app.use(express.json({ limit: "10mb" }));
+ app.use(express.urlencoded({ extended: true, limit: "10mb" }));
+
+ // Session configuration (for development only, use Redis in production)
+ app.use(
+ session({
+ secret:
+ process.env.SESSION_SECRET || "fallback-secret-change-in-production",
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ secure: process.env.NODE_ENV === "production",
+ httpOnly: true,
+ maxAge: 24 * 60 * 60 * 1000, // 24 hours
+ },
+ })
+ );
+
+ // Initialize Passport
+ app.use(passport.initialize());
+ app.use(passport.session());
+
+ // Set view engine
+ app.set("view engine", "ejs");
+
+ // API Routes
+ app.use("/api/auth", authRoutes);
+
+ // API V1 Routes
+ app.use("/api/v1", apiV1Routes);
+
+ // User Dashboard Routes
+ app.use("/dashboard", dashboardRoutes);
+
+ // Existing routes (maintaining backward compatibility)
+ app.use("/", publicRoutes);
+
+ // Health check endpoint
+ app.get("/health", (req, res) => {
+ res.json({
+ status: "healthy",
+ timestamp: new Date().toISOString(),
+ version: "1.0.0",
+ });
+ });
+
+ // Global error handler - should be the last middleware
+ app.use(errorHandler);
+
+ // 404 handler
+ app.use((req, res) => {
+ logger.warn(
+ `404 - Endpoint not found: ${req.originalUrl} - Method: ${req.method} - IP: ${req.ip}`
+ );
+ res.status(404).json({
+ error: {
+ message: "Endpoint not found",
+ code: "NOT_FOUND",
+ },
+ });
+ });
+
+ // Start server
+ app.listen(PORT, () => {
+ logger.info(`Server running on http://localhost:${PORT}`);
+
+ // Environment checks
+ if (!process.env.JWT_SECRET) {
+ logger.warn(
+ "WARNING: JWT_SECRET not set. Authentication will not work properly."
+ );
+ }
+
+ if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
+ logger.info(
+ `Ntfy notifications enabled for topic: ${process.env.NTFY_TOPIC_URL}`
+ );
+ } else {
+ logger.info("Ntfy notifications disabled or topic not configured.");
+ }
+
+ // Start cleanup of expired sessions every hour
+ setInterval(
+ () => {
+ const jwtService = require("./src/services/jwtService");
+ jwtService.cleanupExpiredSessions();
+ },
+ 60 * 60 * 1000
+ );
+ });
+
+ // Graceful shutdown
+ process.on("SIGINT", async () => {
+ logger.info("Received SIGINT, shutting down gracefully...");
+ await closeRedis();
+ process.exit(0);
+ });
+
+ process.on("SIGTERM", async () => {
+ logger.info("Received SIGTERM, shutting down gracefully...");
+ await closeRedis();
+ process.exit(0);
+ });
+}
+
+// Initialize the application
+initializeApp().catch((error) => {
+ logger.error("Failed to initialize application:", error);
+ process.exit(1);
+});
diff --git a/src/auth.rs b/src/auth.rs
deleted file mode 100644
index 1d4b4a3..0000000
--- a/src/auth.rs
+++ /dev/null
@@ -1,119 +0,0 @@
-// 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 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 {
- // 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)",
- )));
- }
- };
-
- // 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 }))
- }
- Ok(None) => {
- log::warn!("Invalid or expired token received");
- ready(Err(ErrorUnauthorized("Invalid or expired token")))
- }
- Err(e) => {
- log::error!("Database error during token validation: {:?}", e);
- 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")))
- }
- }
-}
-
-// 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/config/database.js b/src/config/database.js
new file mode 100644
index 0000000..a873d44
--- /dev/null
+++ b/src/config/database.js
@@ -0,0 +1,20 @@
+const sqlite3 = require("sqlite3").verbose();
+const path = require("path");
+
+const dbPath = path.resolve(__dirname, "../../formies.sqlite");
+
+const db = new sqlite3.Database(dbPath, (err) => {
+ if (err) {
+ console.error("Error opening database", err.message);
+ } else {
+ console.log("Connected to the SQLite database.");
+ // Enable foreign key support
+ db.run("PRAGMA foreign_keys = ON;", (pragmaErr) => {
+ if (pragmaErr) {
+ console.error("Failed to enable foreign keys:", pragmaErr.message);
+ }
+ });
+ }
+});
+
+module.exports = db;
diff --git a/src/config/passport.js b/src/config/passport.js
new file mode 100644
index 0000000..02597b6
--- /dev/null
+++ b/src/config/passport.js
@@ -0,0 +1,170 @@
+const passport = require("passport");
+const LocalStrategy = require("passport-local").Strategy;
+const JwtStrategy = require("passport-jwt").Strategy;
+const ExtractJwt = require("passport-jwt").ExtractJwt;
+const bcrypt = require("bcryptjs");
+const User = require("../models/User");
+
+// Local Strategy for email/password authentication
+passport.use(
+ new LocalStrategy(
+ {
+ usernameField: "email",
+ passwordField: "password",
+ },
+ async (email, password, done) => {
+ try {
+ // Find user by email
+ const user = await User.findByEmail(email);
+
+ if (!user) {
+ return done(null, false, { message: "Invalid email or password" });
+ }
+
+ // Check if account is locked
+ if (
+ user.account_locked_until &&
+ new Date() < user.account_locked_until
+ ) {
+ return done(null, false, {
+ message:
+ "Account temporarily locked due to multiple failed login attempts",
+ });
+ }
+
+ // Check if account is active
+ if (!user.is_active) {
+ return done(null, false, { message: "Account has been deactivated" });
+ }
+
+ // Check if email is verified (for non-admin users)
+ if (!user.is_verified && user.role !== "super_admin") {
+ return done(null, false, {
+ message: "Please verify your email address before logging in",
+ });
+ }
+
+ // Verify password
+ const isValidPassword = await bcrypt.compare(
+ password,
+ user.password_hash
+ );
+
+ if (!isValidPassword) {
+ // Increment failed login attempts
+ await User.incrementFailedLoginAttempts(user.id);
+ return done(null, false, { message: "Invalid email or password" });
+ }
+
+ // Reset failed login attempts and update last login
+ await User.resetFailedLoginAttempts(user.id);
+ await User.updateLastLogin(user.id);
+
+ // Remove sensitive information before returning user
+ const userSafe = {
+ id: user.id,
+ uuid: user.uuid,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ role: user.role,
+ is_verified: user.is_verified,
+ is_active: user.is_active,
+ created_at: user.created_at,
+ last_login: user.last_login,
+ must_change_password: user.must_change_password,
+ };
+
+ return done(null, userSafe);
+ } catch (error) {
+ return done(error);
+ }
+ }
+ )
+);
+
+// JWT Strategy for token-based authentication
+passport.use(
+ new JwtStrategy(
+ {
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ secretOrKey: process.env.JWT_SECRET || "trhrtjtzmkjt56fgdfg3tcvv",
+ issuer: process.env.JWT_ISSUER || "formies",
+ audience: process.env.JWT_AUDIENCE || "formies-users",
+ },
+ async (payload, done) => {
+ try {
+ // Check if token is blacklisted
+ const isBlacklisted = await User.isTokenBlacklisted(payload.jti);
+ if (isBlacklisted) {
+ return done(null, false, { message: "Token has been revoked" });
+ }
+
+ // Find user by ID
+ const user = await User.findById(payload.sub);
+
+ if (!user) {
+ return done(null, false, { message: "User not found" });
+ }
+
+ // Check if account is active
+ if (!user.is_active) {
+ return done(null, false, { message: "Account has been deactivated" });
+ }
+
+ // Remove sensitive information before returning user
+ const userSafe = {
+ id: user.id,
+ uuid: user.uuid,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ role: user.role,
+ is_verified: user.is_verified,
+ is_active: user.is_active,
+ created_at: user.created_at,
+ last_login: user.last_login,
+ must_change_password: user.must_change_password,
+ };
+
+ return done(null, userSafe);
+ } catch (error) {
+ return done(error);
+ }
+ }
+ )
+);
+
+// Serialize user for session
+passport.serializeUser((user, done) => {
+ done(null, user.id);
+});
+
+// Deserialize user from session
+passport.deserializeUser(async (id, done) => {
+ try {
+ const user = await User.findById(id);
+ if (user) {
+ const userSafe = {
+ id: user.id,
+ uuid: user.uuid,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ role: user.role,
+ is_verified: user.is_verified,
+ is_active: user.is_active,
+ created_at: user.created_at,
+ last_login: user.last_login,
+ must_change_password: user.must_change_password,
+ };
+ done(null, userSafe);
+ } else {
+ done(null, false);
+ }
+ } catch (error) {
+ done(error);
+ }
+});
+
+module.exports = passport;
diff --git a/src/config/redis.js b/src/config/redis.js
new file mode 100644
index 0000000..9875788
--- /dev/null
+++ b/src/config/redis.js
@@ -0,0 +1,110 @@
+const { createClient } = require("redis");
+
+let redisClient = null;
+let connectionAttempted = false;
+let isRedisAvailable = false;
+
+const connectRedis = async () => {
+ if (redisClient) {
+ return redisClient;
+ }
+
+ // If we already tried and failed, don't keep trying
+ if (connectionAttempted && !isRedisAvailable) {
+ return null;
+ }
+
+ connectionAttempted = true;
+
+ const redisHost = process.env.REDIS_HOST || "localhost";
+ const redisPort = process.env.REDIS_PORT || 6379;
+ const redisPassword = process.env.REDIS_PASSWORD || "";
+
+ const config = {
+ socket: {
+ host: redisHost,
+ port: redisPort,
+ connectTimeout: 1000, // Reduced timeout to 1 second
+ lazyConnect: true,
+ },
+ // Disable automatic reconnection to prevent spam
+ retry_unfulfilled_commands: false,
+ enable_offline_queue: false,
+ };
+
+ // Add password if provided
+ if (redisPassword) {
+ config.password = redisPassword;
+ }
+
+ redisClient = createClient(config);
+
+ // Only log the first error, not subsequent ones
+ let errorLogged = false;
+ redisClient.on("error", (err) => {
+ if (!errorLogged) {
+ console.warn("Redis connection failed:", err.code || err.message);
+ console.warn("Falling back to in-memory rate limiting");
+ errorLogged = true;
+ }
+ isRedisAvailable = false;
+ });
+
+ redisClient.on("connect", () => {
+ console.log("Connected to Redis");
+ isRedisAvailable = true;
+ });
+
+ redisClient.on("disconnect", () => {
+ if (isRedisAvailable) {
+ console.log("Disconnected from Redis");
+ }
+ isRedisAvailable = false;
+ });
+
+ try {
+ await redisClient.connect();
+ console.log("Redis client connected successfully");
+ isRedisAvailable = true;
+ } catch (error) {
+ console.warn("Failed to connect to Redis:", error.code || error.message);
+ console.warn("Continuing with in-memory rate limiting");
+ isRedisAvailable = false;
+ redisClient = null;
+ return null;
+ }
+
+ return redisClient;
+};
+
+const getRedisClient = () => {
+ if (!redisClient || !isRedisAvailable) {
+ throw new Error("Redis client not available");
+ }
+ return redisClient;
+};
+
+const closeRedis = async () => {
+ if (redisClient && isRedisAvailable) {
+ try {
+ await redisClient.quit();
+ console.log("Redis connection closed");
+ } catch (error) {
+ // Ignore errors during shutdown
+ }
+ }
+ redisClient = null;
+ isRedisAvailable = false;
+ connectionAttempted = false;
+};
+
+const isRedisConnected = () => {
+ return isRedisAvailable && redisClient && redisClient.isOpen;
+};
+
+module.exports = {
+ connectRedis,
+ getRedisClient,
+ closeRedis,
+ isRedisConnected,
+};
diff --git a/src/db.rs b/src/db.rs
deleted file mode 100644
index c210be8..0000000
--- a/src/db.rs
+++ /dev/null
@@ -1,473 +0,0 @@
-// 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
- 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
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- )",
- [],
- )
- .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
- 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,
- FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
- )",
- [],
- )
- .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,
- Some("admin"),
- )
- .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,
- role: Option<&str>, // Optional role parameter
-) -> 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")?;
-
- // 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, role) VALUES (?1, ?2, ?3, ?4)",
- params![user_id, username, hashed_password, role],
- )
- .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, 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
- .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 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| {
- rusqlite::Error::FromSqlConversionFailure(
- 2, // Index of 'fields' column
- rusqlite::types::Type::Text,
- Box::new(e),
- )
- })?;
-
- Ok(models::Form {
- id: Some(id),
- name,
- fields,
- owner_id,
- notify_email,
- notify_ntfy_topic,
- 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, owner_id, notify_email, notify_ntfy_topic, created_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
- ON CONFLICT(id) DO UPDATE SET
- name = excluded.name,
- fields = excluded.fields,
- owner_id = excluded.owner_id,
- notify_email = excluded.notify_email,
- notify_ntfy_topic = excluded.notify_ntfy_topic",
- params![
- id,
- self.name,
- fields_json,
- self.owner_id,
- self.notify_email,
- self.notify_ntfy_topic,
- 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))
- }
-}
-
-// 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(())
- }
-}
-
-// 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
deleted file mode 100644
index 54ae05d..0000000
--- a/src/handlers.rs
+++ /dev/null
@@ -1,1159 +0,0 @@
-use crate::auth::Auth;
-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
-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;
-
-// 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.
-///
-/// 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(
- 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();
- // 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")
- })?;
-
- // Get form definition
- let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?;
-
- // Validate submission against form definition (using the owned payload_value)
- if let Err(validation_errors) =
- validate_submission_against_definition(&payload_value, &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: payload_value, // Store the full validated payload (including captcha_token if sent)
- 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")
- })?;
-
- // --- Notification Throttling & Sending ---
- const NOTIFICATION_THROTTLE_DURATION: Duration = Duration::from_secs(60);
- let mut should_send_notification = true; // Assume we should send initially
-
- // 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;
- }
- }
-
- // If not throttled, update the timestamp *before* attempting to send
- if should_send_notification {
- log::debug!("Updating last notification time for form_id: {}", form_id);
- last_times.insert(form_id.clone(), now);
- }
- } else {
- should_send_notification = false; // Don't attempt if not configured
- }
-
- // Send notifications only if not throttled and configured
- if should_send_notification {
- log::info!("Attempting to send notifications for form_id: {}", form_id);
- // Send Email if configured
- if let Some(notify_email) = &form.notify_email {
- let email_subject = format!("New submission for form: {}", form.name);
- let email_body = format!(
- "A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}",
- form.name,
- submission.id,
- submission.created_at,
- serde_json::to_string_pretty(&submission.data).unwrap_or_default()
- );
- // Use a clone of notification_service if it needs to move into async block
- let notification_service_clone = app_state_ref.notification_service.clone();
- let notify_email_clone = notify_email.clone();
- let email_subject_clone = email_subject.clone();
- let email_body_clone = email_body.clone();
-
- // Spawn email sending as a background task so it doesn't block the response
- tokio::spawn(async move {
- if let Err(e) = notification_service_clone
- .send_email(¬ify_email_clone, &email_subject_clone, &email_body_clone)
- .await
- {
- log::warn!(
- "Failed to send email notification in background task: {}",
- e
- );
- }
- });
- }
-
- // Send ntfy if configured
- if let Some(topic_flag) = &form.notify_ntfy_topic {
- if !topic_flag.is_empty() {
- let ntfy_title = format!("New submission for: {}", form.name);
- let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id);
- // 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: {}", 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",
- "submission_id": submission.id
- })))
-}
-
-// POST /forms
-pub async fn create_form(
- app_state: web::Data,
- auth: Auth,
- form_data: web::Json