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(
'
This email confirms that your password has been successfully changed for your Formies account.
If you didn't make this change, please contact our support team immediately.
For your security, here are some tips:
Use a strong, unique password
Don't share your password with anyone
Consider using a password manager
`;
}
}
/**
* Generates a simple HTML body for the submission notification email.
* @param {string} formName - The name of the form.
* @param {object} submissionData - The data submitted to the form.
* @returns {string} - HTML string for the email body.
*/
function createEmailHtmlBody(formName, submissionData) {
let body = `
You have a new submission for your form: ${formName}.
`;
body += "
Here are the details:
";
for (const [key, value] of Object.entries(submissionData)) {
// Exclude honeypot and other internal fields if necessary
if (key.toLowerCase() !== "honeypot_field" && key !== "_thankyou") {
body += `
${key}: ${value}
`;
}
}
body += "
Thank you for using Formies!
";
return body;
}
/**
* Sends a submission notification email.
* @param {object} form - Form details (name, email_notifications_enabled, notification_email_address).
* @param {object} submissionData - The actual data submitted to the form.
* @param {string} userOwnerEmail - The email of the user who owns the form.
*/
async function sendSubmissionNotification(
form,
submissionData,
userOwnerEmail
) {
if (!resend) {
logger.warn(
"Resend SDK not initialized due to missing API key. Skipping email notification."
);
return;
}
if (!emailFromAddress) {
logger.warn(
"EMAIL_FROM_ADDRESS not configured. Skipping email notification."
);
return;
}
if (!form || !form.email_notifications_enabled) {
logger.info(
`Email notifications are disabled for form: ${form ? form.name : "Unknown Form"}. Skipping.`
);
return;
}
const recipientEmail = form.notification_email_address || userOwnerEmail;
if (!recipientEmail) {
logger.warn(
`No recipient email address found for form: ${form.name}. Skipping notification.`
);
return;
}
const subject = `New Submission for Form: ${form.name}`;
const htmlBody = createEmailHtmlBody(form.name, submissionData);
try {
const { data, error } = await resend.emails.send({
from: emailFromAddress,
to: recipientEmail,
subject: subject,
html: htmlBody,
});
if (error) {
logger.error("Error sending submission email via Resend:", error);
// Do not let email failure break the submission flow (as per 2.3.4)
return; // Or throw a specific error to be caught upstream if needed for more complex handling
}
logger.info(
`Submission email sent successfully to ${recipientEmail} for form ${form.name}. Message ID: ${data ? data.id : "N/A"}`
);
} catch (err) {
logger.error("Exception caught while sending submission email:", err);
// Do not let email failure break the submission flow
}
}
module.exports = {
sendSubmissionNotification,
// Potentially export createEmailHtmlBody if it needs to be used elsewhere or for testing
};
const jwt = require("jsonwebtoken");
const { v4: uuidv4 } = require("uuid");
const User = require("../models/User");
class JWTService {
constructor() {
this.secret = process.env.JWT_SECRET;
this.issuer = process.env.JWT_ISSUER || "formies";
this.audience = process.env.JWT_AUDIENCE || "formies-users";
this.accessTokenExpiry = process.env.JWT_ACCESS_EXPIRY || "15m";
this.refreshTokenExpiry = process.env.JWT_REFRESH_EXPIRY || "7d";
if (!this.secret) {
throw new Error("JWT_SECRET environment variable is required");
}
}
// Generate access token
generateAccessToken(user, sessionInfo = {}) {
const jti = uuidv4(); // JWT ID for token tracking
const payload = {
sub: user.id, // Subject (user ID)
email: user.email,
role: user.role,
jti: jti,
type: "access",
};
const options = {
issuer: this.issuer,
audience: this.audience,
expiresIn: this.accessTokenExpiry,
};
const token = jwt.sign(payload, this.secret, options);
const decoded = jwt.decode(token);
// Save session for token tracking
const expiresAt = new Date(decoded.exp * 1000);
User.saveSession(
user.id,
jti,
expiresAt,
sessionInfo.userAgent,
sessionInfo.ipAddress
).catch(console.error);
return {
token,
expiresAt,
jti,
};
}
// Generate refresh token
generateRefreshToken(user, sessionInfo = {}) {
const jti = uuidv4();
const payload = {
sub: user.id,
jti: jti,
type: "refresh",
};
const options = {
issuer: this.issuer,
audience: this.audience,
expiresIn: this.refreshTokenExpiry,
};
const token = jwt.sign(payload, this.secret, options);
const decoded = jwt.decode(token);
// Save session for token tracking
const expiresAt = new Date(decoded.exp * 1000);
User.saveSession(
user.id,
jti,
expiresAt,
sessionInfo.userAgent,
sessionInfo.ipAddress
).catch(console.error);
return {
token,
expiresAt,
jti,
};
}
// Generate token pair (access + refresh)
generateTokenPair(user, sessionInfo = {}) {
const accessToken = this.generateAccessToken(user, sessionInfo);
const refreshToken = this.generateRefreshToken(user, sessionInfo);
return {
accessToken: accessToken.token,
refreshToken: refreshToken.token,
accessTokenExpiresAt: accessToken.expiresAt,
refreshTokenExpiresAt: refreshToken.expiresAt,
tokenType: "Bearer",
};
}
// Verify and decode token
verifyToken(token, tokenType = "access") {
try {
const options = {
issuer: this.issuer,
audience: this.audience,
};
const decoded = jwt.verify(token, this.secret, options);
// Check token type
if (decoded.type !== tokenType) {
throw new Error(`Invalid token type. Expected ${tokenType}`);
}
return decoded;
} catch (error) {
if (error.name === "TokenExpiredError") {
throw new Error("Token has expired");
} else if (error.name === "JsonWebTokenError") {
throw new Error("Invalid token");
} else if (error.name === "NotBeforeError") {
throw new Error("Token not active yet");
}
throw error;
}
}
// Refresh access token using refresh token
async refreshAccessToken(refreshToken, sessionInfo = {}) {
try {
// Verify refresh token
const decoded = this.verifyToken(refreshToken, "refresh");
// Check if token is blacklisted
const isBlacklisted = await User.isTokenBlacklisted(decoded.jti);
if (isBlacklisted) {
throw new Error("Refresh token has been revoked");
}
// Get user
const user = await User.findById(decoded.sub);
if (!user || !user.is_active) {
throw new Error("User not found or inactive");
}
// Generate new access token
const newAccessToken = this.generateAccessToken(user, sessionInfo);
return {
accessToken: newAccessToken.token,
accessTokenExpiresAt: newAccessToken.expiresAt,
tokenType: "Bearer",
};
} catch (error) {
throw error;
}
}
// Revoke token (add to blacklist)
async revokeToken(token) {
try {
const decoded = jwt.decode(token);
if (!decoded || !decoded.jti) {
throw new Error("Invalid token format");
}
await User.revokeSession(decoded.jti);
return true;
} catch (error) {
console.error("Error revoking token:", error);
return false;
}
}
// Revoke all user tokens
async revokeAllUserTokens(userId) {
try {
const revokedCount = await User.revokeAllUserSessions(userId);
return revokedCount;
} catch (error) {
console.error("Error revoking all user tokens:", error);
return 0;
}
}
// Revoke all user tokens except one
async revokeAllUserTokensExcept(userId, exceptJti) {
try {
const revokedCount = await User.revokeAllUserSessionsExcept(
userId,
exceptJti
);
return revokedCount;
} catch (error) {
console.error("Error revoking user tokens:", error);
return 0;
}
}
// Extract token from Authorization header
extractTokenFromHeader(authHeader) {
if (!authHeader) {
return null;
}
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
}
return parts[1];
}
// Get token info without verification
getTokenInfo(token) {
try {
return jwt.decode(token);
} catch (error) {
return null;
}
}
// Check if token is expired (without verifying signature)
isTokenExpired(token) {
const decoded = this.getTokenInfo(token);
if (!decoded || !decoded.exp) {
return true;
}
return Date.now() >= decoded.exp * 1000;
}
// Cleanup expired sessions (call this periodically)
async cleanupExpiredSessions() {
try {
const cleanedCount = await User.cleanupExpiredSessions();
console.log(`Cleaned up ${cleanedCount} expired sessions`);
return cleanedCount;
} catch (error) {
console.error("Error cleaning up expired sessions:", error);
return 0;
}
}
// Get current session information
async getCurrentSession(token) {
try {
const decoded = this.verifyToken(token);
const session = await User.getSessionByJti(decoded.jti);
if (!session) {
throw new Error("Session not found");
}
return {
jti: session.token_jti,
userAgent: session.user_agent,
ipAddress: session.ip_address,
createdAt: session.created_at,
expiresAt: session.expires_at,
};
} catch (error) {
throw error;
}
}
}
module.exports = new JWTService();
async function sendNtfyNotification(
title,
message,
priority = "default",
tags = ""
) {
if (process.env.NTFY_ENABLED !== "true" || !process.env.NTFY_TOPIC_URL) {
return;
}
try {
const response = await fetch(process.env.NTFY_TOPIC_URL, {
method: "POST",
body: message,
headers: {
Title: title,
Priority: priority,
Tags: tags,
"Content-Type": "text/plain",
},
});
if (!response.ok) {
console.error(`Ntfy error: ${response.status} ${await response.text()}`);
} else {
console.log("Ntfy notification sent successfully.");
}
} catch (error) {
console.error("Failed to send Ntfy notification:", error);
}
}
module.exports = { sendNtfyNotification };
const crypto = require("crypto");
const bcrypt = require("bcryptjs");
const API_KEY_IDENTIFIER_PREFIX = "fsk"; // Formies Secret Key
const API_KEY_IDENTIFIER_LENGTH = 12; // Length of the random part of the identifier
const API_KEY_SECRET_LENGTH = 32; // Length of the secret part in bytes, results in 2x hex string length
/**
* Generates a new API key parts: the full key (to show to user once) and its components for storage.
* Identifier: A public, non-secret unique string for lookup (e.g., 'fsk_abcdef123').
* Secret: A cryptographically strong random string.
* Full Key: Identifier + '_' + Secret (this is what the user gets).
* @returns {{ fullApiKey: string, identifier: string, secret: string }}
*/
function generateApiKeyParts() {
const randomIdentifierPart = crypto
.randomBytes(Math.ceil(API_KEY_IDENTIFIER_LENGTH / 2))
.toString("hex")
.slice(0, API_KEY_IDENTIFIER_LENGTH);
const identifier = `${API_KEY_IDENTIFIER_PREFIX}_${randomIdentifierPart}`;
const secret = crypto.randomBytes(API_KEY_SECRET_LENGTH).toString("hex");
const fullApiKey = `${identifier}_${secret}`;
return { fullApiKey, identifier, secret };
}
/**
* Hashes an API key secret using bcrypt.
* @param {string} apiKeySecret - The secret part of the API key.
* @returns {Promise} - The hashed API key secret.
*/
async function hashApiKeySecret(apiKeySecret) {
const saltRounds = 10; // Standard practice
return bcrypt.hash(apiKeySecret, saltRounds);
}
/**
* Compares a plain text API key secret with a stored hashed secret.
* @param {string} plainTextSecret - The plain text secret part provided by the user.
* @param {string} hashedSecret - The stored hashed secret from the database.
* @returns {Promise} - True if the secrets match, false otherwise.
*/
async function compareApiKeySecret(plainTextSecret, hashedSecret) {
return bcrypt.compare(plainTextSecret, hashedSecret);
}
module.exports = {
generateApiKeyParts,
hashApiKeySecret,
compareApiKeySecret,
API_KEY_IDENTIFIER_PREFIX,
};
// Native fetch is available in Node.js 18+ and doesn't need to be imported
// const logger = require("../../config/logger"); // Adjust path as needed
const RECAPTCHA_V2_SECRET_KEY = process.env.RECAPTCHA_V2_SECRET_KEY;
const GOOGLE_RECAPTCHA_VERIFY_URL =
"https://www.google.com/recaptcha/api/siteverify";
/**
* Verifies a Google reCAPTCHA v2 response.
* @param {string} recaptchaToken - The g-recaptcha-response token from the client.
* @param {string} [clientIp] - Optional. The user's IP address.
* @returns {Promise} - True if verification is successful, false otherwise.
*/
async function verifyRecaptchaV2(recaptchaToken, clientIp) {
if (!RECAPTCHA_V2_SECRET_KEY) {
console.warn(
"RECAPTCHA_V2_SECRET_KEY is not set. Skipping reCAPTCHA verification. THIS IS INSECURE FOR PRODUCTION."
);
// In a real scenario, you might want to fail open or closed based on policy
// For now, let's assume if it's not set, we can't verify, so effectively it fails if meant to be checked.
// However, the calling route will decide if reCAPTCHA is mandatory.
return false; // Or true if you want to bypass if not configured, though less secure.
}
if (!recaptchaToken) {
console.warn("No reCAPTCHA token provided by client.");
return false;
}
const verificationUrl = `${GOOGLE_RECAPTCHA_VERIFY_URL}?secret=${RECAPTCHA_V2_SECRET_KEY}&response=${recaptchaToken}`;
// Add remoteip if provided
const finalUrl = clientIp
? `${verificationUrl}&remoteip=${clientIp}`
: verificationUrl;
try {
const response = await fetch(finalUrl, { method: "POST" });
const data = await response.json();
if (data.success) {
console.info("reCAPTCHA verification successful.");
return true;
} else {
console.warn(
"reCAPTCHA verification failed.",
data["error-codes"] || "No error codes"
);
return false;
}
} catch (error) {
console.error("Error during reCAPTCHA verification request:", error);
return false;
}
}
module.exports = { verifyRecaptchaV2 };
User Dashboard - Formies