diff --git a/.cursor/rules/mvp-scope.mdc b/.cursor/rules/mvp-scope.mdc new file mode 100644 index 0000000..cc4c332 --- /dev/null +++ b/.cursor/rules/mvp-scope.mdc @@ -0,0 +1,77 @@ +--- +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. \ No newline at end of file diff --git a/.env b/.env index febbb19..aeac60b 100644 --- a/.env +++ b/.env @@ -1,4 +1,17 @@ -INITIAL_ADMIN_USERNAME=admin -INITIAL_ADMIN_PASSWORD=admin -ALLOWED_ORIGIN=http://127.0.0.1:5500,http://localhost:5500 -DATABASE_URL=form_data.db \ No newline at end of file +PORT=3000 +JWT_SECRET=dognidnrfognpobibsnccofr + +ADMIN_USER=youradminuser +ADMIN_PASSWORD=yoursecurepassword + +# Ntfy Configuration +NTFY_TOPIC_URL=https://ntfggy.sh/your-secret-form-alerts # IMPORTANT: Change this! +NTFY_ENABLED=true # set to false to disable ntfy + +RECAPTCHA_V2_SITE_KEY=your_actual_site_key +RECAPTCHA_V2_SECRET_KEY=your_actual_secret_key + +RESEND_API_KEY=xxx +EMAIL_FROM_ADDRESS=xxx + +recaptcha_enabled = TRUE \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..d83906d --- /dev/null +++ b/.env.test @@ -0,0 +1,43 @@ +# .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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index ea8c4bf..7dd7eda 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -/target +.env +package-lock.json +node_modules \ No newline at end of file diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..2123336 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,98 @@ +# Formies API Documentation (v1) + +This document provides instructions on how to use the Formies API to access your forms and submission data programmatically. + +## Authentication + +All API requests must be authenticated using an API Key. + +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. diff --git a/AUTHENTICATION_SETUP.md b/AUTHENTICATION_SETUP.md new file mode 100644 index 0000000..8574264 --- /dev/null +++ b/AUTHENTICATION_SETUP.md @@ -0,0 +1,432 @@ +# 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. diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 67c3570..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,4103 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "actix-codec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" -dependencies = [ - "bitflags 2.6.0", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-cors" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0346d8c1f762b41b458ed3145eea914966bb9ad20b9be0d6d463b20d45586370" -dependencies = [ - "actix-utils", - "actix-web", - "derive_more", - "futures-util", - "log", - "once_cell", - "smallvec", -] - -[[package]] -name = "actix-files" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be" -dependencies = [ - "actix-http", - "actix-service", - "actix-utils", - "actix-web", - "bitflags 2.6.0", - "bytes", - "derive_more", - "futures-core", - "http-range", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "v_htmlescape", -] - -[[package]] -name = "actix-http" -version = "3.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d48f96fc3003717aeb9856ca3d02a8c7de502667ad76eeacd830b48d2e91fac4" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-utils", - "ahash 0.8.11", - "base64 0.22.1", - "bitflags 2.6.0", - "brotli", - "bytes", - "bytestring", - "derive_more", - "encoding_rs", - "flate2", - "futures-core", - "h2", - "http 0.2.12", - "httparse", - "httpdate", - "itoa", - "language-tags", - "local-channel", - "mime", - "percent-encoding", - "pin-project-lite", - "rand", - "sha1", - "smallvec", - "tokio", - "tokio-util", - "tracing", - "zstd", -] - -[[package]] -name = "actix-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" -dependencies = [ - "quote", - "syn 2.0.92", -] - -[[package]] -name = "actix-router" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" -dependencies = [ - "bytestring", - "cfg-if", - "http 0.2.12", - "regex", - "regex-lite", - "serde", - "tracing", -] - -[[package]] -name = "actix-rt" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" -dependencies = [ - "actix-macros", - "futures-core", - "tokio", -] - -[[package]] -name = "actix-server" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca2549781d8dd6d75c40cf6b6051260a2cc2f3c62343d761a969a0640646894" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio", - "socket2 0.5.9", - "tokio", - "tracing", -] - -[[package]] -name = "actix-service" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" -dependencies = [ - "futures-core", - "paste", - "pin-project-lite", -] - -[[package]] -name = "actix-utils" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] - -[[package]] -name = "actix-web" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9180d76e5cc7ccbc4d60a506f2c727730b154010262df5b910eb17dbe4b8cb38" -dependencies = [ - "actix-codec", - "actix-http", - "actix-macros", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-utils", - "actix-web-codegen", - "ahash 0.8.11", - "bytes", - "bytestring", - "cfg-if", - "cookie", - "derive_more", - "encoding_rs", - "futures-core", - "futures-util", - "impl-more", - "itoa", - "language-tags", - "log", - "mime", - "once_cell", - "pin-project-lite", - "regex", - "regex-lite", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2 0.5.9", - "time", - "url", -] - -[[package]] -name = "actix-web-codegen" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" -dependencies = [ - "actix-router", - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "actix_route_rate_limiter" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77495de640f6247d4d2d7ef34a98573e20edf9eab03914902ae965ca5c06c1f4" -dependencies = [ - "actix-service", - "actix-web", - "chrono", - "futures", - "log", - "rand", - "tokio", -] - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.15", - "once_cell", - "version_check", -] - -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "getrandom 0.2.15", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" - -[[package]] -name = "async-trait" -version = "0.1.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "backtrace" -version = "0.3.74" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bcrypt" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e7c93a3fb23b2fdde989b2c9ec4dd153063ec81f408507f84c090cd91c6641" -dependencies = [ - "base64 0.13.1", - "blowfish", - "getrandom 0.2.15", - "zeroize", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "blowfish" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" -dependencies = [ - "byteorder", - "cipher", -] - -[[package]] -name = "brotli" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "4.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bumpalo" -version = "3.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" - -[[package]] -name = "bytestring" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" -dependencies = [ - "bytes", -] - -[[package]] -name = "cc" -version = "1.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0" -dependencies = [ - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "chrono" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "config" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23738e11972c7643e4ec947840fc463b6a571afcd3e735bdfce7d03c7a784aca" -dependencies = [ - "async-trait", - "json5", - "lazy_static", - "nom", - "pathdiff", - "ron", - "rust-ini", - "serde", - "serde_json", - "toml", - "yaml-rust", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "cookie" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cssparser" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf 0.11.3", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.92", -] - -[[package]] -name = "debugid" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" -dependencies = [ - "serde", - "uuid", -] - -[[package]] -name = "deranged" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_more" -version = "0.99.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.92", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "dlv-list" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" - -[[package]] -name = "dotenv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" - -[[package]] -name = "dtoa" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "ego-tree" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" - -[[package]] -name = "email-encoding" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a87260449b06739ee78d6281c68d2a0ff3e3af64a78df63d3a1aeb3c06997c8a" -dependencies = [ - "base64 0.22.1", - "memchr", -] - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "env_logger" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" -dependencies = [ - "humantime", - "is-terminal", - "log", - "regex", - "termcolor", -] - -[[package]] -name = "equivalent" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" - -[[package]] -name = "errno" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "findshlibs" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" -dependencies = [ - "cc", - "lazy_static", - "libc", - "winapi", -] - -[[package]] -name = "flate2" -version = "1.0.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "formies_be" -version = "0.1.0" -dependencies = [ - "actix-cors", - "actix-files", - "actix-http", - "actix-rt", - "actix-web", - "actix_route_rate_limiter", - "anyhow", - "bcrypt", - "chrono", - "config", - "dotenv", - "env_logger", - "futures", - "lettre", - "log", - "regex", - "reqwest 0.11.27", - "rusqlite", - "scraper", - "sentry", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-actix-web", - "tracing-appender", - "tracing-bunyan-formatter", - "tracing-log 0.2.0", - "tracing-subscriber", - "ureq", - "url", - "uuid", - "validator", -] - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "getopts" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "getrandom" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "h2" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash 0.8.11", - "allocator-api2", -] - -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" - -[[package]] -name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hostname" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" -dependencies = [ - "libc", - "match_cfg", - "winapi", -] - -[[package]] -name = "hostname" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - -[[package]] -name = "html5ever" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" -dependencies = [ - "log", - "mac", - "markup5ever", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http 1.3.1", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http 1.3.1", - "http-body 1.0.1", - "pin-project-lite", -] - -[[package]] -name = "http-range" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" - -[[package]] -name = "httparse" -version = "1.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.9", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.6.0", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "hyper 1.6.0", - "libc", - "pin-project-lite", - "socket2 0.5.9", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locid" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - -[[package]] -name = "icu_normalizer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "write16", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" - -[[package]] -name = "icu_properties" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locid_transform", - "icu_properties_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" - -[[package]] -name = "icu_provider" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "idna" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "idna" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "if_chain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" - -[[package]] -name = "impl-more" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" - -[[package]] -name = "indexmap" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" -dependencies = [ - "equivalent", - "hashbrown 0.15.2", -] - -[[package]] -name = "inout" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" -dependencies = [ - "generic-array", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "is-terminal" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "itoa" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" - -[[package]] -name = "jobserver" -version = "0.1.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" -dependencies = [ - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - -[[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lettre" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d" -dependencies = [ - "async-trait", - "base64 0.21.7", - "email-encoding", - "email_address", - "fastrand 1.9.0", - "futures-io", - "futures-util", - "hostname 0.3.1", - "httpdate", - "idna 0.3.0", - "mime", - "native-tls", - "nom", - "once_cell", - "quoted_printable", - "socket2 0.4.10", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "libc" -version = "0.2.172" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" - -[[package]] -name = "libsqlite3-sys" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "litemap" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" - -[[package]] -name = "local-channel" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] - -[[package]] -name = "local-waker" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" - -[[package]] -name = "lock_api" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" -dependencies = [ - "log", - "phf 0.10.1", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_cfg" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata 0.1.10", -] - -[[package]] -name = "memchr" -version = "2.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" -dependencies = [ - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", -] - -[[package]] -name = "mutually_exclusive_features" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94e1e6445d314f972ff7395df2de295fe51b71821694f0b0e1e79c4f12c8577" - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" - -[[package]] -name = "openssl" -version = "0.10.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" -dependencies = [ - "bitflags 2.6.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "ordered-multimap" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" -dependencies = [ - "dlv-list", - "hashbrown 0.12.3", -] - -[[package]] -name = "os_info" -version = "3.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a604e53c24761286860eba4e2c8b23a0161526476b1de520139d69cdb85a6b5" -dependencies = [ - "log", - "serde", - "windows-sys 0.52.0", -] - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "parking_lot" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pest" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" -dependencies = [ - "memchr", - "thiserror 2.0.12", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "pest_meta" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" -dependencies = [ - "once_cell", - "pest", - "sha2", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_shared 0.10.0", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.1", -] - -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro2" -version = "1.0.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "quoted_printable" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49" - -[[package]] -name = "r-efi" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.15", -] - -[[package]] -name = "redox_syscall" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" -dependencies = [ - "bitflags 2.6.0", -] - -[[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-lite" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" - -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" - -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-tls 0.5.0", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile 1.0.4", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - -[[package]] -name = "reqwest" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "hyper 1.6.0", - "hyper-tls 0.6.0", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile 2.2.0", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio", - "tokio-native-tls", - "tower", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-registry", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.15", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "ron" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" -dependencies = [ - "base64 0.13.1", - "bitflags 1.3.2", - "serde", -] - -[[package]] -name = "rusqlite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2" -dependencies = [ - "bitflags 2.6.0", - "chrono", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rust-ini" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" -dependencies = [ - "bitflags 2.6.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustls" -version = "0.23.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "rustls-pki-types" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" - -[[package]] -name = "rustls-webpki" -version = "0.103.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - -[[package]] -name = "ryu" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" - -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scraper" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" -dependencies = [ - "ahash 0.8.11", - "cssparser", - "ego-tree", - "getopts", - "html5ever", - "once_cell", - "selectors", - "tendril", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.6.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" -dependencies = [ - "bitflags 2.6.0", - "cssparser", - "derive_more", - "fxhash", - "log", - "new_debug_unreachable", - "phf 0.10.1", - "phf_codegen", - "precomputed-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" - -[[package]] -name = "sentry" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "255914a8e53822abd946e2ce8baa41d4cded6b8e938913b7f7b9da5b7ab44335" -dependencies = [ - "httpdate", - "native-tls", - "reqwest 0.12.15", - "sentry-backtrace", - "sentry-contexts", - "sentry-core", - "sentry-debug-images", - "sentry-log", - "sentry-panic", - "sentry-tracing", - "tokio", - "ureq", -] - -[[package]] -name = "sentry-backtrace" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00293cd332a859961f24fd69258f7e92af736feaeb91020cff84dac4188a4302" -dependencies = [ - "backtrace", - "once_cell", - "regex", - "sentry-core", -] - -[[package]] -name = "sentry-contexts" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961990f9caa76476c481de130ada05614cd7f5aa70fb57c2142f0e09ad3fb2aa" -dependencies = [ - "hostname 0.4.1", - "libc", - "os_info", - "rustc_version", - "sentry-core", - "uname", -] - -[[package]] -name = "sentry-core" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a6409d845707d82415c800290a5d63be5e3df3c2e417b0997c60531dfbd35ef" -dependencies = [ - "once_cell", - "rand", - "sentry-types", - "serde", - "serde_json", -] - -[[package]] -name = "sentry-debug-images" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ab5df4f3b64760508edfe0ba4290feab5acbbda7566a79d72673065888e5cc" -dependencies = [ - "findshlibs", - "once_cell", - "sentry-core", -] - -[[package]] -name = "sentry-log" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693841da8dfb693af29105edfbea1d91348a13d23dd0a5d03761eedb9e450c46" -dependencies = [ - "log", - "sentry-core", -] - -[[package]] -name = "sentry-panic" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "609b1a12340495ce17baeec9e08ff8ed423c337c1a84dffae36a178c783623f3" -dependencies = [ - "sentry-backtrace", - "sentry-core", -] - -[[package]] -name = "sentry-tracing" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f4e86402d5c50239dc7d8fd3f6d5e048221d5fcb4e026d8d50ab57fe4644cb" -dependencies = [ - "sentry-backtrace", - "sentry-core", - "tracing-core", - "tracing-subscriber", -] - -[[package]] -name = "sentry-types" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3f117b8755dbede8260952de2aeb029e20f432e72634e8969af34324591631" -dependencies = [ - "debugid", - "hex", - "rand", - "serde", - "serde_json", - "thiserror 1.0.69", - "time", - "url", - "uuid", -] - -[[package]] -name = "serde" -version = "1.0.216" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.216" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "serde_json" -version = "1.0.134" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "servo_arc" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" -dependencies = [ - "libc", -] - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - -[[package]] -name = "slab" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "socket2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "socket2" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ae51629bf965c5c098cc9e87908a3df5301051a9e087d6f9bef5c9771ed126" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" -dependencies = [ - "fastrand 2.3.0", - "getrandom 0.3.2", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" -dependencies = [ - "thiserror-impl 2.0.12", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "thread_local" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" -dependencies = [ - "cfg-if", - "once_cell", -] - -[[package]] -name = "time" -version = "0.3.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" - -[[package]] -name = "time-macros" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.5.9", - "windows-sys 0.52.0", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper 1.0.2", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-actix-web" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2340b7722695166c7fc9b3e3cd1166e7c74fedb9075b8f0c74d3822d2e41caf5" -dependencies = [ - "actix-web", - "mutually_exclusive_features", - "pin-project", - "tracing", - "uuid", -] - -[[package]] -name = "tracing-appender" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" -dependencies = [ - "crossbeam-channel", - "thiserror 1.0.69", - "time", - "tracing-subscriber", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "tracing-bunyan-formatter" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d637245a0d8774bd48df6482e086c59a8b5348a910c3b0579354045a9d82411" -dependencies = [ - "ahash 0.8.11", - "gethostname", - "log", - "serde", - "serde_json", - "time", - "tracing", - "tracing-core", - "tracing-log 0.1.4", - "tracing-subscriber", -] - -[[package]] -name = "tracing-core" -version = "0.1.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log 0.2.0", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "uname" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" -dependencies = [ - "libc", -] - -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-ident" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" - -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "native-tls", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "url", - "webpki-roots", -] - -[[package]] -name = "url" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" -dependencies = [ - "form_urlencoded", - "idna 1.0.3", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" -dependencies = [ - "getrandom 0.2.15", - "serde", -] - -[[package]] -name = "v_htmlescape" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" - -[[package]] -name = "validator" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" -dependencies = [ - "idna 0.4.0", - "lazy_static", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" -dependencies = [ - "if_chain", - "lazy_static", - "proc-macro-error", - "proc-macro2", - "quote", - "regex", - "syn 1.0.109", - "validator_types", -] - -[[package]] -name = "validator_types" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" -dependencies = [ - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.92", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.26.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37493cadf42a2a939ed404698ded7fb378bf301b5011f973361779a3a74f8c93" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.61.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings 0.4.0", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "windows-link" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" - -[[package]] -name = "windows-registry" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" -dependencies = [ - "windows-result", - "windows-strings 0.3.1", - "windows-targets 0.53.0", -] - -[[package]] -name = "windows-result" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" -dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.6.0", -] - -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" - -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - -[[package]] -name = "yoke" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "byteorder", - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "zerofrom" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zerovec" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.92", -] - -[[package]] -name = "zstd" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.13+zstd.1.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 158bef6..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[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" -tokio = "1.45.0" diff --git a/Dockerfile b/Dockerfile index df502e7..e509b31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,51 +1,28 @@ -# 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 - -# Expose port -EXPOSE 8080 - -# Set proper permissions -RUN chown -R nobody:nogroup /app -USER nobody - -# Run the application -CMD ["./formies-be"] \ No newline at end of file +CMD ["node", "server.js"] \ No newline at end of file diff --git a/RATE_LIMITING.md b/RATE_LIMITING.md new file mode 100644 index 0000000..2d465a2 --- /dev/null +++ b/RATE_LIMITING.md @@ -0,0 +1,164 @@ +# 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 diff --git a/README.md b/README.md deleted file mode 100644 index 250f569..0000000 --- a/README.md +++ /dev/null @@ -1,163 +0,0 @@ -# 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 -- `CAPTCHA_ENABLED`: Enable CAPTCHA verification for public form submissions (`true` or `false`, default: `false`) -- `CAPTCHA_SECRET_KEY`: The secret key provided by your CAPTCHA service (e.g., hCaptcha, reCAPTCHA) -- `CAPTCHA_VERIFICATION_URL`: The verification endpoint URL for your CAPTCHA service (e.g., `https://hcaptcha.com/siteverify`) - -## Development - -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 - -### Form Submission Security - -The public form submission endpoint (`/api/forms/{form_id}/submissions`) includes several security measures: - -- **Global Rate Limiting:** The overall number of requests to the API is limited. -- **Per-Form, Per-IP Rate Limiting:** Limits the number of submissions one IP address can make to a specific form within a time window (e.g., 5 submissions per minute). Configurable in code. -- **CAPTCHA Verification:** If enabled via environment variables (`CAPTCHA_ENABLED=true`), requires a valid CAPTCHA token (e.g., from hCaptcha, reCAPTCHA, Turnstile) to be sent in the `captcha_token` field of the submission payload. The backend verifies this token with the configured provider. -- **Payload Size Limit:** The maximum size of the submission payload is limited (e.g., 1MB) to prevent DoS attacks. Configurable in code. -- **Input Validation:** Submission data is validated against the specific form's field definitions (type, required, length, pattern, etc.). -- **Notification Throttling:** Limits the rate at which notifications (Email, Ntfy) are sent per form to prevent spamming channels (e.g., max 1 per minute). Configurable in code. - -## License - -MIT diff --git a/combined.log b/combined.log new file mode 100644 index 0000000..a62a603 --- /dev/null +++ b/combined.log @@ -0,0 +1,69 @@ +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: / - Method: GET - IP: ::1","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /favicon.ico - Method: GET - IP: ::1","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: / - Method: GET - IP: ::1","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /login - Method: GET - IP: ::1","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /dashboard.html - Method: GET - IP: ::1","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"} +{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Database file found.","service":"user-service"} +{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"} +{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"} +{"level":"info","message":"Received SIGINT, shutting down gracefully...","service":"user-service"} diff --git a/config/default.toml b/config/default.toml deleted file mode 100644 index 8100cb5..0000000 --- a/config/default.toml +++ /dev/null @@ -1,30 +0,0 @@ -[server] -bind_address = "127.0.0.1:8080" -workers = 4 -keep_alive = 60 -client_timeout = 5000 -client_shutdown = 5000 - -[database] -url = "form_data.db" -pool_size = 5 -connection_timeout = 30 - -[security] -rate_limit_requests = 100 -rate_limit_interval = 60 -allowed_origins = ["http://localhost:5173"] -jwt_secret = "your-secret-key" -jwt_expiration = 3600 - -[logging] -level = "info" -format = "json" -file = "logs/app.log" -max_size = 10485760 # 10MB -max_files = 5 - -[monitoring] -sentry_dsn = "" -enable_metrics = true -metrics_port = 9090 \ No newline at end of file diff --git a/config/logger.js b/config/logger.js new file mode 100644 index 0000000..180bc95 --- /dev/null +++ b/config/logger.js @@ -0,0 +1,29 @@ +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; diff --git a/design.html b/design.html deleted file mode 100644 index cdf8d88..0000000 --- a/design.html +++ /dev/null @@ -1,1294 +0,0 @@ - - - - - - FormCraft - Scandinavian Industrial Form Management - - - - -
-
- - -
-
- - - - - 3 -
-
JD
-
-
-
- - -
-
- - -
-

