This file is a merged representation of the entire codebase, combined into a single document by Repomix. This section contains a summary of this file. This file contains a packed representation of the entire repository's contents. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. The content is organized as follows: 1. This summary section 2. Repository information 3. Directory structure 4. Repository files, each consisting of: - File path as an attribute - Full contents of the file - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. - When processing this file, use the file path to distinguish between different files in the repository. - Be aware that this file may contain sensitive information. Handle it with the same level of security as you would the original repository. - Some files may have been excluded based on .gitignore rules and Repomix's configuration - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files - Files matching patterns in .gitignore are excluded - Files matching default ignore patterns are excluded - Files are sorted by Git change count (files with more changes are at the bottom) .cursor/rules/mvp-scope.mdc .env.test .gitignore API_DOCUMENTATION.md AUTHENTICATION_SETUP.md config/logger.js docker-compose.prod.yml docker-compose.yml Dockerfile 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 .env package-lock.json node_modules # 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. # 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. 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; 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: 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: FROM node:18.19-alpine AS builder WORKDIR /usr/src/app COPY package*.json ./ RUN npm ci COPY . . FROM node:18.19-alpine WORKDIR /usr/src/app # Create a non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY --from=builder /usr/src/app/node_modules ./node_modules COPY --from=builder /usr/src/app/package*.json ./ COPY --from=builder /usr/src/app/ ./ # Set ownership to non-root user RUN chown -R appuser:appgroup /usr/src/app USER appuser EXPOSE 3000 CMD ["node", "server.js"] -- 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; // 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:

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') { %> <%- 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 => { %> <% }) %>
Name Identifier (Prefix) Created At Last Used Actions
<%= 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 Name Submissions Endpoint URL Created Date Status Actions
<%= 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 %>

Back to My Forms Export CSV
<% 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
<% } %> <% } %>