Dashboard Overview

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

- - - - - - - - Recent Forms -

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

- - - - - - - - - Recent Submissions -

- - View All Submissions - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Form NameSubmitted byDateStatusActions
Customer Feedback Q2john.doe@example.comMay 05, 2025
New
- -
Annual Conf Registrationsarah.smith@example.comMay 04, 2025
Pending
- -
Customer Feedback Q2mark.rivera@sample.netMay 03, 2025
Reviewed
- -
-
-
-
- -
- - - - - diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..5413ef8 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,68 @@ +version: "3.8" + +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 + + 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 + + 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 + +volumes: + mysql_data: + redis_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..771f137 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +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: diff --git a/error.log b/error.log new file mode 100644 index 0000000..e69de29 diff --git a/form_data.db b/form_data.db deleted file mode 100644 index a2884b8..0000000 Binary files a/form_data.db and /dev/null differ diff --git a/formies.sqlite b/formies.sqlite new file mode 100644 index 0000000..e69de29 diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 74f4b26..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,220 +0,0 @@ - - - - - - Formies - - - - - - -
- -
- -

Formies - Simple Form Manager

- - -
-

Login

-
-
- - -
-
- - -
- - -
-
- - - - - -
-
-

Submit to a Form

-

Enter a Form ID to load and submit:

-
- - - -
- - -
-
- - - - - - - - - - diff --git a/frontend/script.js b/frontend/script.js deleted file mode 100644 index 210a7a9..0000000 --- a/frontend/script.js +++ /dev/null @@ -1,575 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - // --- Configuration --- - const API_BASE_URL = "http://localhost:8080/api"; // Assuming backend serves API under /api - - // --- State --- - let authToken = sessionStorage.getItem("authToken"); // Use sessionStorage for non-persistent login - - // --- DOM Elements --- - const loginSection = document.getElementById("login-section"); - const adminSection = document.getElementById("admin-section"); - const loginForm = document.getElementById("login-form"); - const usernameInput = document.getElementById("username"); - const passwordInput = document.getElementById("password"); - const logoutButton = document.getElementById("logout-button"); - const statusArea = document.getElementById("status-area"); - const loggedInUserSpan = document.getElementById("logged-in-user"); // Added this if needed - - const createForm = document.getElementById("create-form"); - const formNameInput = document.getElementById("form-name"); - - const loadFormsButton = document.getElementById("load-forms-button"); - const formsList = document.getElementById("forms-list"); - - const submissionsSection = document.getElementById("submissions-section"); - const submissionsList = document.getElementById("submissions-list"); - const submissionsFormNameSpan = document.getElementById( - "submissions-form-name" - ); - - const publicFormIdInput = document.getElementById("public-form-id-input"); - const loadPublicFormButton = document.getElementById( - "load-public-form-button" - ); - const publicFormArea = document.getElementById("public-form-area"); - const publicFormTitle = document.getElementById("public-form-title"); - const publicForm = document.getElementById("public-form"); - - // --- Helper Functions --- - function showStatus(message, isError = false) { - statusArea.textContent = message; - statusArea.className = "status"; // Reset classes - if (message) { - statusArea.classList.add(isError ? "error" : "success"); - } - } - - function toggleSections() { - console.log("toggleSections called. Current authToken:", authToken); // Log 3 - if (authToken) { - console.log("AuthToken found, showing admin section."); // Log 4 - loginSection.classList.add("hidden"); - adminSection.classList.remove("hidden"); - // Optionally display username if you fetch it after login - // loggedInUserSpan.textContent = 'Admin'; // Placeholder - } else { - console.log("AuthToken not found, showing login section."); // Log 5 - loginSection.classList.remove("hidden"); - adminSection.classList.add("hidden"); - submissionsSection.classList.add("hidden"); // Hide submissions when logged out - } - // Always hide public form initially on state change - publicFormArea.classList.add("hidden"); - publicForm.innerHTML = ''; // Reset form content - } - - async function makeApiRequest( - endpoint, - method = "GET", - body = null, - requiresAuth = false - ) { - const url = `${API_BASE_URL}${endpoint}`; - const headers = { - "Content-Type": "application/json", - Accept: "application/json", - }; - - if (requiresAuth) { - if (!authToken) { - throw new Error("Authentication required, but no token found."); - } - headers["Authorization"] = `Bearer ${authToken}`; - } - - const options = { - method, - headers, - }; - - if (body) { - options.body = JSON.stringify(body); - } - - try { - const response = await fetch(url, options); - - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); // Try to parse error JSON - } catch (e) { - // If response is not JSON - errorData = { - message: `HTTP Error: ${response.status} ${response.statusText}`, - }; - } - // Check for backend's validation error structure - if (errorData && errorData.validation_errors) { - throw { validationErrors: errorData.validation_errors }; - } - // Throw a more generic error message or the one from backend if available - throw new Error( - errorData.message || `Request failed with status ${response.status}` - ); - } - - // Handle responses with no content (e.g., logout) - if ( - response.status === 204 || - response.headers.get("content-length") === "0" - ) { - return null; // Or return an empty object/success indicator - } - - return await response.json(); // Parse successful JSON response - } catch (error) { - console.error(`API Request Error (${method} ${endpoint}):`, error); - // Re-throw validation errors specifically if they exist - if (error.validationErrors) { - throw error; - } - // Re-throw other errors - throw new Error(error.message || "Network error or failed to fetch"); - } - } - - // --- Event Handlers --- - loginForm.addEventListener("submit", async (e) => { - e.preventDefault(); - showStatus(""); // Clear previous status - const username = usernameInput.value.trim(); - const password = passwordInput.value.trim(); - - if (!username || !password) { - showStatus("Username and password are required.", true); - return; - } - - try { - const data = await makeApiRequest("/login", "POST", { - username, - password, - }); - if (data && data.token) { - console.log("Login successful, received token:", data.token); // Log 1 - authToken = data.token; - sessionStorage.setItem("authToken", authToken); // Store token - console.log("Calling toggleSections after login..."); // Log 2 - toggleSections(); - showStatus("Login successful!"); - usernameInput.value = ""; // Clear fields - passwordInput.value = ""; - } else { - throw new Error("Login failed: No token received."); - } - } catch (error) { - showStatus(`Login failed: ${error.message}`, true); - authToken = null; - sessionStorage.removeItem("authToken"); - toggleSections(); - } - }); - - logoutButton.addEventListener("click", async () => { - showStatus(""); - if (!authToken) return; - - try { - await makeApiRequest("/logout", "POST", null, true); - showStatus("Logout successful!"); - } catch (error) { - showStatus(`Logout failed: ${error.message}`, true); - // Decide if you still want to clear local state even if server fails - // Forcing logout locally might be better UX in case of server error - } finally { - // Always clear local state on logout attempt - authToken = null; - sessionStorage.removeItem("authToken"); - toggleSections(); - } - }); - - if (createForm) { - createForm.addEventListener("submit", async (e) => { - e.preventDefault(); - showStatus(""); - const formName = formNameInput.value.trim(); - if (!formName) { - showStatus("Please enter a form name", true); - return; - } - - try { - // Refactor to use makeApiRequest - const data = await makeApiRequest( - "/forms", // Endpoint relative to API_BASE_URL - "POST", - // TODO: Need a way to define form fields in the UI. - // Sending minimal structure for now. - { name: formName, fields: [] }, - true // Requires authentication - ); - - if (!data || !data.id) { - throw new Error( - "Failed to create form or received invalid response." - ); - } - - showStatus( - `Form '${data.name}' created successfully! (ID: ${data.id})`, - "success" - ); - formNameInput.value = ""; - // Automatically refresh the forms list after creation - if (loadFormsButton) { - loadFormsButton.click(); - } - } catch (error) { - showStatus(`Error creating form: ${error.message}`, true); - } - }); - } - - // Ensure createFormFromUrl exists before adding listener - const createFormFromUrlEl = document.getElementById("create-form-from-url"); - if (createFormFromUrlEl) { - // Check if the element exists - const formNameUrlInput = document.getElementById("form-name-url"); - const formUrlInput = document.getElementById("form-url"); - - createFormFromUrlEl.addEventListener("submit", async (e) => { - e.preventDefault(); - showStatus(""); - const name = formNameUrlInput.value.trim(); - const url = formUrlInput.value.trim(); - - if (!name || !url) { - showStatus("Form name and URL are required.", true); - return; - } - - try { - const newForm = await makeApiRequest( - "/forms/from-url", - "POST", - { name, url }, - true - ); - showStatus( - `Form '${newForm.name}' created successfully with ID: ${newForm.id}` - ); - formNameUrlInput.value = ""; // Clear form - formUrlInput.value = ""; - loadFormsButton.click(); // Refresh the forms list - } catch (error) { - showStatus(`Failed to create form from URL: ${error.message}`, true); - } - }); - } - - if (loadFormsButton) { - loadFormsButton.addEventListener("click", async () => { - showStatus(""); - submissionsSection.classList.add("hidden"); // Hide submissions when reloading forms - formsList.innerHTML = "
  • Loading...
  • "; // Indicate loading - - try { - const forms = await makeApiRequest("/forms", "GET", null, true); - formsList.innerHTML = ""; // Clear list - - if (forms && forms.length > 0) { - forms.forEach((form) => { - const li = document.createElement("li"); - li.textContent = `${form.name} (ID: ${form.id})`; - - const viewSubmissionsButton = document.createElement("button"); - viewSubmissionsButton.textContent = "View Submissions"; - viewSubmissionsButton.onclick = () => - loadSubmissions(form.id, form.name); - - li.appendChild(viewSubmissionsButton); - formsList.appendChild(li); - }); - } else { - formsList.innerHTML = "
  • No forms found.
  • "; - } - } catch (error) { - showStatus(`Failed to load forms: ${error.message}`, true); - formsList.innerHTML = "
  • Error loading forms.
  • "; - } - }); - } - - async function loadSubmissions(formId, formName) { - showStatus(""); - submissionsList.innerHTML = "
  • Loading submissions...
  • "; - submissionsFormNameSpan.textContent = `${formName} (ID: ${formId})`; - submissionsSection.classList.remove("hidden"); - - try { - const submissions = await makeApiRequest( - `/forms/${formId}/submissions`, - "GET", - null, - true - ); - submissionsList.innerHTML = ""; // Clear list - - if (submissions && submissions.length > 0) { - submissions.forEach((sub) => { - const li = document.createElement("li"); - // Display submission data safely - avoid rendering raw HTML - const pre = document.createElement("pre"); - pre.textContent = JSON.stringify(sub.data, null, 2); // Pretty print JSON - li.appendChild(pre); - // Optionally display submission ID and timestamp if available - // const info = document.createElement('small'); - // info.textContent = `ID: ${sub.id}, Submitted: ${sub.created_at || 'N/A'}`; - // li.appendChild(info); - - submissionsList.appendChild(li); - }); - } else { - submissionsList.innerHTML = - "
  • No submissions found for this form.
  • "; - } - } catch (error) { - showStatus( - `Failed to load submissions for form ${formId}: ${error.message}`, - true - ); - submissionsList.innerHTML = "
  • Error loading submissions.
  • "; - submissionsSection.classList.add("hidden"); // Hide section on error - } - } - - // --- Public Form Handling --- - - if (loadPublicFormButton) { - loadPublicFormButton.addEventListener("click", async () => { - const formId = publicFormIdInput.value.trim(); - if (!formId) { - showStatus("Please enter a Form ID.", true); - return; - } - showStatus(""); - publicFormArea.classList.add("hidden"); - publicForm.innerHTML = "Loading form..."; // Clear previous form - - // NOTE: Fetching form definition is NOT directly possible with the current backend - // The backend only provides GET /forms (all, protected) and GET /forms/{id}/submissions (protected) - // It DOES NOT provide a public GET /forms/{id} endpoint to fetch the definition. - // - // **WORKAROUND:** We will *assume* the user knows the structure or we have it cached/predefined. - // For this example, we'll fetch *all* forms (if logged in) and find it, OR fail if not logged in. - // A *better* backend design would include a public GET /forms/{id} endpoint. - - try { - // Attempt to get the form definition (requires login for this workaround) - if (!authToken) { - showStatus( - "Loading public forms requires login in this demo version.", - true - ); - publicForm.innerHTML = ""; // Clear loading message - return; - } - const forms = await makeApiRequest("/forms", "GET", null, true); - const formDefinition = forms.find((f) => f.id === formId); - - if (!formDefinition) { - throw new Error(`Form with ID ${formId} not found or access denied.`); - } - - renderPublicForm(formDefinition); - publicFormArea.classList.remove("hidden"); - } catch (error) { - showStatus(`Failed to load form ${formId}: ${error.message}`, true); - publicForm.innerHTML = ""; // Clear loading message - publicFormArea.classList.add("hidden"); - } - }); - } - - function renderPublicForm(formDefinition) { - publicFormTitle.textContent = formDefinition.name; - publicForm.innerHTML = ""; // Clear previous fields - publicForm.dataset.formId = formDefinition.id; // Store form ID for submission - - if (!formDefinition.fields || !Array.isArray(formDefinition.fields)) { - publicForm.innerHTML = "

    Error: Form definition is invalid.

    "; - console.error("Invalid form fields definition:", formDefinition.fields); - return; - } - - formDefinition.fields.forEach((field) => { - const div = document.createElement("div"); - const label = document.createElement("label"); - label.htmlFor = `field-${field.name}`; - label.textContent = field.label || field.name; // Use label, fallback to name - div.appendChild(label); - - let input; - // Basic type handling - could be expanded - switch (field.type) { - case "textarea": // Allow explicit textarea type - case "string": - // Use textarea for string if maxLength suggests it might be long - if (field.maxLength && field.maxLength > 100) { - input = document.createElement("textarea"); - input.rows = 4; // Default rows - } else { - input = document.createElement("input"); - input.type = "text"; - } - if (field.minLength) input.minLength = field.minLength; - if (field.maxLength) input.maxLength = field.maxLength; - break; - case "email": - input = document.createElement("input"); - input.type = "email"; - break; - case "url": - input = document.createElement("input"); - input.type = "url"; - break; - case "number": - input = document.createElement("input"); - input.type = "number"; - if (field.min !== undefined) input.min = field.min; - if (field.max !== undefined) input.max = field.max; - input.step = field.step || "any"; // Allow decimals by default - break; - case "boolean": - input = document.createElement("input"); - input.type = "checkbox"; - // Checkbox label handling is slightly different - label.insertBefore(input, label.firstChild); // Put checkbox before text - input.style.width = "auto"; // Override default width - input.style.marginRight = "10px"; - break; - // Add cases for 'select', 'radio', 'date' etc. if needed - default: - input = document.createElement("input"); - input.type = "text"; - console.warn( - `Unsupported field type "${field.type}" for field "${field.name}". Rendering as text.` - ); - } - - if (input.type !== "checkbox") { - // Checkbox is already appended inside label - div.appendChild(input); - } - input.id = `field-${field.name}`; - input.name = field.name; // Crucial for form data collection - if (field.required) input.required = true; - if (field.placeholder) input.placeholder = field.placeholder; - if (field.pattern) input.pattern = field.pattern; // Add regex pattern validation - - publicForm.appendChild(div); - }); - - const submitButton = document.createElement("button"); - submitButton.type = "submit"; - submitButton.textContent = "Submit Form"; - publicForm.appendChild(submitButton); - } - - publicForm.addEventListener("submit", async (e) => { - e.preventDefault(); - showStatus(""); - const formId = e.target.dataset.formId; - if (!formId) { - showStatus("Error: Form ID is missing.", true); - return; - } - - const formData = new FormData(e.target); - const submissionData = {}; - - // Convert FormData to a plain object, handling checkboxes correctly - for (const [key, value] of formData.entries()) { - const inputElement = e.target.elements[key]; - - // Handle Checkboxes (boolean) - if (inputElement && inputElement.type === "checkbox") { - // A checkbox value is only present in FormData if it's checked. - // We need to ensure we always send a boolean. - // Check if the element exists in the form (it might be unchecked) - submissionData[key] = inputElement.checked; - } - // Handle Number inputs (convert from string) - else if (inputElement && inputElement.type === "number") { - // Only convert if the value is not empty, otherwise send null or handle as needed - if (value !== "") { - submissionData[key] = parseFloat(value); // Or parseInt if only integers allowed - if (isNaN(submissionData[key])) { - // Handle potential parsing errors if input validation fails - console.warn(`Could not parse number for field ${key}: ${value}`); - submissionData[key] = null; // Or keep as string, or show error - } - } else { - submissionData[key] = null; // Or undefined, depending on backend expectation for empty numbers - } - } - // Handle potential multiple values for the same name (e.g., multi-select), though not rendered here - else if (submissionData.hasOwnProperty(key)) { - if (!Array.isArray(submissionData[key])) { - submissionData[key] = [submissionData[key]]; - } - submissionData[key].push(value); - } - // Default: treat as string - else { - submissionData[key] = value; - } - } - - // Ensure boolean fields that were *unchecked* are explicitly set to false - // FormData only includes checked checkboxes. Find all checkbox inputs in the form. - const checkboxes = e.target.querySelectorAll('input[type="checkbox"]'); - checkboxes.forEach((cb) => { - if (!submissionData.hasOwnProperty(cb.name)) { - submissionData[cb.name] = false; // Set unchecked boxes to false - } - }); - - console.log("Submitting data:", submissionData); // Debugging - - try { - // Public submission endpoint doesn't require auth - const result = await makeApiRequest( - `/forms/${formId}/submissions`, - "POST", - submissionData, - false - ); - showStatus( - `Submission successful! Submission ID: ${result.submission_id}` - ); - e.target.reset(); // Clear the form - // Optionally hide the form after successful submission - // publicFormArea.classList.add('hidden'); - } catch (error) { - let errorMsg = `Submission failed: ${error.message}`; - // Handle validation errors specifically - if (error.validationErrors) { - errorMsg = "Submission failed due to validation errors:\n"; - for (const [field, message] of Object.entries(error.validationErrors)) { - errorMsg += `- ${field}: ${message}\n`; - } - // Highlight invalid fields? (More complex UI update) - } - showStatus(errorMsg, true); - } - }); - - // --- Initial Setup --- - toggleSections(); // Set initial view based on stored token - if (authToken) { - loadFormsButton.click(); // Auto-load forms if logged in - } -}); 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 - - - - -
    -
    - - -
    -
    - - - - - 3 -
    -
    JD
    -
    -
    -
    - - -
    -
    - - -
    -

    Dashboard Overview

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

    - - - - - - - - Recent Forms -

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

    - - - - - - - - - Recent Submissions -

    - - View All Submissions - -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Form NameSubmitted byDateStatusActions
    Customer Feedback Q2john.doe@example.comMay 05, 2025
    New
    - -
    Annual Conf Registrationsarah.smith@example.comMay 04, 2025
    Pending
    - -
    Customer Feedback Q2mark.rivera@sample.netMay 03, 2025
    Reviewed
    - -
    -
    -
    -
    - -
    - - - - - + +# 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

    -
    -
    - - -
    -
    - - -
    - - -
    -
    - - - - - -
    -
    -

    Submit to a Form

    -

    Enter a Form ID to load and submit:

    -
    - - - -
    - - -
    -
    - - - - - - - - - - -
    - - -document.addEventListener("DOMContentLoaded", () => { - // --- Configuration --- - const API_BASE_URL = "http://localhost:8080/api"; // Assuming backend serves API under /api - - // --- State --- - let authToken = sessionStorage.getItem("authToken"); // Use sessionStorage for non-persistent login - - // --- DOM Elements --- - const loginSection = document.getElementById("login-section"); - const adminSection = document.getElementById("admin-section"); - const loginForm = document.getElementById("login-form"); - const usernameInput = document.getElementById("username"); - const passwordInput = document.getElementById("password"); - const logoutButton = document.getElementById("logout-button"); - const statusArea = document.getElementById("status-area"); - const loggedInUserSpan = document.getElementById("logged-in-user"); // Added this if needed - - const createForm = document.getElementById("create-form"); - const formNameInput = document.getElementById("form-name"); - - const loadFormsButton = document.getElementById("load-forms-button"); - const formsList = document.getElementById("forms-list"); - - const submissionsSection = document.getElementById("submissions-section"); - const submissionsList = document.getElementById("submissions-list"); - const submissionsFormNameSpan = document.getElementById( - "submissions-form-name" - ); - - const publicFormIdInput = document.getElementById("public-form-id-input"); - const loadPublicFormButton = document.getElementById( - "load-public-form-button" - ); - const publicFormArea = document.getElementById("public-form-area"); - const publicFormTitle = document.getElementById("public-form-title"); - const publicForm = document.getElementById("public-form"); - - // --- Helper Functions --- - function showStatus(message, isError = false) { - statusArea.textContent = message; - statusArea.className = "status"; // Reset classes - if (message) { - statusArea.classList.add(isError ? "error" : "success"); - } - } - - function toggleSections() { - console.log("toggleSections called. Current authToken:", authToken); // Log 3 - if (authToken) { - console.log("AuthToken found, showing admin section."); // Log 4 - loginSection.classList.add("hidden"); - adminSection.classList.remove("hidden"); - // Optionally display username if you fetch it after login - // loggedInUserSpan.textContent = 'Admin'; // Placeholder - } else { - console.log("AuthToken not found, showing login section."); // Log 5 - loginSection.classList.remove("hidden"); - adminSection.classList.add("hidden"); - submissionsSection.classList.add("hidden"); // Hide submissions when logged out - } - // Always hide public form initially on state change - publicFormArea.classList.add("hidden"); - publicForm.innerHTML = ''; // Reset form content - } - - async function makeApiRequest( - endpoint, - method = "GET", - body = null, - requiresAuth = false - ) { - const url = `${API_BASE_URL}${endpoint}`; - const headers = { - "Content-Type": "application/json", - Accept: "application/json", - }; - - if (requiresAuth) { - if (!authToken) { - throw new Error("Authentication required, but no token found."); - } - headers["Authorization"] = `Bearer ${authToken}`; - } - - const options = { - method, - headers, - }; - - if (body) { - options.body = JSON.stringify(body); - } - - try { - const response = await fetch(url, options); - - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); // Try to parse error JSON - } catch (e) { - // If response is not JSON - errorData = { - message: `HTTP Error: ${response.status} ${response.statusText}`, - }; - } - // Check for backend's validation error structure - if (errorData && errorData.validation_errors) { - throw { validationErrors: errorData.validation_errors }; - } - // Throw a more generic error message or the one from backend if available - throw new Error( - errorData.message || `Request failed with status ${response.status}` - ); - } - - // Handle responses with no content (e.g., logout) - if ( - response.status === 204 || - response.headers.get("content-length") === "0" - ) { - return null; // Or return an empty object/success indicator - } - - return await response.json(); // Parse successful JSON response - } catch (error) { - console.error(`API Request Error (${method} ${endpoint}):`, error); - // Re-throw validation errors specifically if they exist - if (error.validationErrors) { - throw error; - } - // Re-throw other errors - throw new Error(error.message || "Network error or failed to fetch"); - } - } - - // --- Event Handlers --- - loginForm.addEventListener("submit", async (e) => { - e.preventDefault(); - showStatus(""); // Clear previous status - const username = usernameInput.value.trim(); - const password = passwordInput.value.trim(); - - if (!username || !password) { - showStatus("Username and password are required.", true); - return; - } - - try { - const data = await makeApiRequest("/login", "POST", { - username, - password, - }); - if (data && data.token) { - console.log("Login successful, received token:", data.token); // Log 1 - authToken = data.token; - sessionStorage.setItem("authToken", authToken); // Store token - console.log("Calling toggleSections after login..."); // Log 2 - toggleSections(); - showStatus("Login successful!"); - usernameInput.value = ""; // Clear fields - passwordInput.value = ""; - } else { - throw new Error("Login failed: No token received."); - } - } catch (error) { - showStatus(`Login failed: ${error.message}`, true); - authToken = null; - sessionStorage.removeItem("authToken"); - toggleSections(); - } - }); - - logoutButton.addEventListener("click", async () => { - showStatus(""); - if (!authToken) return; - - try { - await makeApiRequest("/logout", "POST", null, true); - showStatus("Logout successful!"); - } catch (error) { - showStatus(`Logout failed: ${error.message}`, true); - // Decide if you still want to clear local state even if server fails - // Forcing logout locally might be better UX in case of server error - } finally { - // Always clear local state on logout attempt - authToken = null; - sessionStorage.removeItem("authToken"); - toggleSections(); - } - }); - - if (createForm) { - createForm.addEventListener("submit", async (e) => { - e.preventDefault(); - showStatus(""); - const formName = formNameInput.value.trim(); - if (!formName) { - showStatus("Please enter a form name", true); - return; - } - - try { - // Refactor to use makeApiRequest - const data = await makeApiRequest( - "/forms", // Endpoint relative to API_BASE_URL - "POST", - // TODO: Need a way to define form fields in the UI. - // Sending minimal structure for now. - { name: formName, fields: [] }, - true // Requires authentication - ); - - if (!data || !data.id) { - throw new Error( - "Failed to create form or received invalid response." - ); - } - - showStatus( - `Form '${data.name}' created successfully! (ID: ${data.id})`, - "success" - ); - formNameInput.value = ""; - // Automatically refresh the forms list after creation - if (loadFormsButton) { - loadFormsButton.click(); - } - } catch (error) { - showStatus(`Error creating form: ${error.message}`, true); - } - }); - } - - // Ensure createFormFromUrl exists before adding listener - const createFormFromUrlEl = document.getElementById("create-form-from-url"); - if (createFormFromUrlEl) { - // Check if the element exists - const formNameUrlInput = document.getElementById("form-name-url"); - const formUrlInput = document.getElementById("form-url"); - - createFormFromUrlEl.addEventListener("submit", async (e) => { - e.preventDefault(); - showStatus(""); - const name = formNameUrlInput.value.trim(); - const url = formUrlInput.value.trim(); - - if (!name || !url) { - showStatus("Form name and URL are required.", true); - return; - } - - try { - const newForm = await makeApiRequest( - "/forms/from-url", - "POST", - { name, url }, - true - ); - showStatus( - `Form '${newForm.name}' created successfully with ID: ${newForm.id}` - ); - formNameUrlInput.value = ""; // Clear form - formUrlInput.value = ""; - loadFormsButton.click(); // Refresh the forms list - } catch (error) { - showStatus(`Failed to create form from URL: ${error.message}`, true); - } - }); - } - - if (loadFormsButton) { - loadFormsButton.addEventListener("click", async () => { - showStatus(""); - submissionsSection.classList.add("hidden"); // Hide submissions when reloading forms - formsList.innerHTML = "
  • Loading...
  • "; // Indicate loading - - try { - const forms = await makeApiRequest("/forms", "GET", null, true); - formsList.innerHTML = ""; // Clear list - - if (forms && forms.length > 0) { - forms.forEach((form) => { - const li = document.createElement("li"); - li.textContent = `${form.name} (ID: ${form.id})`; - - const viewSubmissionsButton = document.createElement("button"); - viewSubmissionsButton.textContent = "View Submissions"; - viewSubmissionsButton.onclick = () => - loadSubmissions(form.id, form.name); - - li.appendChild(viewSubmissionsButton); - formsList.appendChild(li); - }); - } else { - formsList.innerHTML = "
  • No forms found.
  • "; - } - } catch (error) { - showStatus(`Failed to load forms: ${error.message}`, true); - formsList.innerHTML = "
  • Error loading forms.
  • "; - } - }); - } - - async function loadSubmissions(formId, formName) { - showStatus(""); - submissionsList.innerHTML = "
  • Loading submissions...
  • "; - submissionsFormNameSpan.textContent = `${formName} (ID: ${formId})`; - submissionsSection.classList.remove("hidden"); - - try { - const submissions = await makeApiRequest( - `/forms/${formId}/submissions`, - "GET", - null, - true - ); - submissionsList.innerHTML = ""; // Clear list - - if (submissions && submissions.length > 0) { - submissions.forEach((sub) => { - const li = document.createElement("li"); - // Display submission data safely - avoid rendering raw HTML - const pre = document.createElement("pre"); - pre.textContent = JSON.stringify(sub.data, null, 2); // Pretty print JSON - li.appendChild(pre); - // Optionally display submission ID and timestamp if available - // const info = document.createElement('small'); - // info.textContent = `ID: ${sub.id}, Submitted: ${sub.created_at || 'N/A'}`; - // li.appendChild(info); - - submissionsList.appendChild(li); - }); - } else { - submissionsList.innerHTML = - "
  • No submissions found for this form.
  • "; - } - } catch (error) { - showStatus( - `Failed to load submissions for form ${formId}: ${error.message}`, - true - ); - submissionsList.innerHTML = "
  • Error loading submissions.
  • "; - submissionsSection.classList.add("hidden"); // Hide section on error - } - } - - // --- Public Form Handling --- - - if (loadPublicFormButton) { - loadPublicFormButton.addEventListener("click", async () => { - const formId = publicFormIdInput.value.trim(); - if (!formId) { - showStatus("Please enter a Form ID.", true); - return; - } - showStatus(""); - publicFormArea.classList.add("hidden"); - publicForm.innerHTML = "Loading form..."; // Clear previous form - - // NOTE: Fetching form definition is NOT directly possible with the current backend - // The backend only provides GET /forms (all, protected) and GET /forms/{id}/submissions (protected) - // It DOES NOT provide a public GET /forms/{id} endpoint to fetch the definition. - // - // **WORKAROUND:** We will *assume* the user knows the structure or we have it cached/predefined. - // For this example, we'll fetch *all* forms (if logged in) and find it, OR fail if not logged in. - // A *better* backend design would include a public GET /forms/{id} endpoint. - - try { - // Attempt to get the form definition (requires login for this workaround) - if (!authToken) { - showStatus( - "Loading public forms requires login in this demo version.", - true - ); - publicForm.innerHTML = ""; // Clear loading message - return; - } - const forms = await makeApiRequest("/forms", "GET", null, true); - const formDefinition = forms.find((f) => f.id === formId); - - if (!formDefinition) { - throw new Error(`Form with ID ${formId} not found or access denied.`); - } - - renderPublicForm(formDefinition); - publicFormArea.classList.remove("hidden"); - } catch (error) { - showStatus(`Failed to load form ${formId}: ${error.message}`, true); - publicForm.innerHTML = ""; // Clear loading message - publicFormArea.classList.add("hidden"); - } - }); - } - - function renderPublicForm(formDefinition) { - publicFormTitle.textContent = formDefinition.name; - publicForm.innerHTML = ""; // Clear previous fields - publicForm.dataset.formId = formDefinition.id; // Store form ID for submission - - if (!formDefinition.fields || !Array.isArray(formDefinition.fields)) { - publicForm.innerHTML = "

    Error: Form definition is invalid.

    "; - console.error("Invalid form fields definition:", formDefinition.fields); - return; - } - - formDefinition.fields.forEach((field) => { - const div = document.createElement("div"); - const label = document.createElement("label"); - label.htmlFor = `field-${field.name}`; - label.textContent = field.label || field.name; // Use label, fallback to name - div.appendChild(label); - - let input; - // Basic type handling - could be expanded - switch (field.type) { - case "textarea": // Allow explicit textarea type - case "string": - // Use textarea for string if maxLength suggests it might be long - if (field.maxLength && field.maxLength > 100) { - input = document.createElement("textarea"); - input.rows = 4; // Default rows - } else { - input = document.createElement("input"); - input.type = "text"; - } - if (field.minLength) input.minLength = field.minLength; - if (field.maxLength) input.maxLength = field.maxLength; - break; - case "email": - input = document.createElement("input"); - input.type = "email"; - break; - case "url": - input = document.createElement("input"); - input.type = "url"; - break; - case "number": - input = document.createElement("input"); - input.type = "number"; - if (field.min !== undefined) input.min = field.min; - if (field.max !== undefined) input.max = field.max; - input.step = field.step || "any"; // Allow decimals by default - break; - case "boolean": - input = document.createElement("input"); - input.type = "checkbox"; - // Checkbox label handling is slightly different - label.insertBefore(input, label.firstChild); // Put checkbox before text - input.style.width = "auto"; // Override default width - input.style.marginRight = "10px"; - break; - // Add cases for 'select', 'radio', 'date' etc. if needed - default: - input = document.createElement("input"); - input.type = "text"; - console.warn( - `Unsupported field type "${field.type}" for field "${field.name}". Rendering as text.` - ); - } - - if (input.type !== "checkbox") { - // Checkbox is already appended inside label - div.appendChild(input); - } - input.id = `field-${field.name}`; - input.name = field.name; // Crucial for form data collection - if (field.required) input.required = true; - if (field.placeholder) input.placeholder = field.placeholder; - if (field.pattern) input.pattern = field.pattern; // Add regex pattern validation - - publicForm.appendChild(div); - }); - - const submitButton = document.createElement("button"); - submitButton.type = "submit"; - submitButton.textContent = "Submit Form"; - publicForm.appendChild(submitButton); - } - - publicForm.addEventListener("submit", async (e) => { - e.preventDefault(); - showStatus(""); - const formId = e.target.dataset.formId; - if (!formId) { - showStatus("Error: Form ID is missing.", true); - return; - } - - const formData = new FormData(e.target); - const submissionData = {}; - - // Convert FormData to a plain object, handling checkboxes correctly - for (const [key, value] of formData.entries()) { - const inputElement = e.target.elements[key]; - - // Handle Checkboxes (boolean) - if (inputElement && inputElement.type === "checkbox") { - // A checkbox value is only present in FormData if it's checked. - // We need to ensure we always send a boolean. - // Check if the element exists in the form (it might be unchecked) - submissionData[key] = inputElement.checked; - } - // Handle Number inputs (convert from string) - else if (inputElement && inputElement.type === "number") { - // Only convert if the value is not empty, otherwise send null or handle as needed - if (value !== "") { - submissionData[key] = parseFloat(value); // Or parseInt if only integers allowed - if (isNaN(submissionData[key])) { - // Handle potential parsing errors if input validation fails - console.warn(`Could not parse number for field ${key}: ${value}`); - submissionData[key] = null; // Or keep as string, or show error - } - } else { - submissionData[key] = null; // Or undefined, depending on backend expectation for empty numbers - } - } - // Handle potential multiple values for the same name (e.g., multi-select), though not rendered here - else if (submissionData.hasOwnProperty(key)) { - if (!Array.isArray(submissionData[key])) { - submissionData[key] = [submissionData[key]]; - } - submissionData[key].push(value); - } - // Default: treat as string - else { - submissionData[key] = value; - } - } - - // Ensure boolean fields that were *unchecked* are explicitly set to false - // FormData only includes checked checkboxes. Find all checkbox inputs in the form. - const checkboxes = e.target.querySelectorAll('input[type="checkbox"]'); - checkboxes.forEach((cb) => { - if (!submissionData.hasOwnProperty(cb.name)) { - submissionData[cb.name] = false; // Set unchecked boxes to false - } - }); - - console.log("Submitting data:", submissionData); // Debugging - - try { - // Public submission endpoint doesn't require auth - const result = await makeApiRequest( - `/forms/${formId}/submissions`, - "POST", - submissionData, - false - ); - showStatus( - `Submission successful! Submission ID: ${result.submission_id}` - ); - e.target.reset(); // Clear the form - // Optionally hide the form after successful submission - // publicFormArea.classList.add('hidden'); - } catch (error) { - let errorMsg = `Submission failed: ${error.message}`; - // Handle validation errors specifically - if (error.validationErrors) { - errorMsg = "Submission failed due to validation errors:\n"; - for (const [field, message] of Object.entries(error.validationErrors)) { - errorMsg += `- ${field}: ${message}\n`; - } - // Highlight invalid fields? (More complex UI update) - } - showStatus(errorMsg, true); - } - }); - - // --- Initial Setup --- - toggleSections(); // Set initial view based on stored token - if (authToken) { - loadFormsButton.click(); // Auto-load forms if logged in - } + +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> { - log::debug!("Validating received token (existence and expiration)..."); - let mut stmt = conn.prepare( - // Select user ID only if token matches AND it hasn't expired - "SELECT id FROM users WHERE token = ?1 AND token_expires_at IS NOT NULL AND token_expires_at > ?2" - ).context("Failed to prepare query for validating token")?; - - let now_ts = Utc::now().to_rfc3339(); // Use ISO 8601 / RFC 3339 format compatible with SQLite DATETIME - - let user_id_option: Option = stmt - .query_row(params![token, now_ts], |row| row.get(0)) - .optional() // Makes it return Option instead of erroring on no rows - .context("Failed to execute query for validating token")?; - - if user_id_option.is_some() { - log::debug!("Token validation successful."); - } else { - // This covers token not found OR token expired - log::debug!("Token validation failed (token not found or expired)."); - } - - Ok(user_id_option) -} - -// Invalidate a user's token (e.g., on logout) by setting it to NULL and clearing expiration -pub fn invalidate_token(conn: &Connection, user_id: &str) -> AnyhowResult<()> { - log::debug!("Invalidating token for user_id {}", user_id); - conn.execute( - "UPDATE users SET token = NULL, token_expires_at = NULL WHERE id = ?1", - params![user_id], - ) - .context(format!( - "Failed to invalidate token for user_id {}", - user_id - ))?; - Ok(()) -} - -// Authenticate a user by username and password, returning user ID and hash if successful -pub fn authenticate_user( - conn: &Connection, - username: &str, - password: &str, -) -> AnyhowResult> { - log::debug!("Attempting to authenticate user: {}", username); - let mut stmt = conn - .prepare("SELECT id, password FROM users WHERE username = ?1") - .context("Failed to prepare query for authenticating user")?; - - let result = stmt - .query_row(params![username], |row| { - Ok(models::UserAuthData { - id: row.get(0)?, - hashed_password: row.get(1)?, - }) - }) - .optional() - .context(format!( - "Failed to execute query to fetch auth data for user '{}'", - username - ))?; - - match result { - Some(user_data) => { - // Verify the provided password against the stored hash - let is_valid = verify(password, &user_data.hashed_password) - .context("Failed to verify password hash")?; - - if is_valid { - log::info!("Authentication successful for user: {}", username); - Ok(Some(user_data)) // Return user ID and hash - } else { - log::warn!( - "Authentication failed for user '{}' (invalid password)", - username - ); - Ok(None) // Invalid password - } - } - None => { - log::warn!( - "Authentication failed for user '{}' (user not found)", - username - ); - Ok(None) // User not found - } - } -} - -// Generate and save a new session token (with expiration) for a user -pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> AnyhowResult { - let new_token = Uuid::new_v4().to_string(); - // Calculate expiration time - let expires_at = Utc::now() + ChronoDuration::hours(TOKEN_LIFETIME_HOURS); - let expires_at_ts = expires_at.to_rfc3339(); // Store as string - - log::debug!( - "Generating new token for user_id {} expiring at {}", - user_id, - expires_at_ts - ); - - conn.execute( - "UPDATE users SET token = ?1, token_expires_at = ?2 WHERE id = ?3", - params![new_token, expires_at_ts, user_id], - ) - .context(format!("Failed to update token for user_id {}", user_id))?; - - Ok(new_token) -} - -// Fetch a specific form definition by its ID -pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult> { - let mut stmt = conn - .prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1") - .context("Failed to prepare query for fetching form")?; - - let result = stmt - .query_row(params![form_id], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let fields_str: String = row.get(2)?; - let notify_email: Option = row.get(3)?; - let notify_ntfy_topic: Option = row.get(4)?; // Get the new field - let created_at: chrono::DateTime = row.get(5)?; - - // Parse the fields JSON string - let fields = serde_json::from_str(&fields_str).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 2, // Index of 'fields' column - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - - Ok(models::Form { - id: Some(id), - name, - fields, - notify_email, - notify_ntfy_topic, // Include the new field - created_at, - }) - }) - .optional() - .context(format!("Failed to fetch form with ID: {}", form_id))?; - - Ok(result) -} - -// Add a function to save a form -impl models::Form { - pub fn save(&self, conn: &Connection) -> AnyhowResult<()> { - let id = self - .id - .clone() - .unwrap_or_else(|| Uuid::new_v4().to_string()); - let fields_json = serde_json::to_string(&self.fields)?; - - conn.execute( - "INSERT INTO forms (id, name, fields, notify_email, notify_ntfy_topic, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - fields = excluded.fields, - notify_email = excluded.notify_email, - notify_ntfy_topic = excluded.notify_ntfy_topic", // Update the new field on conflict - params![ - id, - self.name, - fields_json, - self.notify_email, - self.notify_ntfy_topic, // Add the new field to params - self.created_at - ], - )?; - - Ok(()) - } - - pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult { - get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id)) - // Added ID to error - } -} - -// Add a function to save a submission -impl models::Submission { - pub fn save(&self, conn: &Connection) -> AnyhowResult<()> { - let data_json = serde_json::to_string(&self.data)?; - - conn.execute( - "INSERT INTO submissions (id, form_id, data, created_at) - VALUES (?1, ?2, ?3, ?4)", - params![self.id, self.form_id, data_json, self.created_at], - )?; - - Ok(()) - } -} - - - -use crate::auth::Auth; -use crate::models::{Form, LoginCredentials, LoginResponse, Submission}; -use crate::AppState; -use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult}; -use chrono; // Only import the module since we use it qualified -use log; -use regex::Regex; // For pattern validation -use rusqlite::{params, Connection}; -use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use uuid::Uuid; - -// --- Helper Function for Validation --- - -/// Validates submission data against the form field definitions with enhanced checks. -/// -/// Expected field definition properties: -/// - `name`: string (required) -/// - `type`: string (e.g., "string", "number", "boolean", "email", "url", "object", "array") (required) -/// - `required`: boolean (optional, default: false) -/// - `maxLength`: number (for "string" type) -/// - `minLength`: number (for "string" type) -/// - `min`: number (for "number" type) -/// - `max`: number (for "number" type) -/// - `pattern`: string (regex for "string", "email", "url" types) -/// -/// Returns `Ok(())` if valid, or `Err(JsonValue)` containing validation errors. -fn validate_submission_against_definition( - submission_data: &JsonValue, - form_definition_fields: &JsonValue, -) -> Result<(), JsonValue> { - let mut errors: HashMap = HashMap::new(); - - // Ensure 'fields' in the definition is a JSON array - let field_definitions = match form_definition_fields.as_array() { - Some(defs) => defs, - None => { - log::error!( - "Form definition 'fields' is not a JSON array. Def: {:?}", - form_definition_fields - ); - errors.insert( - "_internal".to_string(), - "Invalid form definition format (not an array)".to_string(), - ); - return Err(json!({ "validation_errors": errors })); - } - }; - - // Ensure the submission data is a JSON object - let data_map = match submission_data.as_object() { - Some(map) => map, - None => { - errors.insert( - "_submission".to_string(), - "Submission data must be a JSON object".to_string(), - ); - return Err(json!({ "validation_errors": errors })); - } - }; - - // Build a map of valid field names to their definitions from the definition for quick lookup - let defined_field_names: HashMap> = field_definitions - .iter() - .filter_map(|val| val.as_object()) - .filter_map(|def| { - def.get("name") - .and_then(JsonValue::as_str) - .map(|name| (name.to_string(), def)) - }) - .collect(); - - // 1. Check for submitted fields that are NOT in the definition - for submitted_key in data_map.keys() { - if !defined_field_names.contains_key(submitted_key) { - errors.insert( - submitted_key.clone(), - "Unexpected field submitted".to_string(), - ); - } - } - // Exit early if unexpected fields were found - if !errors.is_empty() { - log::warn!("Submission validation failed: Unexpected fields submitted."); - return Err(json!({ "validation_errors": errors })); - } - - // 2. Iterate through each field definition and validate corresponding submitted data - for (field_name, field_def) in &defined_field_names { - // Extract properties using helper functions for clarity - let field_type = field_def - .get("type") - .and_then(JsonValue::as_str) - .unwrap_or("string"); // Default to "string" if type is missing or not a string - let is_required = field_def - .get("required") - .and_then(JsonValue::as_bool) - .unwrap_or(false); // Default to false if required is missing or not a boolean - let min_length = field_def.get("minLength").and_then(JsonValue::as_u64); - let max_length = field_def.get("maxLength").and_then(JsonValue::as_u64); - let min_value = field_def.get("min").and_then(JsonValue::as_f64); // Use f64 for flexibility - let max_value = field_def.get("max").and_then(JsonValue::as_f64); - let pattern = field_def.get("pattern").and_then(JsonValue::as_str); - - match data_map.get(field_name) { - Some(submitted_value) if !submitted_value.is_null() => { - // Field is present and not null, perform type and constraint checks - let mut type_error = None; - let mut constraint_errors = vec![]; - - match field_type { - "string" | "email" | "url" => { - if let Some(s) = submitted_value.as_str() { - if let Some(min) = min_length { - if (s.chars().count() as u64) < min { - // Use chars().count() for UTF-8 correctness - constraint_errors - .push(format!("Must be at least {} characters long", min)); - } - } - if let Some(max) = max_length { - if (s.chars().count() as u64) > max { - constraint_errors.push(format!( - "Must be no more than {} characters long", - max - )); - } - } - if let Some(pat) = pattern { - // Consider caching compiled Regex if performance is critical - // and patterns are reused frequently across requests. - match Regex::new(pat) { - Ok(re) => { - if !re.is_match(s) { - constraint_errors.push(format!("Does not match required pattern")); - } - } - Err(e) => log::warn!("Invalid regex pattern '{}' in form definition for field '{}': {}", pat, field_name, e), // Log regex compilation error - } - } - // Specific checks for email/url - if field_type == "email" { - // Basic email regex (adjust for stricter needs or use a validation crate) - // This regex is very basic and allows many technically invalid addresses. - // Consider crates like `validator` for more robust validation. - let email_regex = - Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); // Safe unwrap for known valid regex - if !email_regex.is_match(s) { - constraint_errors - .push("Must be a valid email address".to_string()); - } - } - if field_type == "url" { - // Basic URL check (consider `url` crate for robustness) - if url::Url::parse(s).is_err() { - constraint_errors.push("Must be a valid URL".to_string()); - } - } - } else { - type_error = Some(format!("Expected a string for '{}'", field_name)); - } - } - "number" => { - // Use as_f64 for flexibility (handles integers and floats) - if let Some(num) = submitted_value.as_f64() { - if let Some(min) = min_value { - if num < min { - constraint_errors.push(format!("Must be at least {}", min)); - } - } - if let Some(max) = max_value { - if num > max { - constraint_errors.push(format!("Must be no more than {}", max)); - } - } - } else { - type_error = Some(format!("Expected a number for '{}'", field_name)); - } - } - "boolean" => { - if !submitted_value.is_boolean() { - type_error = Some(format!( - "Expected a boolean (true/false) for '{}'", - field_name - )); - } - } - "object" => { - if !submitted_value.is_object() { - type_error = - Some(format!("Expected a JSON object for '{}'", field_name)); - } - // TODO: Could add deeper validation for object structure here if needed based on definition - } - "array" => { - if !submitted_value.is_array() { - type_error = - Some(format!("Expected a JSON array for '{}'", field_name)); - } - // TODO: Could add validation for array elements here if needed based on definition - } - _ => { - // Log unsupported types during development/debugging if necessary - log::trace!( - "Unsupported field type '{}' encountered during validation for field '{}'. Treating as valid.", - field_type, - field_name - ); - // Assume valid if type is not specifically handled or unknown - } - } - - // Record errors found for this field - if let Some(err) = type_error { - errors.insert(field_name.clone(), err); - } else if !constraint_errors.is_empty() { - // Combine multiple constraint errors if necessary - errors.insert(field_name.clone(), constraint_errors.join("; ")); - } - } // End check for present and non-null value - Some(_) => { - // Value is present but explicitly null (e.g., "fieldName": null) - if is_required { - errors.insert( - field_name.clone(), - "This field is required and cannot be null".to_string(), - ); - } - // Otherwise, null is considered a valid (empty) value for non-required fields - } - None => { - // Field is missing entirely from the submission object - if is_required { - errors.insert(field_name.clone(), "This field is required".to_string()); - } - // Missing is valid for non-required fields - } - } // End match data_map.get(field_name) - } // End loop through field definitions - - // Check if any errors were collected - if errors.is_empty() { - Ok(()) // Validation passed - } else { - log::info!( - "Submission validation failed with {} error(s).", // Log only the count for brevity - errors.len() - ); - // Return a JSON object containing the specific validation errors - Err(json!({ "validation_errors": errors })) - } -} - -// Helper function to convert anyhow::Error to actix_web::Error -fn anyhow_to_actix_error(e: anyhow::Error) -> ActixWebError { - actix_web::error::ErrorInternalServerError(e.to_string()) -} - -// --- Public Handlers --- - -// POST /login -pub async fn login( - app_state: web::Data, // Expect AppState like other handlers - creds: web::Json, -) -> ActixResult { - // Clone the Arc> from AppState - let db_conn_arc = app_state.db.clone(); - let username = creds.username.clone(); - let password = creds.password.clone(); - - // Wrap the blocking database operations in web::block - let auth_result = web::block(move || { - // Use the cloned Arc here - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?; - crate::db::authenticate_user(&conn, &username, &password) - }) - .await - .map_err(|e| { - log::error!("web::block error during authentication: {:?}", e); - actix_web::error::ErrorInternalServerError("Authentication process failed (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; - - match auth_result { - Some(user_data) => { - // Clone Arc again for token generation, using the AppState db field - let db_conn_token_arc = app_state.db.clone(); - let user_id = user_data.id.clone(); - - // Generate and store a new token within web::block - let token = web::block(move || { - // Use the cloned Arc here - let conn = db_conn_token_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?; - crate::db::generate_and_set_token_for_user(&conn, &user_id) - }) - .await - .map_err(|e| { - log::error!("web::block error during token generation: {:?}", e); - actix_web::error::ErrorInternalServerError( - "Failed to complete login (token generation blocking error)", - ) - })? - .map_err(anyhow_to_actix_error)?; - - log::info!("Login successful for user_id: {}", user_data.id); - Ok(HttpResponse::Ok().json(LoginResponse { token })) - } - None => { - log::warn!("Login failed for username: {}", creds.username); - // Return 401 Unauthorized for failed login attempts - Err(actix_web::error::ErrorUnauthorized( - "Invalid username or password", - )) - } - } -} - -// POST /logout -pub async fn logout( - app_state: web::Data, // Expect AppState - auth: Auth, // Requires authentication (extracts user_id from token) -) -> ActixResult { - log::info!("User {} requesting logout", auth.user_id); - let db_conn_arc = app_state.db.clone(); // Get db from AppState - let user_id = auth.user_id.clone(); - - // Invalidate the token in the database within web::block - web::block(move || { - let conn = db_conn_arc // Use the cloned Arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?; - crate::db::invalidate_token(&conn, &user_id) - }) - .await - .map_err(|e| { - // Use the original auth.user_id here as user_id moved into the block - log::error!( - "web::block error during logout for user {}: {:?}", - auth.user_id, - e - ); - actix_web::error::ErrorInternalServerError("Logout failed (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; - - log::info!("User {} logged out successfully", auth.user_id); - Ok(HttpResponse::Ok().json(json!({ "message": "Logged out successfully" }))) -} - -// POST /forms/{form_id}/submissions -pub async fn submit_form( - app_state: web::Data, - path: web::Path, // Extracts form_id from path - submission_payload: web::Json, // Expect arbitrary JSON payload -) -> ActixResult { - let form_id = path.into_inner(); - let conn = app_state.db.lock().map_err(|e| { - log::error!("Failed to acquire database lock: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - // Get form definition - let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?; - - // Validate submission against form definition - if let Err(validation_errors) = - validate_submission_against_definition(&submission_payload, &form.fields) - { - return Ok(HttpResponse::BadRequest().json(validation_errors)); - } - - // Create submission record - let submission = Submission { - id: Uuid::new_v4().to_string(), - form_id: form_id.clone(), - data: submission_payload.into_inner(), - created_at: chrono::Utc::now(), - }; - - // Save submission to database - submission.save(&conn).map_err(|e| { - log::error!("Failed to save submission: {}", e); - actix_web::error::ErrorInternalServerError("Failed to save submission") - })?; - - // Send notifications if configured - if let Some(notify_email) = form.notify_email { - let email_subject = format!("New submission for form: {}", form.name); - let email_body = format!( - "A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}", - form.name, - submission.id, - submission.created_at, - serde_json::to_string_pretty(&submission.data).unwrap_or_default() - ); - - if let Err(e) = app_state - .notification_service - .send_email(¬ify_email, &email_subject, &email_body) - .await - { - log::warn!("Failed to send email notification: {}", e); - } - - // Also send ntfy notification if configured (sends to the global topic) - if let Some(topic_flag) = &form.notify_ntfy_topic { - // Use field presence as a flag - if !topic_flag.is_empty() { - // Check if the flag string is non-empty - let ntfy_title = format!("New submission for: {}", form.name); - let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id); - if let Err(e) = app_state.notification_service.send_ntfy( - &ntfy_title, - &ntfy_message, - Some(3), // Medium priority - ) { - log::warn!("Failed to send ntfy notification (global topic): {}", e); - } - } - } - } - - Ok(HttpResponse::Created().json(json!({ - "message": "Submission received", - "submission_id": submission.id - }))) -} - -// POST /forms -pub async fn create_form( - app_state: web::Data, - _auth: Auth, // Authentication check via Auth extractor - payload: web::Json, -) -> ActixResult { - let payload = payload.into_inner(); - - // Extract form data from payload - let name = payload["name"] - .as_str() - .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))? - .to_string(); - - let fields = payload["fields"].clone(); - if !fields.is_array() { - return Err(actix_web::error::ErrorBadRequest( - "'fields' must be a JSON array", - )); - } - - let notify_email = payload["notify_email"].as_str().map(|s| s.to_string()); - let notify_ntfy_topic = payload["notify_ntfy_topic"].as_str().map(|s| s.to_string()); - - // Create new form - let form = Form { - id: None, // Will be generated during save - name, - fields, - notify_email, - notify_ntfy_topic, - created_at: chrono::Utc::now(), - }; - - // Save the form - let conn = app_state.db.lock().map_err(|e| { - log::error!("Failed to acquire database lock: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - form.save(&conn).map_err(|e| { - log::error!("Failed to save form: {}", e); - actix_web::error::ErrorInternalServerError("Failed to save form") - })?; - - Ok(HttpResponse::Created().json(form)) -} - -// GET /forms -pub async fn get_forms( - app_state: web::Data, - auth: Auth, // Requires authentication -) -> ActixResult { - log::info!("User {} requesting list of forms", auth.user_id); - - let conn = app_state.db.lock().map_err(|e| { - log::error!("Failed to acquire database lock: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - let mut stmt = conn - .prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms") - .map_err(|e| { - log::error!("Failed to prepare statement: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - let forms_iter = stmt - .query_map([], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let fields_str: String = row.get(2)?; - let notify_email: Option = row.get(3)?; - let notify_ntfy_topic: Option = row.get(4)?; - let created_at: chrono::DateTime = row.get(5)?; - - // Parse the 'fields' JSON string - let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| { - log::error!( - "DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.", - id, - e - ); - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - - Ok(Form { - id: Some(id), - name, - fields, - notify_email, - notify_ntfy_topic, - created_at, - }) - }) - .map_err(|e| { - log::error!("Failed to execute query: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - // Collect results, filtering out rows that failed parsing - let forms: Vec
    = forms_iter - .filter_map(|result| match result { - Ok(form) => Some(form), - Err(e) => { - log::warn!("Skipping a form row due to a processing error: {}", e); - None - } - }) - .collect(); - - log::debug!("Returning {} forms for user {}", forms.len(), auth.user_id); - Ok(HttpResponse::Ok().json(forms)) -} - -// GET /forms/{form_id}/submissions -pub async fn get_submissions( - app_state: web::Data, - auth: Auth, // Requires authentication - path: web::Path, // Extracts form_id from the path -) -> ActixResult { - let form_id = path.into_inner(); - log::info!( - "User {} requesting submissions for form_id: {}", - auth.user_id, - form_id - ); - - let conn = app_state.db.lock().map_err(|e| { - log::error!("Failed to acquire database lock: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - // Check if the form exists - let _form = Form::get_by_id(&conn, &form_id).map_err(|e| { - if e.to_string().contains("not found") { - actix_web::error::ErrorNotFound("Form not found") - } else { - actix_web::error::ErrorInternalServerError("Database error") - } - })?; - - // Get submissions - let mut stmt = conn - .prepare( - "SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", - ) - .map_err(|e| { - log::error!("Failed to prepare statement: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - let submissions_iter = stmt - .query_map(params![form_id], |row| { - let id: String = row.get(0)?; - let form_id: String = row.get(1)?; - let data_str: String = row.get(2)?; - let created_at: chrono::DateTime = row.get(3)?; - - let data: serde_json::Value = serde_json::from_str(&data_str).map_err(|e| { - log::error!( - "DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.", - id, - e - ); - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - - Ok(Submission { - id, - form_id, - data, - created_at, - }) - }) - .map_err(|e| { - log::error!("Failed to execute query: {}", e); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - let submissions: Vec = submissions_iter - .filter_map(|result| match result { - Ok(submission) => Some(submission), - Err(e) => { - log::warn!("Skipping a submission row due to processing error: {}", e); - None - } - }) - .collect(); - - log::debug!( - "Returning {} submissions for form {} requested by user {}", - submissions.len(), - form_id, - auth.user_id - ); - Ok(HttpResponse::Ok().json(submissions)) -} - -// --- Notification Settings Handlers --- - -// GET /forms/{form_id}/notifications -pub async fn get_notification_settings( - app_state: web::Data, - auth: Auth, // Requires authentication - path: web::Path, -) -> ActixResult { - let form_id = path.into_inner(); - log::info!( - "User {} requesting notification settings for form_id: {}", - auth.user_id, - form_id - ); - - let conn = app_state.db.lock().map_err(|e| { - log::error!( - "Failed to acquire database lock for get_notification_settings: {}", - e - ); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - // Get the form to ensure it exists and retrieve current settings - let form = Form::get_by_id(&conn, &form_id).map_err(|e| { - log::warn!( - "Attempt to get settings for non-existent form {}: {}", - form_id, - e - ); - if e.to_string().contains("not found") { - actix_web::error::ErrorNotFound("Form not found") - } else { - actix_web::error::ErrorInternalServerError("Database error retrieving form") - } - })?; - - let settings = crate::models::NotificationSettingsPayload { - notify_email: form.notify_email, - notify_ntfy_topic: form.notify_ntfy_topic, - }; - - Ok(HttpResponse::Ok().json(settings)) -} - -// PUT /forms/{form_id}/notifications -pub async fn update_notification_settings( - app_state: web::Data, - auth: Auth, // Requires authentication - path: web::Path, - payload: web::Json, -) -> ActixResult { - let form_id = path.into_inner(); - let new_settings = payload.into_inner(); - log::info!( - "User {} updating notification settings for form_id: {}. Settings: {:?}", - auth.user_id, - form_id, - new_settings - ); - - let conn = app_state.db.lock().map_err(|e| { - log::error!( - "Failed to acquire database lock for update_notification_settings: {}", - e - ); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - // Fetch the existing form to update it - let mut form = Form::get_by_id(&conn, &form_id).map_err(|e| { - log::warn!( - "Attempt to update settings for non-existent form {}: {}", - form_id, - e - ); - if e.to_string().contains("not found") { - actix_web::error::ErrorNotFound("Form not found") - } else { - actix_web::error::ErrorInternalServerError("Database error retrieving form") - } - })?; - - // Update the form fields - form.notify_email = new_settings.notify_email; - form.notify_ntfy_topic = new_settings.notify_ntfy_topic; - - // Save the updated form - form.save(&conn).map_err(|e| { - log::error!( - "Failed to save updated notification settings for form {}: {}", - form_id, - e - ); - actix_web::error::ErrorInternalServerError("Failed to save notification settings") - })?; - - log::info!( - "Successfully updated notification settings for form {}", - form_id - ); - Ok(HttpResponse::Ok().json(json!({ "message": "Notification settings updated successfully" }))) -} - -pub async fn health_check() -> impl Responder { - HttpResponse::Ok().json(serde_json::json!({ - "status": "ok", - "version": env!("CARGO_PKG_VERSION"), - "timestamp": chrono::Utc::now().to_rfc3339() - })) -} - - - -// src/main.rs -use actix_cors::Cors; -use actix_files as fs; -use actix_route_rate_limiter::{Limiter, RateLimiter}; -use actix_web::{http::header, middleware::Logger, web, App, HttpServer}; -use config::{Config, Environment}; -use dotenv::dotenv; -use std::env; -use std::io::Result as IoResult; -use std::process; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use tracing::{error, info, warn}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -// Import modules -mod auth; -mod db; -mod handlers; -mod models; -mod notifications; - -use notifications::{NotificationConfig, NotificationService}; - -// Application state that will be shared across all routes -pub struct AppState { - db: Arc>, - notification_service: Arc, -} - -#[actix_web::main] -async fn main() -> IoResult<()> { - // Load environment variables from .env file - dotenv().ok(); - - // Initialize Sentry for error tracking - let _guard = sentry::init(( - env::var("SENTRY_DSN").unwrap_or_default(), - sentry::ClientOptions { - release: sentry::release_name!(), - ..Default::default() - }, - )); - - // Initialize structured logging - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::new( - env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), - )) - .with(tracing_subscriber::fmt::layer()) - .init(); - - // Load configuration - let settings = Config::builder() - .add_source(Environment::default()) - .build() - .unwrap_or_else(|e| { - error!("Failed to load configuration: {}", e); - process::exit(1); - }); - - // --- Configuration (Environment Variables) --- - let database_url = settings.get_string("DATABASE_URL").unwrap_or_else(|_| { - warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'."); - "form_data.db".to_string() - }); - - let bind_address = settings.get_string("BIND_ADDRESS").unwrap_or_else(|_| { - warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'."); - "127.0.0.1:8080".to_string() - }); - - // Read allowed origins as a comma-separated string, defaulting to empty - let allowed_origins_str = env::var("ALLOWED_ORIGIN").unwrap_or_else(|_| { - warn!("ALLOWED_ORIGIN environment variable not set. CORS will be restrictive."); - String::new() // Default to empty string if not set - }); - - // Split the string into a vector of origins - let allowed_origins_list: Vec = if allowed_origins_str.is_empty() { - Vec::new() // Return an empty vector if the string is empty - } else { - allowed_origins_str - .split(',') - .map(|s| s.trim().to_string()) // Trim whitespace and convert to String - .filter(|s| !s.is_empty()) // Remove empty strings resulting from extra commas - .collect() - }; - - info!(" --- Formies Backend Configuration ---"); - info!("Required Environment Variables:"); - info!(" - DATABASE_URL (Current: {})", database_url); - info!(" - BIND_ADDRESS (Current: {})", bind_address); - info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)"); - info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)"); - info!("Optional Environment Variables:"); - if !allowed_origins_list.is_empty() { - info!( - " - ALLOWED_ORIGIN (Set: {})", - allowed_origins_list.join(", ") // Log the list nicely - ); - } else { - warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive"); - } - info!(" - RUST_LOG (e.g., 'info,formies_be=debug')"); - info!(" --- End Configuration ---"); - - // Initialize database connection - let db_connection = match db::init_db(&database_url) { - Ok(conn) => conn, - Err(e) => { - if e.to_string().contains("INITIAL_ADMIN_USERNAME") - || e.to_string().contains("INITIAL_ADMIN_PASSWORD") - { - error!("FATAL: {}", e); - error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables."); - } else { - error!( - "FATAL: Failed to initialize database at {}: {:?}", - database_url, e - ); - } - process::exit(1); - } - }; - - // Initialize rate limiter using the correct fields - let limiter = Limiter { - ip_addresses: std::collections::HashMap::new(), // Stores IP request counts - duration: chrono::TimeDelta::from_std(Duration::from_secs(60)).expect("Invalid duration"), // Convert std::time::Duration - num_requests: 100, // Max requests allowed in the duration - }; - // Create the cloneable Arc> outside the closure - let limiter_data = Arc::new(Mutex::new(limiter)); - - // Initialize notification service - let notification_config = NotificationConfig::from_env().unwrap_or_else(|e| { - warn!( - "Failed to load notification configuration: {}. Notifications will not be available.", - e - ); - NotificationConfig::default() - }); - let notification_service = Arc::new(NotificationService::new(notification_config)); - - // Create AppState with both database and notification service - let app_state = web::Data::new(AppState { - db: Arc::new(Mutex::new(db_connection)), - notification_service: notification_service.clone(), - }); - - info!("Starting server at http://{}", bind_address); - - HttpServer::new(move || { - let app_state = app_state.clone(); - let allowed_origins = allowed_origins_list.clone(); - let rate_limiter = RateLimiter::new(limiter_data.clone()); - - // Configure CORS - let cors = if !allowed_origins.is_empty() { - info!("Configuring CORS for origins: {:?}", allowed_origins); - let mut cors = Cors::default(); - for origin in allowed_origins { - cors = cors.allowed_origin(&origin); // Add each origin - } - cors.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) - .allowed_headers(vec![ - header::AUTHORIZATION, - header::ACCEPT, - header::CONTENT_TYPE, - header::ORIGIN, - header::ACCESS_CONTROL_REQUEST_METHOD, - header::ACCESS_CONTROL_REQUEST_HEADERS, - ]) - .supports_credentials() - .max_age(3600) - } else { - warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set."); - Cors::default() // Keep restrictive default if no origins are provided - .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) - .allowed_headers(vec![ - header::AUTHORIZATION, - header::ACCEPT, - header::CONTENT_TYPE, - header::ORIGIN, - header::ACCESS_CONTROL_REQUEST_METHOD, - header::ACCESS_CONTROL_REQUEST_HEADERS, - ]) - .supports_credentials() - .max_age(3600) - }; - - App::new() - .wrap(cors) - .wrap(Logger::default()) - .wrap(tracing_actix_web::TracingLogger::default()) - .wrap(rate_limiter) - .app_data(app_state) - .service( - web::scope("/api") - // Health check endpoint - .route("/health", web::get().to(handlers::health_check)) - // Public routes - .route("/login", web::post().to(handlers::login)) - .route( - "/forms/{form_id}/submissions", - web::post().to(handlers::submit_form), - ) - // Protected routes - .route("/logout", web::post().to(handlers::logout)) - .route("/forms", web::post().to(handlers::create_form)) - .route("/forms", web::get().to(handlers::get_forms)) - .route( - "/forms/{form_id}/submissions", - web::get().to(handlers::get_submissions), - ) - .route( - "/forms/{form_id}/notifications", - web::get().to(handlers::get_notification_settings), - ) - .route( - "/forms/{form_id}/notifications", - web::put().to(handlers::update_notification_settings), - ), - ) - .service( - fs::Files::new("/", "./frontend/") - .index_file("index.html") - .use_last_modified(true) - .default_handler(fs::NamedFile::open("./frontend/index.html").unwrap_or_else( - |_| { - error!("Fallback file not found: ../frontend/index.html"); - process::exit(1); - }, - )), - ) - }) - .bind(&bind_address)? - .run() - .await -} - - - -// src/models.rs -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -// Consider adding chrono for DateTime types if needed in responses -// use chrono::{DateTime, Utc}; - -// Represents the structure for defining a form -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Form { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - pub name: String, - /// Stores the structure defining the form fields. - /// Expected to be a JSON array of field definition objects. - /// Example field definition object: - /// ```json - /// { - /// "name": "email", // String, required: Unique identifier for the field - /// "type": "email", // String, required: "string", "number", "boolean", "email", "url", "object", "array" - /// "label": "Email Address", // String, optional: User-friendly label - /// "required": true, // Boolean, optional (default: false): If the field must have a value - /// "placeholder": "you@example.com", // String, optional: Placeholder text - /// "minLength": 5, // Number, optional: Minimum length for strings - /// "maxLength": 100, // Number, optional: Maximum length for strings - /// "min": 0, // Number, optional: Minimum value for numbers - /// "max": 100, // Number, optional: Maximum value for numbers - /// "pattern": "^\\S+@\\S+\\.\\S+$" // String, optional: Regex pattern for strings (e.g., email, url types might use this implicitly or explicitly) - /// // Add other properties like "options" for select/radio, etc. - /// } - /// ``` - pub fields: serde_json::Value, - pub notify_email: Option, - pub notify_ntfy_topic: Option, - pub created_at: DateTime, -} - -// Represents a single submission for a specific form -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Submission { - pub id: String, - pub form_id: String, - /// Stores the data submitted by the user. - /// Expected to be a JSON object where keys are field names (`name`) from the form definition's `fields` array. - /// Example: `{ "email": "user@example.com", "age": 30 }` - pub data: serde_json::Value, - pub created_at: DateTime, -} - -// Used for the /login endpoint request body -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginCredentials { - pub username: String, - pub password: String, -} - -// Used for the /login endpoint response body -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginResponse { - pub token: String, // The session token (UUID) -} - -// Used internally to represent a user fetched from the DB for authentication check -// Not serialized, only used within db.rs and handlers.rs -#[derive(Debug)] -pub struct UserAuthData { - pub id: String, - pub hashed_password: String, - // Note: Token and expiry are handled separately and not needed in this specific struct -} - -// Used for the GET/PUT /forms/{form_id}/notifications endpoints -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct NotificationSettingsPayload { - pub notify_email: Option, - pub notify_ntfy_topic: Option, -} - - - -# Formies Backend - -A production-ready Rust backend for the Formies application. - -## Features - -- RESTful API endpoints -- SQLite database with connection pooling -- JWT-based authentication -- Rate limiting -- Structured logging -- Error tracking with Sentry -- Health check endpoint -- CORS support -- Configuration management -- Metrics endpoint - -## Prerequisites - -- Rust 1.70 or later -- SQLite 3 -- Make (optional, for using Makefile commands) - -## Configuration - -The application can be configured using environment variables or a configuration file. The following environment variables are supported: - -### Required Environment Variables - -- `DATABASE_URL`: SQLite database URL (default: form_data.db) -- `BIND_ADDRESS`: Server bind address (default: 127.0.0.1:8080) -- `INITIAL_ADMIN_USERNAME`: Initial admin username -- `INITIAL_ADMIN_PASSWORD`: Initial admin password - -### Optional Environment Variables - -- `ALLOWED_ORIGIN`: CORS allowed origin -- `RUST_LOG`: Log level (default: info) -- `SENTRY_DSN`: Sentry DSN for error tracking -- `JWT_SECRET`: JWT secret key -- `JWT_EXPIRATION`: JWT expiration time in seconds - -## Development - -1. Clone the repository -2. Install dependencies: - ```bash - cargo build - ``` -3. Set up environment variables: - ```bash - cp .env.example .env - # Edit .env with your configuration - ``` -4. Run the development server: - ```bash - cargo run - ``` - -## Production Deployment - -### Docker - -1. Build the Docker image: - - ```bash - docker build -t formies-backend . - ``` - -2. Run the container: - ```bash - docker run -d \ - --name formies-backend \ - -p 8080:8080 \ - -v $(pwd)/data:/app/data \ - -e DATABASE_URL=/app/data/form_data.db \ - -e BIND_ADDRESS=0.0.0.0:8080 \ - -e INITIAL_ADMIN_USERNAME=admin \ - -e INITIAL_ADMIN_PASSWORD=your-secure-password \ - -e ALLOWED_ORIGIN=https://your-frontend-domain.com \ - -e SENTRY_DSN=your-sentry-dsn \ - formies-backend - ``` - -### Systemd Service - -1. Create a systemd service file at `/etc/systemd/system/formies-backend.service`: - - ```ini - [Unit] - Description=Formies Backend Service - After=network.target - - [Service] - Type=simple - User=formies - WorkingDirectory=/opt/formies-backend - ExecStart=/opt/formies-backend/formies-be - Restart=always - Environment=DATABASE_URL=/opt/formies-backend/data/form_data.db - Environment=BIND_ADDRESS=0.0.0.0:8080 - Environment=INITIAL_ADMIN_USERNAME=admin - Environment=INITIAL_ADMIN_PASSWORD=your-secure-password - Environment=ALLOWED_ORIGIN=https://your-frontend-domain.com - Environment=SENTRY_DSN=your-sentry-dsn - - [Install] - WantedBy=multi-user.target - ``` - -2. Enable and start the service: - ```bash - sudo systemctl enable formies-backend - sudo systemctl start formies-backend - ``` - -## Monitoring - -### Health Check - -The application exposes a health check endpoint at `/api/health`: - -```bash -curl http://localhost:8080/api/health -``` - -### Metrics - -Metrics are available at `/metrics` when enabled in the configuration. - -### Logging - -Logs are written to the configured log file and can be viewed using: - -```bash -tail -f logs/app.log -``` - -## Security - -- All API endpoints are rate-limited -- CORS is configured to only allow specified origins -- JWT tokens are used for authentication -- Passwords are hashed using bcrypt -- SQLite database is protected with proper file permissions - -## License - -MIT - - - -name: Build and Push Docker Image - -on: - push: - branches: - - build - -jobs: - build_and_push: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Install Docker - run: | - sudo apt-get update - sudo apt-get install -y docker.io - - - name: Build Docker image - run: | - docker build -t git.vinylnostalgia.com/mo/formies:latest . - - - name: Push Docker image to Gitea - env: - GITEA_USERNAME: ${{ secrets.ME_USERNAME }} - GITEA_PASSWORD: ${{ secrets.ME_PASSWORD }} - run: | - echo $GITEA_PASSWORD | docker login git.vinylnostalgia.com -u $GITEA_USERNAME --password-stdin - docker push git.vinylnostalgia.com/mo/formies:latest + +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( + '

    Thank You!

    Your submission has been received.

    Back to form manager

    ' + ); + } catch (error) { + console.error("Error processing submission:", error); + await sendNtfyNotification( + `Submission Error: ${formNameForNotification}`, + `Failed to process submission for ${formUuid} from IP ${ipAddress}. Error: ${error.message}`, + "max" + ); + res.status(500).send("Error processing submission."); + } + } +); + +module.exports = router; +
    + + +const nodemailer = require("nodemailer"); +require("dotenv").config(); // Ensure environment variables are loaded +const { Resend } = require("resend"); +const logger = require("../../config/logger"); // Adjust path as needed + +const resendApiKey = process.env.RESEND_API_KEY; +const emailFromAddress = process.env.EMAIL_FROM_ADDRESS; + +if (!resendApiKey) { + logger.warn( + "RESEND_API_KEY is not set. Email notifications will be disabled." + ); +} +if (!emailFromAddress) { + logger.warn( + "EMAIL_FROM_ADDRESS is not set. Email notifications may not work correctly." + ); +} + +const resend = resendApiKey ? new Resend(resendApiKey) : null; + +class EmailService { + constructor() { + this.transporter = null; + this.init(); + } + + async init() { + try { + // Create reusable transporter object using the default SMTP transport + this.transporter = nodemailer.createTransporter({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT || 587, + secure: process.env.SMTP_SECURE === "true", // true for 465, false for other ports + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + // Verify connection configuration + if (this.transporter && process.env.SMTP_HOST) { + await this.transporter.verify(); + console.log("Email service initialized successfully"); + } else { + console.warn( + "Email service not configured. Set SMTP environment variables." + ); + } + } catch (error) { + console.error("Email service initialization failed:", error.message); + this.transporter = null; + } + } + + // Check if email service is available + isAvailable() { + return this.transporter !== null; + } + + // Send verification email + async sendVerificationEmail(to, firstName, verificationToken) { + if (!this.isAvailable()) { + console.warn("Email service not available. Verification email not sent."); + return false; + } + + const verificationUrl = `${process.env.APP_URL}/auth/verify-email?token=${verificationToken}`; + + const subject = "Verify Your Email Address - Formies"; + const html = this.getVerificationEmailTemplate(firstName, verificationUrl); + + try { + await this.transporter.sendMail({ + from: `"Formies" <${ + process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER + }>`, + to, + subject, + html, + }); + + console.log(`Verification email sent to ${to}`); + return true; + } catch (error) { + console.error("Failed to send verification email:", error); + return false; + } + } + + // Send password reset email + async sendPasswordResetEmail(to, firstName, resetToken) { + if (!this.isAvailable()) { + console.warn( + "Email service not available. Password reset email not sent." + ); + return false; + } + + const resetUrl = `${process.env.APP_URL}/auth/reset-password?token=${resetToken}`; + + const subject = "Password Reset Request - Formies"; + const html = this.getPasswordResetEmailTemplate(firstName, resetUrl); + + try { + await this.transporter.sendMail({ + from: `"Formies" <${ + process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER + }>`, + to, + subject, + html, + }); + + console.log(`Password reset email sent to ${to}`); + return true; + } catch (error) { + console.error("Failed to send password reset email:", error); + return false; + } + } + + // Send welcome email + async sendWelcomeEmail(to, firstName) { + if (!this.isAvailable()) { + console.warn("Email service not available. Welcome email not sent."); + return false; + } + + const subject = "Welcome to Formies!"; + const html = this.getWelcomeEmailTemplate(firstName); + + try { + await this.transporter.sendMail({ + from: `"Formies" <${ + process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER + }>`, + to, + subject, + html, + }); + + console.log(`Welcome email sent to ${to}`); + return true; + } catch (error) { + console.error("Failed to send welcome email:", error); + return false; + } + } + + // Send password changed notification + async sendPasswordChangedEmail(to, firstName) { + if (!this.isAvailable()) { + return false; + } + + const subject = "Password Changed Successfully - Formies"; + const html = this.getPasswordChangedEmailTemplate(firstName); + + try { + await this.transporter.sendMail({ + from: `"Formies" <${ + process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER + }>`, + to, + subject, + html, + }); + + return true; + } catch (error) { + console.error("Failed to send password changed email:", error); + return false; + } + } + + // Email templates + getVerificationEmailTemplate(firstName, verificationUrl) { + return ` + + + + + + +
    +
    +

    Welcome to Formies!

    +
    +
    +

    Hi ${firstName || "there"},

    +

    Thank you for signing up for Formies! To complete your registration, please verify your email address by clicking the button below:

    +

    + Verify Email Address +

    +

    If the button doesn't work, you can copy and paste this link into your browser:

    +

    ${verificationUrl}

    +

    This link will expire in 24 hours.

    +

    If you didn't create an account with Formies, you can safely ignore this email.

    +
    + +
    + + + `; + } + + getPasswordResetEmailTemplate(firstName, resetUrl) { + return ` + + + + + + +
    +
    +

    Password Reset Request

    +
    +
    +

    Hi ${firstName || "there"},

    +

    We received a request to reset your password for your Formies account. If you made this request, click the button below to reset your password:

    +

    + Reset Password +

    +

    If the button doesn't work, you can copy and paste this link into your browser:

    +

    ${resetUrl}

    +

    This link will expire in 1 hour.

    +

    If you didn't request a password reset, you can safely ignore this email. Your password won't be changed.

    +
    + +
    + + + `; + } + + getWelcomeEmailTemplate(firstName) { + return ` + + + + + + +
    +
    +

    Welcome to Formies!

    +
    +
    +

    Hi ${firstName || "there"},

    +

    Welcome to Formies! Your email has been verified and your account is now active.

    +

    You can now start creating beautiful forms and collecting submissions. Here's what you can do:

    +
      +
    • Create unlimited forms
    • +
    • Customize form fields and styling
    • +
    • Receive instant notifications
    • +
    • Export your data anytime
    • +
    +

    + Go to Dashboard +

    +
    + +
    + + + `; + } + + getPasswordChangedEmailTemplate(firstName) { + return ` + + + + + + +
    +
    +

    Password Changed

    +
    +
    +

    Hi ${firstName || "there"},

    +

    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 + + + + + + +
    + + <% if (view === 'my_forms') { %> +
    +

    My Forms

    + + Create New Form +
    + <%- include('partials/_forms_table', { forms: forms, appUrl: appUrl }) %> + <% } else if (view === 'create_form') { %> +

    Create New Form

    + +
    + + +
    + + <% if (typeof error !== 'undefined' && error) { %> +

    <%= error %>

    + <% } %> + + <% } else if (view === 'form_submissions') { %> <%- + include('partials/_submissions_view', { submissions: submissions, + formUuid: formUuid, formName: formName, pagination: pagination, appUrl: + appUrl }) %> <% } else if (view === 'account_settings') { %> +

    Account Settings

    +

    Account settings will be here.

    + <% } else if (view === 'form_settings') { %> +
    +

    + Settings for <%= formName %> +

    + Back to My Forms +
    + + <% if (typeof successMessage !== 'undefined' && successMessage) { %> +
    + <%= successMessage %> +
    + <% } %> <% if (typeof errorMessage !== 'undefined' && errorMessage) { %> +
    + <%= errorMessage %> +
    + <% } %> + +
    +

    General Settings

    +
    + + +
    + +

    Email Notifications

    +
    + + style="margin-right: 0.5rem;"> + +
    +
    + + + If left blank, notifications will be sent to your account email: + <%= user.email %> +
    + +

    Spam Protection

    +
    + + style="margin-right: 0.5rem;"> + + Uses the globally configured site and secret keys. Ensure these are set in your server's .env file. +
    + +

    Thank You Page

    +
    + + +
    +
    + + + + If a "Thank You URL" is provided, it will be used. Otherwise, this custom message will be shown. If both are blank, a default message is used. + +
    + +

    Allowed Domains

    +
    + + + + Comma-separated list of domains. Leave blank to allow submissions from any domain. + +
    + + +
    + + <% } else if (view === 'api_keys') { %> +
    +

    API Keys

    +
    + + <% if (typeof successMessage !== 'undefined' && successMessage) { %> +
    + <%= successMessage %> +
    + <% } %> + <% if (typeof errorMessage !== 'undefined' && errorMessage) { %> +
    + <%= errorMessage %> +
    + <% } %> + + <% if (typeof newlyGeneratedApiKey !== 'undefined' && newlyGeneratedApiKey) { %> +
    +

    New API Key Generated: <%= newlyGeneratedApiKeyName %>

    +

    Important: This is the only time you will see this API key. Copy it now and store it securely.

    +
    <%= newlyGeneratedApiKey %>
    + +
    + <% } %> + +
    +

    Generate New API Key

    +
    +
    + + +
    + +
    +
    + +
    +

    Your API Keys

    + <% if (apiKeys && apiKeys.length > 0) { %> + + + + + + + + + + + + <% apiKeys.forEach(key => { %> + + + + + + + + <% }) %> + +
    NameIdentifier (Prefix)Created AtLast UsedActions
    <%= key.key_name %><%= key.api_key_identifier %><%= new Date(key.created_at).toLocaleDateString() %><%= key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' %> +
    + +
    +
    + <% } else { %> +

    You have not generated any API keys yet.

    + <% } %> +
    + + <% } %> +
    + + + + +
    + + +<% if (forms && forms.length > 0) { %> + + + + + + + + + + + + + <% forms.forEach(form => { %> + + + + + + + + + <% }); %> + +
    Form NameSubmissionsEndpoint URLCreated DateStatusActions
    <%= form.name %><%= form.submission_count %> + <%= appUrl %>/submit/<%= form.uuid %> + + <%= new Date(form.created_at).toLocaleDateString() %><%= form.is_archived ? 'Archived' : 'Active' %> + View Submissions + Settings + +
    + +
    +
    + +
    +
    +<% } else { %> +

    You haven't created any forms yet. Create one now!

    +<% } %> + + +
    + + +
    +

    + Submissions for <%= formName %> +

    + +
    + +<% if (submissions.length === 0) { %> +
    + No submissions yet for this form. +
    +<% } else { %> <% submissions.forEach(submission => { %> +
    +
    +
    +
    +
    + Submitted: <%= new Date(submission.submitted_at).toLocaleString() %> +
    +
    + IP: <%= submission.ip_address %> +
    +
    +
    + + +
    +
    +
    +
    + <% let data = {}; try { data = JSON.parse(submission.data); } catch (e) { + console.error("Failed to parse submission data:", submission.data); data = + { "error": "Could not parse submission data" }; } %> <% + Object.entries(data).forEach(([key, value]) => { %> <% if (key !== + 'honeypot_field' && key !== '_thankyou') { %> +
    + <%= key %>: + <%= value %> +
    + <% } %> <% }); %> +
    +
    +
    +<% }); %> + + +<% if (pagination.totalPages > 1) { %> + +
    + Showing <%= (pagination.currentPage - 1) * pagination.limit + 1 %> to <%= + Math.min(pagination.currentPage * pagination.limit, + pagination.totalSubmissions) %> of <%= pagination.totalSubmissions %> + submissions +
    +<% } %> <% } %>
    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
    , -) -> ActixResult { - let mut form = form_data.into_inner(); - form.owner_id = auth.user_id.clone(); // Set the owner_id to the authenticated user's ID - - let db_conn_arc = app_state.db.clone(); - web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - form.save(&conn) - }) - .await - .map_err(|e| { - log::error!("web::block error while creating form: {:?}", e); - actix_web::error::ErrorInternalServerError("Failed to create form") - })? - .map_err(anyhow_to_actix_error)?; - - Ok(HttpResponse::Created().json(form)) -} - -// GET /forms -pub async fn get_forms(app_state: web::Data, auth: Auth) -> ActixResult { - let db_conn_arc = app_state.db.clone(); - let user_id = auth.user_id.clone(); - let is_admin = auth.role == "admin"; - - let forms = web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - - let mut stmt = if is_admin { - // Admins can see all forms - conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms")? - } else { - // Regular users can only see their own forms - conn.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms WHERE owner_id = ?1")? - }; - - let forms_iter = if is_admin { - stmt.query_map([], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let fields_str: String = row.get(2)?; - let owner_id: String = row.get(3)?; - let notify_email: Option = row.get(4)?; - let notify_ntfy_topic: Option = row.get(5)?; - let created_at: chrono::DateTime = row.get(6)?; - - let fields = serde_json::from_str(&fields_str).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - - Ok(Form { - id: Some(id), - name, - fields, - owner_id, - notify_email, - notify_ntfy_topic, - created_at, - }) - })? - } else { - stmt.query_map(params![user_id], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let fields_str: String = row.get(2)?; - let owner_id: String = row.get(3)?; - let notify_email: Option = row.get(4)?; - let notify_ntfy_topic: Option = row.get(5)?; - let created_at: chrono::DateTime = row.get(6)?; - - let fields = serde_json::from_str(&fields_str).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - - Ok(Form { - id: Some(id), - name, - fields, - owner_id, - notify_email, - notify_ntfy_topic, - created_at, - }) - })? - }; - - let mut forms = Vec::new(); - for form_result in forms_iter { - forms.push(form_result?); - } - - Ok::<_, anyhow::Error>(forms) - }) - .await - .map_err(|e| { - log::error!("web::block error while fetching forms: {:?}", e); - actix_web::error::ErrorInternalServerError("Failed to fetch forms") - })? - .map_err(anyhow_to_actix_error)?; - - Ok(HttpResponse::Ok().json(forms)) -} - -// GET /forms/{form_id}/submissions -pub async fn get_submissions( - app_state: web::Data, - auth: Auth, - form_id: web::Path, -) -> ActixResult { - let db_conn_arc = app_state.db.clone(); - let form_id_str = form_id.into_inner(); - let user_id = auth.user_id.clone(); - let is_admin = auth.role == "admin"; - - // First check if the user has access to this form - let can_access = web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - - if is_admin { - // Admins can access all forms - return Ok(true); - } - - // Check if the form belongs to the user - let owner_id: Option = conn - .query_row( - "SELECT owner_id FROM forms WHERE id = ?1", - params![form_id_str], - |row| row.get(0), - ) - .optional()?; - - match owner_id { - Some(owner_id) => Ok(owner_id == user_id), - None => Ok(false), // Form doesn't exist - } - }) - .await - .map_err(|e| { - log::error!("web::block error while checking form access: {:?}", e); - actix_web::error::ErrorInternalServerError("Failed to check form access") - })? - .map_err(anyhow_to_actix_error)?; - - if !can_access { - return Err(actix_web::error::ErrorForbidden("Access denied")); - } - - // Now fetch the submissions - let db_conn_arc = app_state.db.clone(); - let form_id_str = form_id.into_inner(); - - let submissions = web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - - let mut stmt = conn - .prepare("SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1")?; - - let submissions_iter = stmt.query_map(params![form_id_str], |row| { - let id: String = row.get(0)?; - let form_id: String = row.get(1)?; - let data_str: String = row.get(2)?; - let created_at: chrono::DateTime = row.get(3)?; - - let data = serde_json::from_str(&data_str).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - - Ok(Submission { - id, - form_id, - data, - created_at, - }) - })?; - - let mut submissions = Vec::new(); - for submission_result in submissions_iter { - submissions.push(submission_result?); - } - - Ok::<_, anyhow::Error>(submissions) - }) - .await - .map_err(|e| { - log::error!("web::block error while fetching submissions: {:?}", e); - actix_web::error::ErrorInternalServerError("Failed to fetch submissions") - })? - .map_err(anyhow_to_actix_error)?; - - Ok(HttpResponse::Ok().json(submissions)) -} - -// --- Notification Settings Handlers --- - -// GET /forms/{form_id}/notifications -pub async fn get_notification_settings( - app_state: web::Data, - auth: Auth, // Requires authentication - path: web::Path, -) -> ActixResult { - let form_id = path.into_inner(); - log::info!( - "User {} requesting notification settings for form_id: {}", - auth.user_id, - form_id - ); - - let conn = app_state.db.lock().map_err(|e| { - log::error!( - "Failed to acquire database lock for get_notification_settings: {}", - e - ); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - // Get the form to ensure it exists and retrieve current settings - let form = Form::get_by_id(&conn, &form_id).map_err(|e| { - log::warn!( - "Attempt to get settings for non-existent form {}: {}", - form_id, - e - ); - if e.to_string().contains("not found") { - actix_web::error::ErrorNotFound("Form not found") - } else { - actix_web::error::ErrorInternalServerError("Database error retrieving form") - } - })?; - - let settings = crate::models::NotificationSettingsPayload { - notify_email: form.notify_email, - notify_ntfy_topic: form.notify_ntfy_topic, - }; - - Ok(HttpResponse::Ok().json(settings)) -} - -// PUT /forms/{form_id}/notifications -pub async fn update_notification_settings( - app_state: web::Data, - auth: Auth, // Requires authentication - path: web::Path, - payload: web::Json, -) -> ActixResult { - let form_id = path.into_inner(); - let new_settings = payload.into_inner(); - log::info!( - "User {} updating notification settings for form_id: {}. Settings: {:?}", - auth.user_id, - form_id, - new_settings - ); - - let conn = app_state.db.lock().map_err(|e| { - log::error!( - "Failed to acquire database lock for update_notification_settings: {}", - e - ); - actix_web::error::ErrorInternalServerError("Database error") - })?; - - // Fetch the existing form to update it - let mut form = Form::get_by_id(&conn, &form_id).map_err(|e| { - log::warn!( - "Attempt to update settings for non-existent form {}: {}", - form_id, - e - ); - if e.to_string().contains("not found") { - actix_web::error::ErrorNotFound("Form not found") - } else { - actix_web::error::ErrorInternalServerError("Database error retrieving form") - } - })?; - - // Update the form fields - form.notify_email = new_settings.notify_email; - form.notify_ntfy_topic = new_settings.notify_ntfy_topic; - - // Save the updated form - form.save(&conn).map_err(|e| { - log::error!( - "Failed to save updated notification settings for form {}: {}", - form_id, - e - ); - actix_web::error::ErrorInternalServerError("Failed to save notification settings") - })?; - - log::info!( - "Successfully updated notification settings for form {}", - form_id - ); - Ok(HttpResponse::Ok().json(json!({ "message": "Notification settings updated successfully" }))) -} - -pub async fn health_check() -> impl Responder { - HttpResponse::Ok().json(serde_json::json!({ - "status": "ok", - "version": env!("CARGO_PKG_VERSION"), - "timestamp": chrono::Utc::now().to_rfc3339() - })) -} - -// POST /register -pub async fn register( - app_state: web::Data, - registration: web::Json, -) -> ActixResult { - let db_conn_arc = app_state.db.clone(); - let username = registration.username.clone(); - let password = registration.password.clone(); - - // Register user in a blocking operation - let result = web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during registration"))?; - - // Check if username already exists - if let Some(_) = crate::db::get_user_by_username(&conn, &username)? { - return Err(anyhow::anyhow!("Username already exists")); - } - - // Add new user with default role "user" - crate::db::add_user_if_not_exists(&conn, &username, &password, None) - }) - .await - .map_err(|e| { - log::error!("web::block error during registration: {:?}", e); - actix_web::error::ErrorInternalServerError("Registration process failed") - })? - .map_err(|e| { - if e.to_string().contains("already exists") { - actix_web::error::ErrorConflict(e.to_string()) - } else { - actix_web::error::ErrorInternalServerError(e.to_string()) - } - })?; - - Ok(HttpResponse::Created().json(json!({ - "message": "User registered successfully" - }))) -} - -// GET /users (admin only) -pub async fn list_users(app_state: web::Data, auth: Auth) -> ActixResult { - // Check admin role - crate::auth::require_admin(&auth)?; - - let db_conn_arc = app_state.db.clone(); - - let users = web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - crate::db::list_users(&conn) - }) - .await - .map_err(|e| { - log::error!("web::block error while listing users: {:?}", e); - actix_web::error::ErrorInternalServerError("Failed to list users") - })? - .map_err(anyhow_to_actix_error)?; - - Ok(HttpResponse::Ok().json(users)) -} - -// GET /users/{user_id} (admin or self) -pub async fn get_user( - app_state: web::Data, - auth: Auth, - user_id: web::Path, -) -> ActixResult { - // Allow if admin or if user is requesting their own data - if auth.role != "admin" && auth.user_id != user_id.as_str() { - return Err(actix_web::error::ErrorForbidden("Access denied")); - } - - let db_conn_arc = app_state.db.clone(); - let user_id_str = user_id.into_inner(); - - let user = web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - crate::db::get_user_by_id(&conn, &user_id_str) - }) - .await - .map_err(|e| { - log::error!("web::block error while fetching user: {:?}", e); - actix_web::error::ErrorInternalServerError("Failed to fetch user") - })? - .map_err(anyhow_to_actix_error)?; - - match user { - Some(user) => Ok(HttpResponse::Ok().json(user)), - None => Ok(HttpResponse::NotFound().json(json!({ - "message": "User not found" - }))), - } -} - -// PUT /users/{user_id} (admin or self) -pub async fn update_user( - app_state: web::Data, - auth: Auth, - user_id: web::Path, - update: web::Json, -) -> ActixResult { - // Allow if admin or if user is updating their own data - if auth.role != "admin" && auth.user_id != user_id.as_str() { - return Err(actix_web::error::ErrorForbidden("Access denied")); - } - - let db_conn_arc = app_state.db.clone(); - let user_id_str = user_id.into_inner(); - let update_data = update.into_inner(); - - web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - crate::db::update_user(&conn, &user_id_str, &update_data) - }) - .await - .map_err(|e| { - log::error!("web::block error while updating user: {:?}", e); - actix_web::error::ErrorInternalServerError("Failed to update user") - })? - .map_err(anyhow_to_actix_error)?; - - Ok(HttpResponse::Ok().json(json!({ - "message": "User updated successfully" - }))) -} - -// DELETE /users/{user_id} (admin only) -pub async fn delete_user( - app_state: web::Data, - auth: Auth, - user_id: web::Path, -) -> ActixResult { - // Only admins can delete users - crate::auth::require_admin(&auth)?; - - let db_conn_arc = app_state.db.clone(); - let user_id_str = user_id.into_inner(); - - let deleted = web::block(move || { - let conn = db_conn_arc - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned"))?; - crate::db::delete_user(&conn, &user_id_str) - }) - .await - .map_err(|e| { - log::error!("web::block error while deleting user: {:?}", e); - actix_web::error::ErrorInternalServerError("Failed to delete user") - })? - .map_err(anyhow_to_actix_error)?; - - if deleted { - Ok(HttpResponse::Ok().json(json!({ - "message": "User deleted successfully" - }))) - } else { - Ok(HttpResponse::NotFound().json(json!({ - "message": "User not found" - }))) - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 4f337ef..0000000 --- a/src/main.rs +++ /dev/null @@ -1,320 +0,0 @@ -// 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, Instant}; -use tracing::{error, info, warn}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -// Added for throttling map -use std::collections::HashMap; - -// Import modules -mod auth; -mod db; -mod handlers; -mod models; -mod notifications; - -use notifications::{NotificationConfig, NotificationService}; - -// --- CAPTCHA Configuration --- -#[derive(Clone, Debug)] -pub struct CaptchaConfig { - pub enabled: bool, - pub secret_key: String, - pub verification_url: String, // e.g., "https://hcaptcha.com/siteverify" -} - -impl CaptchaConfig { - // Function to load from environment variables - pub fn from_env() -> Result { - // Return VarError for simplicity - let enabled = std::env::var("CAPTCHA_ENABLED") - .map(|v| v.parse().unwrap_or(false)) - .unwrap_or(false); // Default to false if not set or parse error - - // Use Ok variant of Result for keys, default to empty if not found - let secret_key = std::env::var("CAPTCHA_SECRET_KEY").unwrap_or_default(); - let verification_url = std::env::var("CAPTCHA_VERIFICATION_URL").unwrap_or_default(); - - // Basic validation: if enabled, secret key and URL must be present - if enabled && (secret_key.is_empty() || verification_url.is_empty()) { - warn!("CAPTCHA_ENABLED is true, but CAPTCHA_SECRET_KEY or CAPTCHA_VERIFICATION_URL is missing. CAPTCHA will be effectively disabled."); - Ok(Self { - enabled: false, // Force disable if config is incomplete - secret_key, - verification_url, - }) - } else { - Ok(Self { - enabled, - secret_key, - verification_url, - }) - } - } -} -// --- End CAPTCHA Configuration --- - -// Application state that will be shared across all routes -pub struct AppState { - db: Arc>, - notification_service: Arc, - captcha_config: CaptchaConfig, - // Map form_id to the Instant of the last notification attempt for that form - last_notification_times: Arc>>, - // Map form_id -> ip_address -> (last_attempt_time, count) for rate limiting - form_submission_attempts: Arc>>>, -} - -#[actix_web::main] -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)); - - // Load CAPTCHA Configuration - let captcha_config = CaptchaConfig::from_env().unwrap_or_else(|e| { - warn!( - "Failed to load CAPTCHA configuration: {}. CAPTCHA will be disabled.", - e - ); - // Ensure default is truly disabled - CaptchaConfig { - enabled: false, - secret_key: String::new(), - verification_url: String::new(), - } - }); - if captcha_config.enabled { - info!("CAPTCHA verification is ENABLED."); - } else { - info!("CAPTCHA verification is DISABLED (or required env vars missing)."); - } - - // Create AppState with all services - let app_state = web::Data::new(AppState { - db: Arc::new(Mutex::new(db_connection)), - notification_service: notification_service.clone(), - captcha_config: captcha_config.clone(), - last_notification_times: Arc::new(Mutex::new(HashMap::new())), - form_submission_attempts: Arc::new(Mutex::new(HashMap::new())), // Initialize rate limit map - }); - - info!("Starting server at http://{}", bind_address); - - HttpServer::new(move || { - let app_state = app_state.clone(); // This now includes captcha_config - 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) - }; - - // Configure JSON payload limits (e.g., 1MB) - let json_config = web::JsonConfig::default().limit(1024 * 1024); - - App::new() - .wrap(cors) - .wrap(Logger::default()) - .wrap(tracing_actix_web::TracingLogger::default()) - .wrap(rate_limiter) - .app_data(app_state) // Share app state (db, notifications, captcha) - .app_data(json_config) // Add JSON payload configuration - .service( - web::scope("/api") - // Health check endpoint - .route("/health", web::get().to(handlers::health_check)) - // Public routes - .route("/login", web::post().to(handlers::login)) - .route("/register", web::post().to(handlers::register)) - .route( - "/forms/{form_id}/submissions", - web::post().to(handlers::submit_form), - ) - // 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), - ) - // User management routes - .route("/users", web::get().to(handlers::list_users)) - .route("/users/{user_id}", web::get().to(handlers::get_user)) - .route("/users/{user_id}", web::put().to(handlers::update_user)) - .route("/users/{user_id}", web::delete().to(handlers::delete_user)), - ) - .service( - fs::Files::new("/", "./frontend/") - .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 -} diff --git a/src/middleware/apiAuthMiddleware.js b/src/middleware/apiAuthMiddleware.js new file mode 100644 index 0000000..9aa0c4a --- /dev/null +++ b/src/middleware/apiAuthMiddleware.js @@ -0,0 +1,101 @@ +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; diff --git a/src/middleware/authMiddleware.js b/src/middleware/authMiddleware.js new file mode 100644 index 0000000..89cf784 --- /dev/null +++ b/src/middleware/authMiddleware.js @@ -0,0 +1,263 @@ +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, +}; diff --git a/src/middleware/domainChecker.js b/src/middleware/domainChecker.js new file mode 100644 index 0000000..457a2cb --- /dev/null +++ b/src/middleware/domainChecker.js @@ -0,0 +1,48 @@ +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; diff --git a/src/middleware/redisRateLimiter.js b/src/middleware/redisRateLimiter.js new file mode 100644 index 0000000..9665311 --- /dev/null +++ b/src/middleware/redisRateLimiter.js @@ -0,0 +1,146 @@ +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, +}; diff --git a/src/middleware/validation.js b/src/middleware/validation.js new file mode 100644 index 0000000..b3a5038 --- /dev/null +++ b/src/middleware/validation.js @@ -0,0 +1,115 @@ +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, +}; diff --git a/src/models.rs b/src/models.rs deleted file mode 100644 index 57e1615..0000000 --- a/src/models.rs +++ /dev/null @@ -1,101 +0,0 @@ -// 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 owner_id: String, - 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, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct User { - pub id: String, - pub username: String, - #[serde(skip_serializing)] // Never send password in responses - pub password: Option, - pub role: String, - pub created_at: DateTime, -} - -// Used for user registration -#[derive(Debug, Serialize, Deserialize)] -pub struct UserRegistration { - pub username: String, - pub password: String, -} - -// Used for user profile updates -#[derive(Debug, Serialize, Deserialize)] -pub struct UserUpdate { - pub username: Option, - pub password: Option, -} diff --git a/src/models/User.js b/src/models/User.js new file mode 100644 index 0000000..2fe2777 --- /dev/null +++ b/src/models/User.js @@ -0,0 +1,400 @@ +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; diff --git a/src/notifications.rs b/src/notifications.rs deleted file mode 100644 index 8f0503e..0000000 --- a/src/notifications.rs +++ /dev/null @@ -1,148 +0,0 @@ -use anyhow::Result; -use lettre::message::header::ContentType; -use lettre::transport::smtp::authentication::Credentials; -use lettre::{Message, SmtpTransport, Transport}; -use serde::Serialize; -use std::env; - -#[derive(Debug, Serialize)] -pub struct NotificationConfig { - smtp_host: String, - smtp_port: u16, - smtp_username: String, - smtp_password: String, - from_email: String, - ntfy_topic: String, - ntfy_server: String, -} - -impl Default for NotificationConfig { - fn default() -> Self { - Self { - smtp_host: String::new(), - smtp_port: 587, - smtp_username: String::new(), - smtp_password: String::new(), - from_email: String::new(), - ntfy_topic: String::new(), - ntfy_server: "https://ntfy.sh".to_string(), - } - } -} - -impl NotificationConfig { - pub fn from_env() -> Result { - Ok(Self { - smtp_host: env::var("SMTP_HOST")?, - smtp_port: env::var("SMTP_PORT")?.parse()?, - smtp_username: env::var("SMTP_USERNAME")?, - smtp_password: env::var("SMTP_PASSWORD")?, - from_email: env::var("FROM_EMAIL")?, - ntfy_topic: env::var("NTFY_TOPIC")?, - ntfy_server: env::var("NTFY_SERVER").unwrap_or_else(|_| "https://ntfy.sh".to_string()), - }) - } - - pub fn is_email_configured(&self) -> bool { - !self.smtp_host.is_empty() - && !self.smtp_username.is_empty() - && !self.smtp_password.is_empty() - && !self.from_email.is_empty() - } - - pub fn is_ntfy_configured(&self) -> bool { - !self.ntfy_topic.is_empty() - } -} - -pub struct NotificationService { - config: NotificationConfig, -} - -impl NotificationService { - pub fn new(config: NotificationConfig) -> Self { - Self { config } - } - - pub async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { - if !self.config.is_email_configured() { - return Ok(()); - } - - let email = Message::builder() - .from(self.config.from_email.parse()?) - .to(to.parse()?) - .subject(subject) - .header(ContentType::TEXT_PLAIN) - .body(body.to_string())?; - - let creds = Credentials::new( - self.config.smtp_username.clone(), - self.config.smtp_password.clone(), - ); - - let mailer = SmtpTransport::relay(&self.config.smtp_host)? - .port(self.config.smtp_port) - .credentials(creds) - .build(); - - mailer.send(&email)?; - Ok(()) - } - - pub fn send_ntfy(&self, title: &str, message: &str, priority: Option) -> Result<()> { - if !self.config.is_ntfy_configured() { - return Ok(()); - } - - let url = format!("{}/{}", self.config.ntfy_server, self.config.ntfy_topic); - - let mut request = ureq::post(&url).set("Title", title); - - if let Some(p) = priority { - request = request.set("Priority", &p.to_string()); - } - - request.send_string(message)?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_notification_config() { - std::env::set_var("SMTP_HOST", "smtp.example.com"); - std::env::set_var("SMTP_PORT", "587"); - std::env::set_var("SMTP_USERNAME", "test@example.com"); - std::env::set_var("SMTP_PASSWORD", "password"); - std::env::set_var("FROM_EMAIL", "noreply@example.com"); - std::env::set_var("NTFY_TOPIC", "my-topic"); - - let config = NotificationConfig::from_env().unwrap(); - assert_eq!(config.smtp_host, "smtp.example.com"); - assert_eq!(config.smtp_port, 587); - assert_eq!(config.ntfy_server, "https://ntfy.sh"); - } - - #[test] - fn test_config_validation() { - let default_config = NotificationConfig::default(); - assert!(!default_config.is_email_configured()); - assert!(!default_config.is_ntfy_configured()); - - let config = NotificationConfig { - smtp_host: "smtp.example.com".to_string(), - smtp_port: 587, - smtp_username: "user".to_string(), - smtp_password: "pass".to_string(), - from_email: "test@example.com".to_string(), - ntfy_topic: "topic".to_string(), - ntfy_server: "https://ntfy.sh".to_string(), - }; - assert!(config.is_email_configured()); - assert!(config.is_ntfy_configured()); - } -} diff --git a/src/routes/api_v1.js b/src/routes/api_v1.js new file mode 100644 index 0000000..4c96034 --- /dev/null +++ b/src/routes/api_v1.js @@ -0,0 +1,97 @@ +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; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..fac030e --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,765 @@ +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; diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js new file mode 100644 index 0000000..cc2d091 --- /dev/null +++ b/src/routes/dashboard.js @@ -0,0 +1,774 @@ +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; diff --git a/src/routes/public.js b/src/routes/public.js new file mode 100644 index 0000000..d8555af --- /dev/null +++ b/src/routes/public.js @@ -0,0 +1,214 @@ +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( + '

    Thank You!

    Your submission has been received.

    Back to form manager

    ' + ); + } catch (error) { + console.error("Error processing submission:", error); + await sendNtfyNotification( + `Submission Error: ${formNameForNotification}`, + `Failed to process submission for ${formUuid} from IP ${ipAddress}. Error: ${error.message}`, + "max" + ); + res.status(500).send("Error processing submission."); + } + } +); + +module.exports = router; diff --git a/src/services/emailService.js b/src/services/emailService.js new file mode 100644 index 0000000..ad7ba15 --- /dev/null +++ b/src/services/emailService.js @@ -0,0 +1,450 @@ +const nodemailer = require("nodemailer"); +require("dotenv").config(); // Ensure environment variables are loaded +const { Resend } = require("resend"); +const logger = require("../../config/logger"); // Adjust path as needed + +const resendApiKey = process.env.RESEND_API_KEY; +const emailFromAddress = process.env.EMAIL_FROM_ADDRESS; + +if (!resendApiKey) { + logger.warn( + "RESEND_API_KEY is not set. Email notifications will be disabled." + ); +} +if (!emailFromAddress) { + logger.warn( + "EMAIL_FROM_ADDRESS is not set. Email notifications may not work correctly." + ); +} + +const resend = resendApiKey ? new Resend(resendApiKey) : null; + +class EmailService { + constructor() { + this.transporter = null; + this.init(); + } + + async init() { + try { + // Create reusable transporter object using the default SMTP transport + this.transporter = nodemailer.createTransporter({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT || 587, + secure: process.env.SMTP_SECURE === "true", // true for 465, false for other ports + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + // Verify connection configuration + if (this.transporter && process.env.SMTP_HOST) { + await this.transporter.verify(); + console.log("Email service initialized successfully"); + } else { + console.warn( + "Email service not configured. Set SMTP environment variables." + ); + } + } catch (error) { + console.error("Email service initialization failed:", error.message); + this.transporter = null; + } + } + + // Check if email service is available + isAvailable() { + return this.transporter !== null; + } + + // Send verification email + async sendVerificationEmail(to, firstName, verificationToken) { + if (!this.isAvailable()) { + console.warn("Email service not available. Verification email not sent."); + return false; + } + + const verificationUrl = `${process.env.APP_URL}/auth/verify-email?token=${verificationToken}`; + + const subject = "Verify Your Email Address - Formies"; + const html = this.getVerificationEmailTemplate(firstName, verificationUrl); + + try { + await this.transporter.sendMail({ + from: `"Formies" <${ + process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER + }>`, + to, + subject, + html, + }); + + console.log(`Verification email sent to ${to}`); + return true; + } catch (error) { + console.error("Failed to send verification email:", error); + return false; + } + } + + // Send password reset email + async sendPasswordResetEmail(to, firstName, resetToken) { + if (!this.isAvailable()) { + console.warn( + "Email service not available. Password reset email not sent." + ); + return false; + } + + const resetUrl = `${process.env.APP_URL}/auth/reset-password?token=${resetToken}`; + + const subject = "Password Reset Request - Formies"; + const html = this.getPasswordResetEmailTemplate(firstName, resetUrl); + + try { + await this.transporter.sendMail({ + from: `"Formies" <${ + process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER + }>`, + to, + subject, + html, + }); + + console.log(`Password reset email sent to ${to}`); + return true; + } catch (error) { + console.error("Failed to send password reset email:", error); + return false; + } + } + + // Send welcome email + async sendWelcomeEmail(to, firstName) { + if (!this.isAvailable()) { + console.warn("Email service not available. Welcome email not sent."); + return false; + } + + const subject = "Welcome to Formies!"; + const html = this.getWelcomeEmailTemplate(firstName); + + try { + await this.transporter.sendMail({ + from: `"Formies" <${ + process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER + }>`, + to, + subject, + html, + }); + + console.log(`Welcome email sent to ${to}`); + return true; + } catch (error) { + console.error("Failed to send welcome email:", error); + return false; + } + } + + // Send password changed notification + async sendPasswordChangedEmail(to, firstName) { + if (!this.isAvailable()) { + return false; + } + + const subject = "Password Changed Successfully - Formies"; + const html = this.getPasswordChangedEmailTemplate(firstName); + + try { + await this.transporter.sendMail({ + from: `"Formies" <${ + process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER + }>`, + to, + subject, + html, + }); + + return true; + } catch (error) { + console.error("Failed to send password changed email:", error); + return false; + } + } + + // Email templates + getVerificationEmailTemplate(firstName, verificationUrl) { + return ` + + + + + + +
    +
    +

    Welcome to Formies!

    +
    +
    +

    Hi ${firstName || "there"},

    +

    Thank you for signing up for Formies! To complete your registration, please verify your email address by clicking the button below:

    +

    + Verify Email Address +

    +

    If the button doesn't work, you can copy and paste this link into your browser:

    +

    ${verificationUrl}

    +

    This link will expire in 24 hours.

    +

    If you didn't create an account with Formies, you can safely ignore this email.

    +
    + +
    + + + `; + } + + getPasswordResetEmailTemplate(firstName, resetUrl) { + return ` + + + + + + +
    +
    +

    Password Reset Request

    +
    +
    +

    Hi ${firstName || "there"},

    +

    We received a request to reset your password for your Formies account. If you made this request, click the button below to reset your password:

    +

    + Reset Password +

    +

    If the button doesn't work, you can copy and paste this link into your browser:

    +

    ${resetUrl}

    +

    This link will expire in 1 hour.

    +

    If you didn't request a password reset, you can safely ignore this email. Your password won't be changed.

    +
    + +
    + + + `; + } + + getWelcomeEmailTemplate(firstName) { + return ` + + + + + + +
    +
    +

    Welcome to Formies!

    +
    +
    +

    Hi ${firstName || "there"},

    +

    Welcome to Formies! Your email has been verified and your account is now active.

    +

    You can now start creating beautiful forms and collecting submissions. Here's what you can do:

    +
      +
    • Create unlimited forms
    • +
    • Customize form fields and styling
    • +
    • Receive instant notifications
    • +
    • Export your data anytime
    • +
    +

    + Go to Dashboard +

    +
    + +
    + + + `; + } + + getPasswordChangedEmailTemplate(firstName) { + return ` + + + + + + +
    +
    +

    Password Changed

    +
    +
    +

    Hi ${firstName || "there"},

    +

    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 +}; diff --git a/src/services/jwtService.js b/src/services/jwtService.js new file mode 100644 index 0000000..32603ee --- /dev/null +++ b/src/services/jwtService.js @@ -0,0 +1,272 @@ +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(); diff --git a/src/services/notification.js b/src/services/notification.js new file mode 100644 index 0000000..78a55e3 --- /dev/null +++ b/src/services/notification.js @@ -0,0 +1,31 @@ +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 }; diff --git a/src/utils/apiKeyHelper.js b/src/utils/apiKeyHelper.js new file mode 100644 index 0000000..9ca2c29 --- /dev/null +++ b/src/utils/apiKeyHelper.js @@ -0,0 +1,51 @@ +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, +}; diff --git a/src/utils/recaptchaHelper.js b/src/utils/recaptchaHelper.js new file mode 100644 index 0000000..803aef7 --- /dev/null +++ b/src/utils/recaptchaHelper.js @@ -0,0 +1,56 @@ +// 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 }; diff --git a/tests/handlers_test.rs b/tests/handlers_test.rs deleted file mode 100644 index 8b13789..0000000 --- a/tests/handlers_test.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/views/dashboard.ejs b/views/dashboard.ejs new file mode 100644 index 0000000..30bf852 --- /dev/null +++ b/views/dashboard.ejs @@ -0,0 +1,356 @@ + + + + + + User Dashboard - Formies + + + + + + +
    + + <% if (view === 'my_forms') { %> +
    +

    My Forms

    + + Create New Form +
    + <%- include('partials/_forms_table', { forms: forms, appUrl: appUrl }) %> + <% } else if (view === 'create_form') { %> +

    Create New Form

    + +
    + + +
    + + <% if (typeof error !== 'undefined' && error) { %> +

    <%= error %>

    + <% } %> + + <% } else if (view === 'form_submissions') { %> <%- + include('partials/_submissions_view', { submissions: submissions, + formUuid: formUuid, formName: formName, pagination: pagination, appUrl: + appUrl }) %> <% } else if (view === 'account_settings') { %> +

    Account Settings

    +

    Account settings will be here.

    + <% } else if (view === 'form_settings') { %> +
    +

    + Settings for <%= formName %> +

    + Back to My Forms +
    + + <% if (typeof successMessage !== 'undefined' && successMessage) { %> +
    + <%= successMessage %> +
    + <% } %> <% if (typeof errorMessage !== 'undefined' && errorMessage) { %> +
    + <%= errorMessage %> +
    + <% } %> + +
    +

    General Settings

    +
    + + +
    + +

    Email Notifications

    +
    + + style="margin-right: 0.5rem;"> + +
    +
    + + + If left blank, notifications will be sent to your account email: + <%= user.email %> +
    + +

    Spam Protection

    +
    + + style="margin-right: 0.5rem;"> + + Uses the globally configured site and secret keys. Ensure these are set in your server's .env file. +
    + +

    Thank You Page

    +
    + + +
    +
    + + + + If a "Thank You URL" is provided, it will be used. Otherwise, this custom message will be shown. If both are blank, a default message is used. + +
    + +

    Allowed Domains

    +
    + + + + Comma-separated list of domains. Leave blank to allow submissions from any domain. + +
    + + +
    + + <% } else if (view === 'api_keys') { %> +
    +

    API Keys

    +
    + + <% if (typeof successMessage !== 'undefined' && successMessage) { %> +
    + <%= successMessage %> +
    + <% } %> + <% if (typeof errorMessage !== 'undefined' && errorMessage) { %> +
    + <%= errorMessage %> +
    + <% } %> + + <% if (typeof newlyGeneratedApiKey !== 'undefined' && newlyGeneratedApiKey) { %> +
    +

    New API Key Generated: <%= newlyGeneratedApiKeyName %>

    +

    Important: This is the only time you will see this API key. Copy it now and store it securely.

    +
    <%= newlyGeneratedApiKey %>
    + +
    + <% } %> + +
    +

    Generate New API Key

    +
    +
    + + +
    + +
    +
    + +
    +

    Your API Keys

    + <% if (apiKeys && apiKeys.length > 0) { %> + + + + + + + + + + + + <% apiKeys.forEach(key => { %> + + + + + + + + <% }) %> + +
    NameIdentifier (Prefix)Created AtLast UsedActions
    <%= key.key_name %><%= key.api_key_identifier %><%= new Date(key.created_at).toLocaleDateString() %><%= key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' %> +
    + +
    +
    + <% } else { %> +

    You have not generated any API keys yet.

    + <% } %> +
    + + <% } %> +
    + + + + diff --git a/views/partials/_forms_table.ejs b/views/partials/_forms_table.ejs new file mode 100644 index 0000000..2fd01e5 --- /dev/null +++ b/views/partials/_forms_table.ejs @@ -0,0 +1,51 @@ +<% if (forms && forms.length > 0) { %> + + + + + + + + + + + + + <% forms.forEach(form => { %> + + + + + + + + + <% }); %> + +
    Form NameSubmissionsEndpoint URLCreated DateStatusActions
    <%= form.name %><%= form.submission_count %> + <%= appUrl %>/submit/<%= form.uuid %> + + <%= new Date(form.created_at).toLocaleDateString() %><%= form.is_archived ? 'Archived' : 'Active' %> + View Submissions + Settings + +
    + +
    +
    + +
    +
    +<% } else { %> +

    You haven't created any forms yet. Create one now!

    +<% } %> + + \ No newline at end of file diff --git a/views/partials/_submissions_view.ejs b/views/partials/_submissions_view.ejs new file mode 100644 index 0000000..888ad62 --- /dev/null +++ b/views/partials/_submissions_view.ejs @@ -0,0 +1,176 @@ +
    +

    + Submissions for <%= formName %> +

    + +
    + +<% if (submissions.length === 0) { %> +
    + No submissions yet for this form. +
    +<% } else { %> <% submissions.forEach(submission => { %> +
    +
    +
    +
    +
    + Submitted: <%= new Date(submission.submitted_at).toLocaleString() %> +
    +
    + IP: <%= submission.ip_address %> +
    +
    +
    + + +
    +
    +
    +
    + <% let data = {}; try { data = JSON.parse(submission.data); } catch (e) { + console.error("Failed to parse submission data:", submission.data); data = + { "error": "Could not parse submission data" }; } %> <% + Object.entries(data).forEach(([key, value]) => { %> <% if (key !== + 'honeypot_field' && key !== '_thankyou') { %> +
    + <%= key %>: + <%= value %> +
    + <% } %> <% }); %> +
    +
    +
    +<% }); %> + + +<% if (pagination.totalPages > 1) { %> + +
    + Showing <%= (pagination.currentPage - 1) * pagination.limit + 1 %> to <%= + Math.min(pagination.currentPage * pagination.limit, + pagination.totalSubmissions) %> of <%= pagination.totalSubmissions %> + submissions +
    +<% } %> <% } %>