Update environment configuration, add API documentation, and implement user authentication system

- Updated `.env` and added `.env.test` for environment variables.
- Introduced API documentation in `API_DOCUMENTATION.md`.
- Added authentication setup guide in `AUTHENTICATION_SETUP.md`.
- Implemented user authentication with JWT and email verification.
- Created new routes for user management and form submissions.
- Added middleware for API key authentication and error handling.
- Set up Redis for rate limiting and notifications.
- Removed obsolete files and configurations related to the previous Rust implementation.
This commit is contained in:
Mohamad.Elsena 2025-05-28 11:18:35 +02:00
parent 1b012b3923
commit 2927013a6d
58 changed files with 13024 additions and 13702 deletions

View File

@ -0,0 +1,77 @@
---
description:
globs:
alwaysApply: false
---
Objective: Deliver the minimum set of features a user would expect from a basic form backend service.
use notes.md to track progress!
Task 2.1: User Dashboard & Form Management UI (Replacing current "admin")
* Mindset Shift: This is no longer your admin panel. It's the user's control center.
* Subtask 2.1.1: Design User Dashboard Layout:
* [ ] Wireframe basic layout: List forms, create form, account settings (placeholder).
* [ ] Decide on frontend tech (EJS is fine for MVP if you make it dynamic with client-side JS, or consider a simple SPA framework if comfortable).
* Subtask 2.1.2: "My Forms" View:
* [ ] Fetch and display forms owned by the logged-in user.
* [ ] Show key info: name, submission count, endpoint URL, created date.
* [ ] Links to: view submissions, edit settings, delete.
* Subtask 2.1.3: "Create New Form" Functionality (for logged-in user):
* [ ] UI and backend logic. Associates form with req.user.id.
* Subtask 2.1.4: "View Form Submissions" (Scoped & Paginated):
* [ ] UI and backend for a user to view submissions for their specific form.
* [ ] Pagination is critical here (as you have).
* Subtask 2.1.5: Form Settings UI (Basic):
* [ ] Allow users to update form name.
* [ ] Placeholder for future settings (thank you URL, notifications).
* Subtask 2.1.6: Delete Form/Submission (with soft delete/archival consideration):
* [ ] You have is_archived. Solidify this. Users should be able to archive/unarchive.
* [ ] True delete should be a confirmed, rare operation.
Task 2.2: Per-Form Configuration by User
* Mindset Shift: Empower users to customize their form behavior.
* Subtask 2.2.1: Database Schema Updates for forms Table:
* [ ] Review existing fields (thank_you_url, thank_you_message, ntfy_enabled, allowed_domains). These are good.
* [ ] Add email_notifications_enabled (boolean).
* [ ] Add notification_email_address (string, defaults to user's email, but allow override).
* Subtask 2.2.2: UI for Form Settings Page:
* [ ] Create a dedicated page/modal for each form's settings.
* [ ] Allow users to edit: Name, Thank You URL, Thank You Message, Allowed Domains, Email Notification toggle, Notification Email Address.
* Subtask 2.2.3: Backend to Save and Apply Settings:
* [ ] API endpoints to update these settings for a specific form (owned by user).
* [ ] Logic in /submit/:formUuid to use these form-specific settings.
Task 2.3: Email Notifications for Submissions (Core Feature)
* Mindset Shift: Ntfy is cool for you. Users expect email.
* Subtask 2.3.1: Integrate Transactional Email Service:
* [ ] Sign up for SendGrid, Mailgun, AWS SES (free tiers available).
* [ ] Install their SDK. Store API key securely (env vars).
* Subtask 2.3.2: Email Sending Logic:
* [ ] Create a service/function sendSubmissionNotification(form, submissionData).
* [ ] If email_notifications_enabled for the form, send an email to notification_email_address.
* Subtask 2.3.3: Basic Email Template:
* [ ] Simple, clear email: "New Submission for [Form Name]", list submitted data.
* Subtask 2.3.4: Error Handling for Email Sending:
* [ ] Log errors if email fails to send; don't let it break the submission flow.
Task 2.4: Enhanced Spam Protection (Beyond Basic Honeypot)
* Mindset Shift: Your honeypot is step 1. Real services need more.
* Subtask 2.4.1: Integrate CAPTCHA (e.g., Google reCAPTCHA):
* [ ] Sign up for reCAPTCHA (v2 "I'm not a robot" or v3 invisible). Get site/secret keys.
* [ ] Frontend: Add reCAPTCHA widget/JS to user's HTML form example.
* [ ] Backend: /submit/:formUuid endpoint must verify reCAPTCHA token with Google.
* Subtask 2.4.2: User Configuration for Spam Protection:
* [ ] Allow users to enable/disable reCAPTCHA for their forms (and input their own site key if they want, or use a global one you provide).
* Subtask 2.4.3: (Future Consideration) Akismet / Content Analysis.
Task 2.5: Basic API for Users to Access Their Data
* Mindset Shift: Power users and integrations need an API.
* Subtask 2.5.1: API Key Generation & Management:
* [ ] Allow users to generate/revoke API keys from their dashboard.
* [ ] Store hashed API keys in DB, associated with user.
* Subtask 2.5.2: Secure API Endpoints:
* [ ] Create new API routes (e.g., /api/v1/forms, /api/v1/forms/:uuid/submissions).
* [ ] Authenticate using API keys (e.g., Bearer token).
* Subtask 2.5.3: Basic API Documentation:
* [ ] Simple Markdown file explaining authentication and available endpoints.

21
.env
View File

@ -1,4 +1,17 @@
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=admin
ALLOWED_ORIGIN=http://127.0.0.1:5500,http://localhost:5500
DATABASE_URL=form_data.db
PORT=3000
JWT_SECRET=dognidnrfognpobibsnccofr
ADMIN_USER=youradminuser
ADMIN_PASSWORD=yoursecurepassword
# Ntfy Configuration
NTFY_TOPIC_URL=https://ntfggy.sh/your-secret-form-alerts # IMPORTANT: Change this!
NTFY_ENABLED=true # set to false to disable ntfy
RECAPTCHA_V2_SITE_KEY=your_actual_site_key
RECAPTCHA_V2_SECRET_KEY=your_actual_secret_key
RESEND_API_KEY=xxx
EMAIL_FROM_ADDRESS=xxx
recaptcha_enabled = TRUE

43
.env.test Normal file
View File

@ -0,0 +1,43 @@
# .env.test
NODE_ENV=test
PORT=3001 # Use a different port for testing if your main app might be running
# Test Database Configuration (use a SEPARATE database for testing)
DB_HOST=localhost # Or your test DB host
DB_USER=your_test_db_user
DB_PASSWORD=your_test_db_password
DB_NAME=forms_db_test # CRITICAL: Use a different database name
# JWT Configuration (can be the same as dev, or specific test secrets)
JWT_SECRET=your-super-secret-jwt-key-for-tests-only-make-it-different
JWT_ISSUER=formies-test
JWT_AUDIENCE=formies-users-test
JWT_ACCESS_EXPIRY=5s # Short expiry for testing expiration
JWT_REFRESH_EXPIRY=10s
# Session Configuration
SESSION_SECRET=your-test-session-secret-key
# Application Configuration
APP_URL=http://localhost:3001
# Email Configuration (mocked or use a test service like Mailtrap.io)
SMTP_HOST=
SMTP_PORT=
SMTP_SECURE=
SMTP_USER=
SMTP_PASS=
SMTP_FROM_EMAIL=
RESEND_API_KEY=test_resend_key # So it doesn't try to send real emails
EMAIL_FROM_ADDRESS=test@formies.local
# Notification Configuration
NTFY_ENABLED=false # Disable for tests unless specifically testing ntfy
# reCAPTCHA (use test keys or disable for most tests)
RECAPTCHA_V2_SITE_KEY=your_test_recaptcha_site_key
RECAPTCHA_V2_SECRET_KEY=your_test_recaptcha_secret_key # Google provides test keys that always pass/fail
# Legacy Admin (if still relevant)
ADMIN_USER=testadmin
ADMIN_PASSWORD=testpassword

4
.gitignore vendored
View File

@ -1 +1,3 @@
/target
.env
package-lock.json
node_modules

98
API_DOCUMENTATION.md Normal file
View File

@ -0,0 +1,98 @@
# Formies API Documentation (v1)
This document provides instructions on how to use the Formies API to access your forms and submission data programmatically.
## Authentication
All API requests must be authenticated using an API Key.
1. **Generate an API Key**: You can generate and manage your API keys from your user dashboard under the "API Keys" section.
2. **Pass the API Key**: The API key must be included in the `Authorization` header of your HTTP requests, using the `Bearer` scheme.
Example:
```
Authorization: Bearer YOUR_FULL_API_KEY_HERE
```
Replace `YOUR_FULL_API_KEY_HERE` with the actual API key you generated (e.g., `fsk_xxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy`).
If authentication fails (e.g., missing key, invalid key, expired key), the API will respond with a `401 Unauthorized` or `403 Forbidden` status code and a JSON error message.
## Endpoints
All API endpoints are prefixed with `/api/v1`.
### 1. List Your Forms
- **Endpoint**: `GET /api/v1/forms`
- **Method**: `GET`
- **Authentication**: Required (Bearer Token)
- **Description**: Retrieves a list of all forms owned by the authenticated user.
- **Successful Response (200 OK)**:
```json
{
"success": true,
"forms": [
{
"uuid": "form-uuid-123",
"name": "My Contact Form",
"created_at": "2023-10-26T10:00:00.000Z",
"is_archived": false,
"submission_count": 150
}
// ... other forms
]
}
```
- **Error Responses**:
- `401 Unauthorized`: Authentication failed.
- `500 Internal Server Error`: If there was an issue fetching the forms.
### 2. List Submissions for a Form
- **Endpoint**: `GET /api/v1/forms/:formUuid/submissions`
- **Method**: `GET`
- **Authentication**: Required (Bearer Token)
- **Path Parameters**:
- `formUuid` (string, required): The UUID of the form for which to retrieve submissions.
- **Query Parameters (for pagination)**:
- `page` (integer, optional, default: `1`): The page number of submissions to retrieve.
- `limit` (integer, optional, default: `25`): The number of submissions to retrieve per page.
- **Description**: Retrieves a paginated list of submissions for a specific form owned by the authenticated user.
- **Successful Response (200 OK)**:
```json
{
"success": true,
"formName": "My Contact Form",
"formUuid": "form-uuid-123",
"pagination": {
"currentPage": 1,
"totalPages": 3,
"totalSubmissions": 65,
"limit": 25,
"perPage": 25,
"count": 25
},
"submissions": [
{
"id": 1,
"data": { "email": "test@example.com", "message": "Hello!" },
"ip_address": "123.123.123.123",
"submitted_at": "2023-10-27T14:30:00.000Z"
}
// ... other submissions for the current page
]
}
```
- **Error Responses**:
- `401 Unauthorized`: Authentication failed.
- `403 Forbidden`: If the authenticated user does not own the specified form.
- `404 Not Found`: If the specified `formUuid` does not exist.
- `500 Internal Server Error`: If there was an issue fetching the submissions.
## General Notes
- All API responses are in JSON format.
- Successful responses will generally include a `success: true` field.
- Error responses will include `success: false` and an `error` field (string or object) with details.

432
AUTHENTICATION_SETUP.md Normal file
View File

@ -0,0 +1,432 @@
# Authentication System Setup Guide
## Overview
This guide will help you set up the robust user authentication and authorization system for your Formies SaaS application. The system includes:
- **JWT-based authentication** with access and refresh tokens
- **Email verification** with automated emails
- **Password reset** functionality
- **Role-based authorization** (user, admin, super_admin)
- **Account security** features (failed login tracking, account locking)
- **Rate limiting** to prevent abuse
- **Session management** with token blacklisting
## Required Dependencies
The following packages have been added to your `package.json`:
```json
{
"bcryptjs": "^2.4.3",
"express-rate-limit": "^7.1.5",
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.8",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0"
}
```
## Environment Variables
Create a `.env` file with the following variables:
```env
# Database Configuration
DB_HOST=localhost
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=forms_db
# JWT Configuration (REQUIRED)
JWT_SECRET=your-super-secret-jwt-key-at-least-32-characters-long
JWT_ISSUER=formies
JWT_AUDIENCE=formies-users
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Session Configuration
SESSION_SECRET=your-session-secret-key-change-this-in-production
# Application Configuration
APP_URL=http://localhost:3000
NODE_ENV=development
PORT=3000
# SMTP Email Configuration (Optional but recommended)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM_EMAIL=noreply@yourdomain.com
# Notification Configuration
NTFY_ENABLED=true
NTFY_TOPIC_URL=https://ntfy.sh/your-topic
```
## Database Setup
1. **Install dependencies:**
```bash
npm install
```
2. **Update your database** by running the updated `init.sql`:
This script will create all necessary tables, including the `users` table with a default `super_admin` account (`admin@formies.local`).
The initial password for this `super_admin` is NOT set in the `init.sql` script. The `must_change_password` flag will be set to `TRUE`.
```bash
# If using Docker
docker-compose down
docker-compose up -d
# Or manually run the SQL file in your MySQL database
mysql -u your_user -p your_database < init.sql
```
If the login is for the `super_admin` (`admin@formies.local`) and it's their first login (`must_change_password` is `TRUE` on the user object returned from the `/login` attempt, even if successful), the API might return a successful login response but the client should check for this flag. Alternatively, the `/login` endpoint itself has been modified to return a `403 Forbidden` response with `code: "MUST_CHANGE_PASSWORD"` directly if this condition is met. The client application should handle this response and prompt the user to use the `/force-change-password` endpoint.
## API Endpoints
### Authentication Endpoints
All authentication endpoints are prefixed with `/api/auth`:
#### Registration
```http
POST /api/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!",
"first_name": "John",
"last_name": "Doe"
}
```
#### Login
```http
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!"
}
Response:
{
"success": true,
"message": "Login successful",
"data": {
"user": { ... },
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"accessTokenExpiresAt": "2024-01-01T00:00:00.000Z",
"refreshTokenExpiresAt": "2024-01-07T00:00:00.000Z",
"tokenType": "Bearer"
}
}
```
**Super Admin First Login:**
If the login attempt is for the `super_admin` (`admin@formies.local`) and the `must_change_password` flag is `TRUE` for this user, the `/api/auth/login` endpoint will return a `403 Forbidden` response with the following structure:
```json
{
"success": false,
"message": "Password change required.",
"code": "MUST_CHANGE_PASSWORD",
"data": {
"user": {
"id": "user_id",
"uuid": "user_uuid",
"email": "admin@formies.local",
"role": "super_admin"
}
}
}
```
The client application should detect this `code: "MUST_CHANGE_PASSWORD"` and guide the user to set a new password using the endpoint below. The `accessToken` and `refreshToken` will NOT be issued in this case. The client will need to make a subsequent call to `/api/auth/force-change-password` using a temporary mechanism if required, or by having the user log in, get the 403, then use a password change form that calls the next endpoint. For the current implementation, the super_admin will receive a standard JWT upon providing correct credentials (even if `must_change_password` is true), and this token should be used for the `/force-change-password` call.
#### Force Password Change
This endpoint is used when a user, particularly the initial `super_admin`, needs to set their password for the first time or has been flagged for a mandatory password update.
```http
POST /api/auth/force-change-password
Authorization: Bearer your-access-token-from-login-attempt
Content-Type: application/json
{
"newPassword": "ANewStrongPassword123!"
}
Response (on success):
{
"success": true,
"message": "Password changed successfully. Please log in again with your new password."
}
```
After a successful password change using this endpoint:
- The user's password is updated.
- The `must_change_password` flag is set to `FALSE`.
- All other active sessions for this user are invalidated for security.
- The user will need to log in again with their new password to obtain new session tokens.
#### Token Refresh
```http
POST /api/auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJ..."
}
```
#### Logout
```http
POST /api/auth/logout
Authorization: Bearer your-access-token
```
#### Email Verification
```http
GET /api/auth/verify-email?token=verification_token
```
#### Profile Management
```http
GET /api/auth/profile
Authorization: Bearer your-access-token
PUT /api/auth/profile
Authorization: Bearer your-access-token
Content-Type: application/json
{
"first_name": "John",
"last_name": "Doe",
"email": "newemail@example.com"
}
```
## Security Features
### Password Requirements
- Minimum 8 characters
- At least one lowercase letter
- At least one uppercase letter
- At least one number
- At least one special character (@$!%\*?&)
### Account Security
- Failed login attempts are tracked
- Account locks after 5 failed attempts for 30 minutes
- Email verification required for new accounts
- JWT tokens are tracked and can be revoked
### Rate Limiting
- **Login attempts:** 5 per 15 minutes per IP/email
- **Registration:** 3 per hour per IP
- **Password reset:** 3 per hour per IP/email
## Using the Authentication System
### Frontend Integration
1. **Store tokens securely:**
```javascript
// Store in secure httpOnly cookies or localStorage (less secure)
localStorage.setItem("accessToken", response.data.accessToken);
localStorage.setItem("refreshToken", response.data.refreshToken);
```
2. **Include token in requests:**
```javascript
fetch("/api/protected-endpoint", {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
```
3. **Handle token refresh:**
```javascript
async function refreshToken() {
const refreshToken = localStorage.getItem("refreshToken");
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("accessToken", data.data.accessToken);
return data.data.accessToken;
} else {
// Redirect to login
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.location.href = "/login";
}
}
```
### Backend Integration
1. **Protect routes with authentication:**
```javascript
const {
requireAuth,
requireAdmin,
} = require("./src/middleware/authMiddleware");
// Require authentication
router.get("/protected", requireAuth, (req, res) => {
res.json({ user: req.user });
});
// Require admin role
router.get("/admin-only", requireAdmin, (req, res) => {
res.json({ message: "Admin access granted" });
});
```
2. **Check resource ownership:**
```javascript
const {
requireOwnershipOrAdmin,
} = require("./src/middleware/authMiddleware");
router.get(
"/forms/:id",
requireOwnershipOrAdmin(async (req) => {
const form = await Form.findById(req.params.id);
return form.user_id;
}),
(req, res) => {
// User can only access their own forms or admin can access all
}
);
```
## Migration from Basic Auth
The system maintains backward compatibility with your existing basic auth. To fully migrate:
1. **Update admin routes** to use the new authentication system
2. **Create admin users** in the database with appropriate roles
3. **Remove basic auth middleware** once migration is complete
## Default Admin Account
A default super admin account is created automatically:
- **Email:** admin@formies.local
- **Password:** admin123 (change immediately!)
## Email Configuration
For email verification and password reset to work, configure SMTP settings:
### Gmail Setup
1. Enable 2-factor authentication
2. Generate an app password
3. Use the app password in `SMTP_PASS`
### Other Providers
- **Outlook:** smtp-mail.outlook.com:587
- **SendGrid:** smtp.sendgrid.net:587
- **Mailgun:** smtp.mailgun.org:587
## Production Considerations
1. **Use strong secrets:** Generate random JWT_SECRET and SESSION_SECRET
2. **Enable HTTPS:** Set `NODE_ENV=production` and use SSL certificates
3. **Use Redis for sessions:** Replace memory sessions with Redis
4. **Monitor rate limits:** Adjust rate limiting based on usage patterns
5. **Backup token sessions:** Consider database-backed session storage
## Troubleshooting
### Common Issues
1. **JWT_SECRET not set:**
```
WARNING: JWT_SECRET not set. Authentication will not work properly.
```
Solution: Add JWT_SECRET to your .env file
2. **Email service not working:**
```
Email service not configured. Set SMTP environment variables.
```
Solution: Configure SMTP settings in .env file
3. **Database connection errors:**
- Verify database credentials
- Ensure database exists
- Check if init.sql has been run
4. **Token validation errors:**
- Check if JWT_SECRET matches between requests
- Verify token hasn't expired
- Ensure token is properly formatted in Authorization header
## Testing the System
Use these curl commands to test the authentication endpoints:
```bash
# Register a new user
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"TestPass123!","first_name":"Test","last_name":"User"}'
# Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"TestPass123!"}'
# Access protected endpoint
curl -X GET http://localhost:3000/api/auth/profile \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
This authentication system provides enterprise-grade security for your SaaS application while maintaining flexibility and ease of use.

4103
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +0,0 @@
[package]
name = "formies_be"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.0"
rusqlite = { version = "0.29", features = ["bundled", "chrono"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4"] }
actix-files = "0.6"
actix-cors = "0.6"
env_logger = "0.10"
log = "0.4"
futures = "0.3"
bcrypt = "0.13"
anyhow = "1.0"
dotenv = "0.15.0"
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
url = "2"
reqwest = { version = "0.11", features = ["json"] }
scraper = "0.18"
lettre = { version = "0.10", features = ["builder", "tokio1-native-tls"] }
ureq = { version = "2.9", features = ["json"] }
# Production dependencies
actix_route_rate_limiter = "0.2.2"
actix-rt = "2.0"
actix-http = "3.0"
config = "0.13"
sentry = { version = "0.37", features = ["log"] }
validator = { version = "0.16", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-actix-web = "0.7"
tracing-log = "0.2"
tracing-appender = "0.2"
tracing-bunyan-formatter = "0.3"
tokio = "1.45.0"

View File

@ -1,51 +1,28 @@
# Build stage
FROM rust:1.70-slim as builder
FROM node:18.19-alpine AS builder
WORKDIR /app
WORKDIR /usr/src/app
# Install build dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN cargo build --release
FROM node:18.19-alpine
# Runtime stage
FROM debian:bullseye-slim
WORKDIR /usr/src/app
WORKDIR /app
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
libsqlite3-0 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/package*.json ./
COPY --from=builder /usr/src/app/ ./
# Create necessary directories
RUN mkdir -p /app/data /app/logs
# Set ownership to non-root user
RUN chown -R appuser:appgroup /usr/src/app
# Copy the binary from builder
COPY --from=builder /app/target/release/formies-be /app/
USER appuser
# Copy configuration
COPY config/default.toml /app/config/default.toml
EXPOSE 3000
# Set environment variables
ENV RUST_LOG=info
ENV DATABASE_URL=/app/data/form_data.db
ENV BIND_ADDRESS=0.0.0.0:8080
# Expose port
EXPOSE 8080
# Set proper permissions
RUN chown -R nobody:nogroup /app
USER nobody
# Run the application
CMD ["./formies-be"]
CMD ["node", "server.js"]

164
RATE_LIMITING.md Normal file
View File

@ -0,0 +1,164 @@
# Rate Limiting Documentation
## Overview
This application now implements a scalable Redis-backed rate limiting system to protect against abuse and ensure fair usage of the form submission endpoints.
## Rate Limiting Strategy
The `/submit/:formUuid` endpoint is protected by three layers of rate limiting:
### 1. Strict Rate Limiter (First Layer)
- **Window**: 1 hour
- **Limit**: 50 requests per IP address across all forms
- **Purpose**: Prevents aggressive abuse from single IP addresses
- **Key**: `strict_ip:{ip_address}`
### 2. General Submission Rate Limiter (Second Layer)
- **Window**: 15 minutes
- **Limit**: 10 requests per IP address for any form submissions
- **Purpose**: Prevents rapid-fire submissions from legitimate users
- **Key**: `submit_ip:{ip_address}`
### 3. Form-Specific Rate Limiter (Third Layer)
- **Window**: 5 minutes
- **Limit**: 3 requests per IP address per specific form
- **Purpose**: Prevents spam on individual forms
- **Key**: `submit_form:{formUuid}:{ip_address}`
## Infrastructure
### Redis Configuration
#### Development Environment
- **Service**: `redis:7-alpine`
- **Port**: `6379`
- **Data Persistence**: Yes (Redis AOF)
- **Volume**: `redis_data:/data`
#### Production Environment
- **Service**: `redis:7-alpine`
- **Port**: `6380` (external, to avoid conflicts)
- **Data Persistence**: Yes (Redis AOF)
- **Volume**: `redis_data:/data`
- **Password Protection**: Configurable via `REDIS_PASSWORD`
- **Health Checks**: Enabled
### Environment Variables
```env
# Redis Configuration
REDIS_HOST=redis # Redis hostname (default: redis in Docker, localhost otherwise)
REDIS_PORT=6379 # Redis port (default: 6379)
REDIS_PASSWORD= # Optional Redis password (production recommended)
```
## Fallback Mechanism
If Redis is unavailable, the system automatically falls back to an in-memory rate limiter:
- **Graceful Degradation**: Application continues to function without Redis
- **Automatic Detection**: Detects Redis availability and switches accordingly
- **Logging**: Warns when falling back to memory store
- **Same Limits**: Maintains the same rate limiting rules
## Rate Limit Headers
When rate limits are applied, the following headers are returned:
- `RateLimit-Limit`: Maximum number of requests allowed
- `RateLimit-Remaining`: Number of requests remaining in window
- `RateLimit-Reset`: Time when the rate limit window resets
## Error Responses
When rate limits are exceeded, the API returns:
```json
{
"error": "Too many requests from this IP address. Please try again later."
}
```
The specific error message varies by rate limiter:
- **Strict**: "Too many requests from this IP address. Please try again later."
- **General**: "Too many form submissions from this IP address. Please try again later."
- **Form-Specific**: "Too many submissions for this form from your IP address. Please try again later."
## Deployment
### Starting Services
#### Development
```bash
docker-compose up -d
```
#### Production
```bash
docker-compose -f docker-compose.prod.yml up -d
```
### Monitoring Redis
Check Redis connection:
```bash
docker exec -it formies-redis-1 redis-cli ping
```
View rate limiting keys:
```bash
docker exec -it formies-redis-1 redis-cli --scan --pattern "submit_*"
```
## Security Considerations
1. **Redis Security**: In production, always use password authentication
2. **Network Security**: Redis should not be exposed to public networks
3. **Data Persistence**: Redis data is persisted to handle container restarts
4. **Graceful Shutdown**: Application properly closes Redis connections on exit
## Performance
- **Scalability**: Redis-backed rate limiting scales across multiple application instances
- **Efficiency**: O(1) operations for rate limit checks
- **Memory Usage**: Efficient key expiration prevents memory leaks
- **High Availability**: Can be configured with Redis clustering for production
## Troubleshooting
### Common Issues
1. **Redis Connection Failed**
- Check if Redis container is running
- Verify environment variables
- Check Docker network connectivity
2. **Rate Limiting Not Working**
- Verify Redis connection in application logs
- Check if fallback to memory store is occurring
- Ensure proper IP address detection
3. **Performance Issues**
- Monitor Redis memory usage
- Check for connection pooling configuration
- Verify network latency between app and Redis
### Logs to Monitor
- Redis connection status
- Rate limiter fallback warnings
- Rate limit exceeded events
- Redis error messages

163
README.md
View File

@ -1,163 +0,0 @@
# Formies Backend
A production-ready Rust backend for the Formies application.
## Features
- RESTful API endpoints
- SQLite database with connection pooling
- JWT-based authentication
- Rate limiting
- Structured logging
- Error tracking with Sentry
- Health check endpoint
- CORS support
- Configuration management
- Metrics endpoint
## Prerequisites
- Rust 1.70 or later
- SQLite 3
- Make (optional, for using Makefile commands)
## Configuration
The application can be configured using environment variables or a configuration file. The following environment variables are supported:
### Required Environment Variables
- `DATABASE_URL`: SQLite database URL (default: form_data.db)
- `BIND_ADDRESS`: Server bind address (default: 127.0.0.1:8080)
- `INITIAL_ADMIN_USERNAME`: Initial admin username
- `INITIAL_ADMIN_PASSWORD`: Initial admin password
### Optional Environment Variables
- `ALLOWED_ORIGIN`: CORS allowed origin
- `RUST_LOG`: Log level (default: info)
- `SENTRY_DSN`: Sentry DSN for error tracking
- `JWT_SECRET`: JWT secret key
- `JWT_EXPIRATION`: JWT expiration time in seconds
- `CAPTCHA_ENABLED`: Enable CAPTCHA verification for public form submissions (`true` or `false`, default: `false`)
- `CAPTCHA_SECRET_KEY`: The secret key provided by your CAPTCHA service (e.g., hCaptcha, reCAPTCHA)
- `CAPTCHA_VERIFICATION_URL`: The verification endpoint URL for your CAPTCHA service (e.g., `https://hcaptcha.com/siteverify`)
## Development
1. Clone the repository
2. Install dependencies:
```bash
cargo build
```
3. Set up environment variables:
```bash
cp .env.example .env
# Edit .env with your configuration
```
4. Run the development server:
```bash
cargo run
```
## Production Deployment
### Docker
1. Build the Docker image:
```bash
docker build -t formies-backend .
```
2. Run the container:
```bash
docker run -d \
--name formies-backend \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-e DATABASE_URL=/app/data/form_data.db \
-e BIND_ADDRESS=0.0.0.0:8080 \
-e INITIAL_ADMIN_USERNAME=admin \
-e INITIAL_ADMIN_PASSWORD=your-secure-password \
-e ALLOWED_ORIGIN=https://your-frontend-domain.com \
-e SENTRY_DSN=your-sentry-dsn \
formies-backend
```
### Systemd Service
1. Create a systemd service file at `/etc/systemd/system/formies-backend.service`:
```ini
[Unit]
Description=Formies Backend Service
After=network.target
[Service]
Type=simple
User=formies
WorkingDirectory=/opt/formies-backend
ExecStart=/opt/formies-backend/formies-be
Restart=always
Environment=DATABASE_URL=/opt/formies-backend/data/form_data.db
Environment=BIND_ADDRESS=0.0.0.0:8080
Environment=INITIAL_ADMIN_USERNAME=admin
Environment=INITIAL_ADMIN_PASSWORD=your-secure-password
Environment=ALLOWED_ORIGIN=https://your-frontend-domain.com
Environment=SENTRY_DSN=your-sentry-dsn
[Install]
WantedBy=multi-user.target
```
2. Enable and start the service:
```bash
sudo systemctl enable formies-backend
sudo systemctl start formies-backend
```
## Monitoring
### Health Check
The application exposes a health check endpoint at `/api/health`:
```bash
curl http://localhost:8080/api/health
```
### Metrics
Metrics are available at `/metrics` when enabled in the configuration.
### Logging
Logs are written to the configured log file and can be viewed using:
```bash
tail -f logs/app.log
```
## Security
- All API endpoints are rate-limited
- CORS is configured to only allow specified origins
- JWT tokens are used for authentication
- Passwords are hashed using bcrypt
- SQLite database is protected with proper file permissions
### Form Submission Security
The public form submission endpoint (`/api/forms/{form_id}/submissions`) includes several security measures:
- **Global Rate Limiting:** The overall number of requests to the API is limited.
- **Per-Form, Per-IP Rate Limiting:** Limits the number of submissions one IP address can make to a specific form within a time window (e.g., 5 submissions per minute). Configurable in code.
- **CAPTCHA Verification:** If enabled via environment variables (`CAPTCHA_ENABLED=true`), requires a valid CAPTCHA token (e.g., from hCaptcha, reCAPTCHA, Turnstile) to be sent in the `captcha_token` field of the submission payload. The backend verifies this token with the configured provider.
- **Payload Size Limit:** The maximum size of the submission payload is limited (e.g., 1MB) to prevent DoS attacks. Configurable in code.
- **Input Validation:** Submission data is validated against the specific form's field definitions (type, required, length, pattern, etc.).
- **Notification Throttling:** Limits the rate at which notifications (Email, Ntfy) are sent per form to prevent spamming channels (e.g., max 1 per minute). Configurable in code.
## License
MIT

69
combined.log Normal file
View File

@ -0,0 +1,69 @@
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: / - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /favicon.ico - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Admin access: User: youradminuser, Pass: (hidden)","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: / - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /login - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /dashboard.html - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"warn","message":"404 - Endpoint not found: /.well-known/appspecific/com.chrome.devtools.json - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Database file found.","service":"user-service"}
{"level":"info","message":"Server running on http://localhost:3000","service":"user-service"}
{"level":"info","message":"Ntfy notifications enabled for topic: https://ntfggy.sh/your-secret-form-alerts","service":"user-service"}
{"level":"info","message":"Received SIGINT, shutting down gracefully...","service":"user-service"}

View File

@ -1,30 +0,0 @@
[server]
bind_address = "127.0.0.1:8080"
workers = 4
keep_alive = 60
client_timeout = 5000
client_shutdown = 5000
[database]
url = "form_data.db"
pool_size = 5
connection_timeout = 30
[security]
rate_limit_requests = 100
rate_limit_interval = 60
allowed_origins = ["http://localhost:5173"]
jwt_secret = "your-secret-key"
jwt_expiration = 3600
[logging]
level = "info"
format = "json"
file = "logs/app.log"
max_size = 10485760 # 10MB
max_files = 5
[monitoring]
sentry_dsn = ""
enable_metrics = true
metrics_port = 9090

29
config/logger.js Normal file
View File

@ -0,0 +1,29 @@
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
defaultMeta: { service: "user-service" },
transports: [
//
// - Write all logs with importance level of `error` or less to `error.log`
// - Write all logs with importance level of `info` or less to `combined.log`
//
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
//
// If we're not in production then log to the `console` with the format:
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
//
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple(),
})
);
}
module.exports = logger;

File diff suppressed because it is too large Load Diff

68
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,68 @@
version: "3.8"
services:
app:
build: .
ports:
- "3000:3000" # Expose app on host port 3000
depends_on:
db:
condition: service_healthy # Wait for DB to be healthy
redis:
condition: service_started # Wait for Redis to start
environment:
- DB_HOST=${DB_HOST}
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- PORT=${PORT}
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
restart: unless-stopped
db:
image: mysql:8.0
ports:
- "3307:3306" # Expose DB on host port 3307 (to avoid conflict if you have local MySQL on 3306)
environment:
MYSQL_ROOT_PASSWORD: your_root_password # Change this
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql # Persist database data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql # Run init script on startup
healthcheck:
test:
[
"CMD",
"mysqladmin",
"ping",
"-h",
"localhost",
"-u$$MYSQL_USER",
"-p$$MYSQL_PASSWORD",
]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6380:6379" # Expose Redis on host port 6380 (to avoid conflict if you have local Redis on 6379)
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-}
volumes:
- redis_data:/data # Persist Redis data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
mysql_data:
redis_data:

45
docker-compose.yml Normal file
View File

@ -0,0 +1,45 @@
version: "3.8"
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DB_HOST=mysql
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- REDIS_HOST=redis
- REDIS_PORT=6379
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
depends_on:
- mysql
- redis
mysql:
image: mysql:8.0
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
- MYSQL_DATABASE=${DB_NAME}
- MYSQL_USER=${DB_USER}
- MYSQL_PASSWORD=${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
mysql_data:
redis_data:

0
error.log Normal file
View File

Binary file not shown.

0
formies.sqlite Normal file
View File

View File

@ -1,220 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Formies</title>
<!-- Link to the new CSS file -->
<link rel="stylesheet" href="style.css" />
<style>
/* Basic Modal Styling (can be moved to style.css) */
.modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1000; /* Sit on top */
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto; /* Enable scroll if needed */
background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
padding-top: 60px;
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 500px;
}
.close-button {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close-button:hover,
.close-button:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
#notification-settings-modal label {
display: block;
margin-top: 10px;
}
#notification-settings-modal input[type="text"],
#notification-settings-modal input[type="email"] {
width: 95%;
padding: 8px;
margin-top: 5px;
}
#notification-settings-modal .modal-actions {
margin-top: 20px;
text-align: right;
}
</style>
</head>
<body>
<!-- Added Container -->
<div class="container page-container">
<!-- Moved Status Area inside container -->
<div id="status-area" class="status"></div>
<h1 class="page-title">Formies - Simple Form Manager</h1>
<!-- Login Section -->
<div id="login-section" class="content-card">
<h2 class="section-title">Login</h2>
<form post="" id="login-form">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" required />
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" required />
</div>
<!-- Added button class -->
<button type="submit" class="button">Login</button>
</form>
</div>
<!-- Logged In Section (Admin Area) -->
<div id="admin-section" class="hidden">
<div class="admin-header content-card">
<p>
Welcome, <span id="logged-in-user">Admin</span>!
<!-- Added button classes -->
<button id="logout-button" class="button button-danger">
Logout
</button>
</p>
</div>
<hr class="divider" />
<h2 class="section-title">Admin Panel</h2>
<!-- Create Form -->
<div class="content-card form-section">
<h3 class="card-title">Create New Form</h3>
<form id="createForm">
<div class="form-group">
<label for="formName">Form Name:</label>
<input type="text" id="formName" name="formName" required />
</div>
<!-- Added button class -->
<button type="submit" class="button">Create Form</button>
</form>
</div>
<!-- List Forms -->
<div class="content-card section">
<h3 class="card-title">Existing Forms</h3>
<!-- Added button class -->
<button id="load-forms-button" class="button button-secondary">
Load Forms
</button>
<ul id="forms-list" class="styled-list">
<!-- Forms will be listed here -->
</ul>
</div>
<!-- View Submissions -->
<div id="submissions-section" class="content-card section hidden">
<h3 class="card-title">
Submissions for <span id="submissions-form-name"></span>
</h3>
<ul id="submissions-list" class="styled-list submissions">
<!-- Submissions will be listed here -->
</ul>
</div>
</div>
<!-- Public Form Display / Submission Area -->
<hr class="divider" />
<div class="content-card">
<h2 class="section-title">Submit to a Form</h2>
<p>Enter a Form ID to load and submit:</p>
<div class="form-group inline-form-group">
<input
type="text"
id="public-form-id-input"
placeholder="Enter Form ID here" />
<!-- Added button class -->
<button id="load-public-form-button" class="button">Load Form</button>
</div>
<div id="public-form-area" class="section hidden">
<h3 id="public-form-title" class="card-title"></h3>
<form id="public-form">
<!-- Form fields will be rendered here -->
<!-- Submit button will be added by JS, style it below -->
</form>
</div>
</div>
</div>
<!-- /.container -->
<section id="forms-section" class="hidden">
<h2>Manage Forms</h2>
<button id="load-forms">Load My Forms</button>
<ul id="forms-list">
<!-- Form list items will be populated here -->
<!-- Example Structure (generated by script.js):
<li>
Form Name (ID: form-id-123)
<button class="view-submissions-btn" data-form-id="form-id-123" data-form-name="Form Name">View Submissions</button>
<button class="manage-notifications-btn" data-form-id="form-id-123">Manage Notifications</button> // Added button
</li>
-->
</ul>
</section>
<!-- Notification Settings Modal -->
<div id="notification-settings-modal" class="modal">
<div class="modal-content">
<span class="close-button" id="close-notification-modal">&times;</span>
<h2>Notification Settings for <span id="modal-form-name"></span></h2>
<form id="notification-settings-form">
<input type="hidden" id="modal-form-id" />
<div id="modal-status" class="status"></div>
<label for="modal-notify-email">Notify Email Address:</label>
<input
type="email"
id="modal-notify-email"
name="notify_email"
placeholder="leave blank to disable email" />
<label for="modal-notify-ntfy-topic">Enable ntfy Notification:</label>
<input
type="text"
id="modal-notify-ntfy-topic"
name="notify_ntfy_topic"
placeholder="enter any text to enable (uses global topic)" />
<small
>Enter any non-empty text here (e.g., "yes" or the topic name
itself) to enable ntfy notifications for this form. The notification
will be sent to the globally configured ntfy topic specified in the
backend environment variables. Leave blank to disable ntfy for this
form.</small
>
<div class="modal-actions">
<button type="submit" id="save-notification-settings">
Save Settings
</button>
<button type="button" id="cancel-notification-settings">
Cancel
</button>
</div>
</form>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

View File

@ -1,575 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
// --- Configuration ---
const API_BASE_URL = "http://localhost:8080/api"; // Assuming backend serves API under /api
// --- State ---
let authToken = sessionStorage.getItem("authToken"); // Use sessionStorage for non-persistent login
// --- DOM Elements ---
const loginSection = document.getElementById("login-section");
const adminSection = document.getElementById("admin-section");
const loginForm = document.getElementById("login-form");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const logoutButton = document.getElementById("logout-button");
const statusArea = document.getElementById("status-area");
const loggedInUserSpan = document.getElementById("logged-in-user"); // Added this if needed
const createForm = document.getElementById("create-form");
const formNameInput = document.getElementById("form-name");
const loadFormsButton = document.getElementById("load-forms-button");
const formsList = document.getElementById("forms-list");
const submissionsSection = document.getElementById("submissions-section");
const submissionsList = document.getElementById("submissions-list");
const submissionsFormNameSpan = document.getElementById(
"submissions-form-name"
);
const publicFormIdInput = document.getElementById("public-form-id-input");
const loadPublicFormButton = document.getElementById(
"load-public-form-button"
);
const publicFormArea = document.getElementById("public-form-area");
const publicFormTitle = document.getElementById("public-form-title");
const publicForm = document.getElementById("public-form");
// --- Helper Functions ---
function showStatus(message, isError = false) {
statusArea.textContent = message;
statusArea.className = "status"; // Reset classes
if (message) {
statusArea.classList.add(isError ? "error" : "success");
}
}
function toggleSections() {
console.log("toggleSections called. Current authToken:", authToken); // Log 3
if (authToken) {
console.log("AuthToken found, showing admin section."); // Log 4
loginSection.classList.add("hidden");
adminSection.classList.remove("hidden");
// Optionally display username if you fetch it after login
// loggedInUserSpan.textContent = 'Admin'; // Placeholder
} else {
console.log("AuthToken not found, showing login section."); // Log 5
loginSection.classList.remove("hidden");
adminSection.classList.add("hidden");
submissionsSection.classList.add("hidden"); // Hide submissions when logged out
}
// Always hide public form initially on state change
publicFormArea.classList.add("hidden");
publicForm.innerHTML = '<button type="submit">Submit Form</button>'; // Reset form content
}
async function makeApiRequest(
endpoint,
method = "GET",
body = null,
requiresAuth = false
) {
const url = `${API_BASE_URL}${endpoint}`;
const headers = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (requiresAuth) {
if (!authToken) {
throw new Error("Authentication required, but no token found.");
}
headers["Authorization"] = `Bearer ${authToken}`;
}
const options = {
method,
headers,
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
let errorData;
try {
errorData = await response.json(); // Try to parse error JSON
} catch (e) {
// If response is not JSON
errorData = {
message: `HTTP Error: ${response.status} ${response.statusText}`,
};
}
// Check for backend's validation error structure
if (errorData && errorData.validation_errors) {
throw { validationErrors: errorData.validation_errors };
}
// Throw a more generic error message or the one from backend if available
throw new Error(
errorData.message || `Request failed with status ${response.status}`
);
}
// Handle responses with no content (e.g., logout)
if (
response.status === 204 ||
response.headers.get("content-length") === "0"
) {
return null; // Or return an empty object/success indicator
}
return await response.json(); // Parse successful JSON response
} catch (error) {
console.error(`API Request Error (${method} ${endpoint}):`, error);
// Re-throw validation errors specifically if they exist
if (error.validationErrors) {
throw error;
}
// Re-throw other errors
throw new Error(error.message || "Network error or failed to fetch");
}
}
// --- Event Handlers ---
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
showStatus(""); // Clear previous status
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
if (!username || !password) {
showStatus("Username and password are required.", true);
return;
}
try {
const data = await makeApiRequest("/login", "POST", {
username,
password,
});
if (data && data.token) {
console.log("Login successful, received token:", data.token); // Log 1
authToken = data.token;
sessionStorage.setItem("authToken", authToken); // Store token
console.log("Calling toggleSections after login..."); // Log 2
toggleSections();
showStatus("Login successful!");
usernameInput.value = ""; // Clear fields
passwordInput.value = "";
} else {
throw new Error("Login failed: No token received.");
}
} catch (error) {
showStatus(`Login failed: ${error.message}`, true);
authToken = null;
sessionStorage.removeItem("authToken");
toggleSections();
}
});
logoutButton.addEventListener("click", async () => {
showStatus("");
if (!authToken) return;
try {
await makeApiRequest("/logout", "POST", null, true);
showStatus("Logout successful!");
} catch (error) {
showStatus(`Logout failed: ${error.message}`, true);
// Decide if you still want to clear local state even if server fails
// Forcing logout locally might be better UX in case of server error
} finally {
// Always clear local state on logout attempt
authToken = null;
sessionStorage.removeItem("authToken");
toggleSections();
}
});
if (createForm) {
createForm.addEventListener("submit", async (e) => {
e.preventDefault();
showStatus("");
const formName = formNameInput.value.trim();
if (!formName) {
showStatus("Please enter a form name", true);
return;
}
try {
// Refactor to use makeApiRequest
const data = await makeApiRequest(
"/forms", // Endpoint relative to API_BASE_URL
"POST",
// TODO: Need a way to define form fields in the UI.
// Sending minimal structure for now.
{ name: formName, fields: [] },
true // Requires authentication
);
if (!data || !data.id) {
throw new Error(
"Failed to create form or received invalid response."
);
}
showStatus(
`Form '${data.name}' created successfully! (ID: ${data.id})`,
"success"
);
formNameInput.value = "";
// Automatically refresh the forms list after creation
if (loadFormsButton) {
loadFormsButton.click();
}
} catch (error) {
showStatus(`Error creating form: ${error.message}`, true);
}
});
}
// Ensure createFormFromUrl exists before adding listener
const createFormFromUrlEl = document.getElementById("create-form-from-url");
if (createFormFromUrlEl) {
// Check if the element exists
const formNameUrlInput = document.getElementById("form-name-url");
const formUrlInput = document.getElementById("form-url");
createFormFromUrlEl.addEventListener("submit", async (e) => {
e.preventDefault();
showStatus("");
const name = formNameUrlInput.value.trim();
const url = formUrlInput.value.trim();
if (!name || !url) {
showStatus("Form name and URL are required.", true);
return;
}
try {
const newForm = await makeApiRequest(
"/forms/from-url",
"POST",
{ name, url },
true
);
showStatus(
`Form '${newForm.name}' created successfully with ID: ${newForm.id}`
);
formNameUrlInput.value = ""; // Clear form
formUrlInput.value = "";
loadFormsButton.click(); // Refresh the forms list
} catch (error) {
showStatus(`Failed to create form from URL: ${error.message}`, true);
}
});
}
if (loadFormsButton) {
loadFormsButton.addEventListener("click", async () => {
showStatus("");
submissionsSection.classList.add("hidden"); // Hide submissions when reloading forms
formsList.innerHTML = "<li>Loading...</li>"; // Indicate loading
try {
const forms = await makeApiRequest("/forms", "GET", null, true);
formsList.innerHTML = ""; // Clear list
if (forms && forms.length > 0) {
forms.forEach((form) => {
const li = document.createElement("li");
li.textContent = `${form.name} (ID: ${form.id})`;
const viewSubmissionsButton = document.createElement("button");
viewSubmissionsButton.textContent = "View Submissions";
viewSubmissionsButton.onclick = () =>
loadSubmissions(form.id, form.name);
li.appendChild(viewSubmissionsButton);
formsList.appendChild(li);
});
} else {
formsList.innerHTML = "<li>No forms found.</li>";
}
} catch (error) {
showStatus(`Failed to load forms: ${error.message}`, true);
formsList.innerHTML = "<li>Error loading forms.</li>";
}
});
}
async function loadSubmissions(formId, formName) {
showStatus("");
submissionsList.innerHTML = "<li>Loading submissions...</li>";
submissionsFormNameSpan.textContent = `${formName} (ID: ${formId})`;
submissionsSection.classList.remove("hidden");
try {
const submissions = await makeApiRequest(
`/forms/${formId}/submissions`,
"GET",
null,
true
);
submissionsList.innerHTML = ""; // Clear list
if (submissions && submissions.length > 0) {
submissions.forEach((sub) => {
const li = document.createElement("li");
// Display submission data safely - avoid rendering raw HTML
const pre = document.createElement("pre");
pre.textContent = JSON.stringify(sub.data, null, 2); // Pretty print JSON
li.appendChild(pre);
// Optionally display submission ID and timestamp if available
// const info = document.createElement('small');
// info.textContent = `ID: ${sub.id}, Submitted: ${sub.created_at || 'N/A'}`;
// li.appendChild(info);
submissionsList.appendChild(li);
});
} else {
submissionsList.innerHTML =
"<li>No submissions found for this form.</li>";
}
} catch (error) {
showStatus(
`Failed to load submissions for form ${formId}: ${error.message}`,
true
);
submissionsList.innerHTML = "<li>Error loading submissions.</li>";
submissionsSection.classList.add("hidden"); // Hide section on error
}
}
// --- Public Form Handling ---
if (loadPublicFormButton) {
loadPublicFormButton.addEventListener("click", async () => {
const formId = publicFormIdInput.value.trim();
if (!formId) {
showStatus("Please enter a Form ID.", true);
return;
}
showStatus("");
publicFormArea.classList.add("hidden");
publicForm.innerHTML = "Loading form..."; // Clear previous form
// NOTE: Fetching form definition is NOT directly possible with the current backend
// The backend only provides GET /forms (all, protected) and GET /forms/{id}/submissions (protected)
// It DOES NOT provide a public GET /forms/{id} endpoint to fetch the definition.
//
// **WORKAROUND:** We will *assume* the user knows the structure or we have it cached/predefined.
// For this example, we'll fetch *all* forms (if logged in) and find it, OR fail if not logged in.
// A *better* backend design would include a public GET /forms/{id} endpoint.
try {
// Attempt to get the form definition (requires login for this workaround)
if (!authToken) {
showStatus(
"Loading public forms requires login in this demo version.",
true
);
publicForm.innerHTML = ""; // Clear loading message
return;
}
const forms = await makeApiRequest("/forms", "GET", null, true);
const formDefinition = forms.find((f) => f.id === formId);
if (!formDefinition) {
throw new Error(`Form with ID ${formId} not found or access denied.`);
}
renderPublicForm(formDefinition);
publicFormArea.classList.remove("hidden");
} catch (error) {
showStatus(`Failed to load form ${formId}: ${error.message}`, true);
publicForm.innerHTML = ""; // Clear loading message
publicFormArea.classList.add("hidden");
}
});
}
function renderPublicForm(formDefinition) {
publicFormTitle.textContent = formDefinition.name;
publicForm.innerHTML = ""; // Clear previous fields
publicForm.dataset.formId = formDefinition.id; // Store form ID for submission
if (!formDefinition.fields || !Array.isArray(formDefinition.fields)) {
publicForm.innerHTML = "<p>Error: Form definition is invalid.</p>";
console.error("Invalid form fields definition:", formDefinition.fields);
return;
}
formDefinition.fields.forEach((field) => {
const div = document.createElement("div");
const label = document.createElement("label");
label.htmlFor = `field-${field.name}`;
label.textContent = field.label || field.name; // Use label, fallback to name
div.appendChild(label);
let input;
// Basic type handling - could be expanded
switch (field.type) {
case "textarea": // Allow explicit textarea type
case "string":
// Use textarea for string if maxLength suggests it might be long
if (field.maxLength && field.maxLength > 100) {
input = document.createElement("textarea");
input.rows = 4; // Default rows
} else {
input = document.createElement("input");
input.type = "text";
}
if (field.minLength) input.minLength = field.minLength;
if (field.maxLength) input.maxLength = field.maxLength;
break;
case "email":
input = document.createElement("input");
input.type = "email";
break;
case "url":
input = document.createElement("input");
input.type = "url";
break;
case "number":
input = document.createElement("input");
input.type = "number";
if (field.min !== undefined) input.min = field.min;
if (field.max !== undefined) input.max = field.max;
input.step = field.step || "any"; // Allow decimals by default
break;
case "boolean":
input = document.createElement("input");
input.type = "checkbox";
// Checkbox label handling is slightly different
label.insertBefore(input, label.firstChild); // Put checkbox before text
input.style.width = "auto"; // Override default width
input.style.marginRight = "10px";
break;
// Add cases for 'select', 'radio', 'date' etc. if needed
default:
input = document.createElement("input");
input.type = "text";
console.warn(
`Unsupported field type "${field.type}" for field "${field.name}". Rendering as text.`
);
}
if (input.type !== "checkbox") {
// Checkbox is already appended inside label
div.appendChild(input);
}
input.id = `field-${field.name}`;
input.name = field.name; // Crucial for form data collection
if (field.required) input.required = true;
if (field.placeholder) input.placeholder = field.placeholder;
if (field.pattern) input.pattern = field.pattern; // Add regex pattern validation
publicForm.appendChild(div);
});
const submitButton = document.createElement("button");
submitButton.type = "submit";
submitButton.textContent = "Submit Form";
publicForm.appendChild(submitButton);
}
publicForm.addEventListener("submit", async (e) => {
e.preventDefault();
showStatus("");
const formId = e.target.dataset.formId;
if (!formId) {
showStatus("Error: Form ID is missing.", true);
return;
}
const formData = new FormData(e.target);
const submissionData = {};
// Convert FormData to a plain object, handling checkboxes correctly
for (const [key, value] of formData.entries()) {
const inputElement = e.target.elements[key];
// Handle Checkboxes (boolean)
if (inputElement && inputElement.type === "checkbox") {
// A checkbox value is only present in FormData if it's checked.
// We need to ensure we always send a boolean.
// Check if the element exists in the form (it might be unchecked)
submissionData[key] = inputElement.checked;
}
// Handle Number inputs (convert from string)
else if (inputElement && inputElement.type === "number") {
// Only convert if the value is not empty, otherwise send null or handle as needed
if (value !== "") {
submissionData[key] = parseFloat(value); // Or parseInt if only integers allowed
if (isNaN(submissionData[key])) {
// Handle potential parsing errors if input validation fails
console.warn(`Could not parse number for field ${key}: ${value}`);
submissionData[key] = null; // Or keep as string, or show error
}
} else {
submissionData[key] = null; // Or undefined, depending on backend expectation for empty numbers
}
}
// Handle potential multiple values for the same name (e.g., multi-select), though not rendered here
else if (submissionData.hasOwnProperty(key)) {
if (!Array.isArray(submissionData[key])) {
submissionData[key] = [submissionData[key]];
}
submissionData[key].push(value);
}
// Default: treat as string
else {
submissionData[key] = value;
}
}
// Ensure boolean fields that were *unchecked* are explicitly set to false
// FormData only includes checked checkboxes. Find all checkbox inputs in the form.
const checkboxes = e.target.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach((cb) => {
if (!submissionData.hasOwnProperty(cb.name)) {
submissionData[cb.name] = false; // Set unchecked boxes to false
}
});
console.log("Submitting data:", submissionData); // Debugging
try {
// Public submission endpoint doesn't require auth
const result = await makeApiRequest(
`/forms/${formId}/submissions`,
"POST",
submissionData,
false
);
showStatus(
`Submission successful! Submission ID: ${result.submission_id}`
);
e.target.reset(); // Clear the form
// Optionally hide the form after successful submission
// publicFormArea.classList.add('hidden');
} catch (error) {
let errorMsg = `Submission failed: ${error.message}`;
// Handle validation errors specifically
if (error.validationErrors) {
errorMsg = "Submission failed due to validation errors:\n";
for (const [field, message] of Object.entries(error.validationErrors)) {
errorMsg += `- ${field}: ${message}\n`;
}
// Highlight invalid fields? (More complex UI update)
}
showStatus(errorMsg, true);
}
});
// --- Initial Setup ---
toggleSections(); // Set initial view based on stored token
if (authToken) {
loadFormsButton.click(); // Auto-load forms if logged in
}
});

View File

@ -1,411 +0,0 @@
/* --- Variables copied from FormCraft --- */
:root {
--color-bg: #f7f7f7;
--color-surface: #ffffff;
--color-primary: #3a4750; /* Dark grayish blue */
--color-secondary: #d8d8d8; /* Light gray */
--color-accent: #b06f42; /* Warm wood/leather brown */
--color-text: #2d3436; /* Dark gray */
--color-text-light: #636e72; /* Medium gray */
--color-border: #e0e0e0; /* Light border gray */
--color-success: #2e7d32; /* Green */
--color-success-bg: #e8f5e9;
--color-error: #a94442; /* Red for errors */
--color-error-bg: #f2dede;
--color-danger: #e74c3c; /* Red for danger buttons */
--color-danger-hover: #c0392b;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
--border-radius: 6px;
}
/* --- Global Reset & Body Styles --- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
min-height: 100vh;
display: flex; /* Helps with potential footer later */
flex-direction: column;
}
/* --- Container --- */
.container {
max-width: 900px; /* Adjusted width for simpler content */
width: 100%;
margin: 0 auto;
padding: 32px 24px; /* Add padding like main content */
}
.page-container {
flex: 1; /* Make container take available space if using flex on body */
}
/* --- Typography --- */
h1,
h2,
h3 {
color: var(--color-primary);
margin-bottom: 16px;
line-height: 1.3;
}
h1.page-title {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 24px;
text-align: center; /* Center main title */
}
h2.section-title {
font-size: 1.25rem;
font-weight: 600;
border-bottom: 1px solid var(--color-border);
padding-bottom: 8px;
margin-bottom: 20px;
}
h3.card-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: 16px;
}
p {
margin-bottom: 16px;
color: var(--color-text-light);
}
p:last-child {
margin-bottom: 0;
}
hr.divider {
border: 0;
height: 1px;
background: var(--color-border);
margin: 32px 0;
}
/* --- Content Card / Section Styling --- */
.content-card,
.section {
background-color: var(--color-surface);
padding: 24px;
margin-bottom: 24px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
}
.admin-header p {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0;
color: var(--color-text);
font-weight: 500;
}
.admin-header span {
font-weight: 600;
color: var(--color-primary);
}
/* --- Forms --- */
form .form-group {
margin-bottom: 16px;
}
/* For side-by-side input and button */
form .inline-form-group {
display: flex;
gap: 10px;
align-items: flex-start; /* Align items to top */
}
form .inline-form-group input {
flex-grow: 1; /* Allow input to take available space */
margin-bottom: 0; /* Remove bottom margin */
}
form .inline-form-group button {
flex-shrink: 0; /* Prevent button from shrinking */
}
label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text-light);
}
input[type="text"],
input[type="password"],
input[type="email"],
input[type="url"],
input[type="number"],
textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
font-size: 0.95rem;
color: var(--color-text);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="email"]:focus,
input[type="url"]:focus,
input[type="number"]:focus,
textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(176, 111, 66, 0.2); /* Accent focus ring */
}
textarea {
min-height: 80px;
resize: vertical;
}
/* Styling for dynamically generated public form fields */
#public-form div {
margin-bottom: 16px; /* Keep consistent spacing */
}
/* Specific styles for checkboxes */
#public-form input[type="checkbox"] {
width: auto; /* Override 100% width */
margin-right: 10px;
vertical-align: middle; /* Align checkbox nicely with label text */
margin-bottom: 0; /* Remove bottom margin if label handles spacing */
}
#public-form input[type="checkbox"] + label, /* Style label differently if needed when next to checkbox */
#public-form label input[type="checkbox"] /* Style if checkbox is inside label */ {
display: inline-flex; /* Or inline-block */
align-items: center;
margin-bottom: 0; /* Prevent double margin */
font-weight: normal; /* Checkboxes often have normal weight labels */
color: var(--color-text);
}
/* --- Buttons --- */
.button {
background-color: var(--color-primary);
color: white;
border: 1px solid transparent; /* Add border for consistency */
padding: 10px 18px;
border-radius: var(--border-radius);
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
text-decoration: none;
line-height: 1.5;
vertical-align: middle; /* Align with text/inputs */
}
.button:hover {
background-color: #2c373f; /* Slightly darker hover */
box-shadow: var(--shadow-sm);
}
.button:active {
background-color: #1e2a31; /* Even darker active state */
}
.button-secondary {
background-color: var(--color-surface);
color: var(--color-primary);
border: 1px solid var(--color-border);
}
.button-secondary:hover {
background-color: #f8f8f8; /* Subtle hover for secondary */
border-color: #d0d0d0;
}
.button-secondary:active {
background-color: #f0f0f0;
}
.button-danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
}
.button-danger:hover {
background-color: var(--color-danger-hover);
border-color: var(--color-danger-hover);
}
.button-danger:active {
background-color: #a52e22; /* Even darker red */
}
/* Smaller button variant for lists? */
.button-sm {
padding: 5px 10px;
font-size: 0.8rem;
}
/* Ensure buttons added by JS (like submit in public form) get styled */
#public-form button[type="submit"] {
/* Inherit .button styles if possible, otherwise redefine */
background-color: var(--color-primary);
color: white;
border: 1px solid transparent;
padding: 10px 18px;
border-radius: var(--border-radius);
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
line-height: 1.5;
margin-top: 10px; /* Add some space above submit */
}
#public-form button[type="submit"]:hover {
background-color: #2c373f;
box-shadow: var(--shadow-sm);
}
#public-form button[type="submit"]:active {
background-color: #1e2a31;
}
/* --- Lists (Forms & Submissions) --- */
ul.styled-list {
list-style: none;
padding: 0;
margin-top: 20px; /* Space below heading/button */
}
ul.styled-list li {
background-color: #fcfcfc; /* Slightly off-white */
border: 1px solid var(--color-border);
padding: 12px 16px;
margin-bottom: 8px;
border-radius: var(--border-radius);
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s ease;
font-size: 0.95rem;
}
ul.styled-list li:hover {
background-color: #f5f5f5;
}
ul.styled-list li button {
margin-left: 16px; /* Space between text and button */
/* Use smaller button style */
padding: 5px 10px;
font-size: 0.8rem;
/* Inherit base button colors or use secondary */
background-color: var(--color-surface);
color: var(--color-primary);
border: 1px solid var(--color-border);
}
ul.styled-list li button:hover {
background-color: #f8f8f8;
border-color: #d0d0d0;
}
/* Specific styling for submissions list items */
ul.submissions li {
display: block; /* Allow pre tag to format */
background-color: var(--color-surface); /* White background for submissions */
}
ul.submissions li pre {
white-space: pre-wrap; /* Wrap long lines */
word-wrap: break-word; /* Break long words */
background-color: #f9f9f9; /* Light grey background for code block */
padding: 10px;
border-radius: var(--border-radius);
border: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text);
max-height: 200px; /* Limit height */
overflow-y: auto; /* Add scroll if needed */
}
/* --- Status Area --- */
.status {
padding: 12px 16px;
margin-bottom: 20px;
border-radius: var(--border-radius);
font-weight: 500;
border: 1px solid transparent;
display: none; /* Hide by default, JS shows it */
}
.status.success,
.status.error {
display: block; /* Show when class is added */
}
.status.success {
background-color: var(--color-success-bg);
color: var(--color-success);
border-color: var(--color-success); /* Darker green border */
}
.status.error {
background-color: var(--color-error-bg);
color: var(--color-error);
border-color: var(--color-error); /* Darker red border */
white-space: pre-wrap; /* Allow multi-line errors */
}
/* --- Utility --- */
.hidden {
display: none !important; /* Use !important to override potential inline styles if needed */
}
/* --- Responsive Adjustments (Basic) --- */
@media (max-width: 768px) {
.container {
padding: 24px 16px;
}
h1.page-title {
font-size: 1.5rem;
}
h2.section-title {
font-size: 1.15rem;
}
ul.styled-list li {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
ul.styled-list li button {
margin-left: 0;
align-self: flex-end; /* Move button to bottom right */
}
form .inline-form-group {
flex-direction: column;
align-items: stretch; /* Make elements full width */
}
form .inline-form-group button {
width: 100%; /* Make button full width */
}
}
@media (max-width: 576px) {
.content-card,
.section {
padding: 16px;
}
.button {
padding: 8px 14px;
font-size: 0.85rem;
}
}

133
init.sql Normal file
View File

@ -0,0 +1,133 @@
-- init.sql
CREATE DATABASE IF NOT EXISTS forms_db;
USE forms_db;
-- Users table for authentication and authorization
CREATE TABLE IF NOT EXISTS `users` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`uuid` TEXT NOT NULL UNIQUE,
`email` TEXT NOT NULL UNIQUE,
`password_hash` TEXT NOT NULL,
`first_name` TEXT DEFAULT NULL,
`last_name` TEXT DEFAULT NULL,
`role` TEXT DEFAULT 'user' CHECK(`role` IN ('user', 'admin', 'super_admin')),
`is_verified` INTEGER DEFAULT 0,
`is_active` INTEGER DEFAULT 1,
`verification_token` TEXT DEFAULT NULL,
`password_reset_token` TEXT DEFAULT NULL,
`password_reset_expires` DATETIME NULL DEFAULT NULL,
`last_login` DATETIME NULL DEFAULT NULL,
`failed_login_attempts` INTEGER DEFAULT 0,
`account_locked_until` DATETIME NULL DEFAULT NULL,
`must_change_password` INTEGER DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (`email`),
UNIQUE (`uuid`)
);
CREATE INDEX IF NOT EXISTS `idx_email` ON `users` (`email`);
CREATE INDEX IF NOT EXISTS `idx_verification_token` ON `users` (`verification_token`);
CREATE INDEX IF NOT EXISTS `idx_password_reset_token` ON `users` (`password_reset_token`);
CREATE INDEX IF NOT EXISTS `idx_uuid_users` ON `users` (`uuid`);
-- User sessions table for JWT blacklisting and session management
CREATE TABLE IF NOT EXISTS `user_sessions` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`user_id` INTEGER NOT NULL,
`token_jti` TEXT NOT NULL UNIQUE,
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`user_agent` TEXT DEFAULT NULL,
`ip_address` TEXT DEFAULT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS `idx_token_jti` ON `user_sessions` (`token_jti`);
CREATE INDEX IF NOT EXISTS `idx_user_id_sessions` ON `user_sessions` (`user_id`);
CREATE INDEX IF NOT EXISTS `idx_expires_at_sessions` ON `user_sessions` (`expires_at`);
-- Update forms table to associate with users
CREATE TABLE IF NOT EXISTS `forms` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`uuid` TEXT NOT NULL UNIQUE,
`user_id` INTEGER NOT NULL,
`name` TEXT DEFAULT 'My Form',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`thank_you_url` TEXT DEFAULT NULL,
`thank_you_message` TEXT DEFAULT NULL,
`ntfy_enabled` INTEGER DEFAULT 1,
`is_archived` INTEGER DEFAULT 0,
`allowed_domains` TEXT DEFAULT NULL,
`email_notifications_enabled` INTEGER NOT NULL DEFAULT 0,
`notification_email_address` TEXT DEFAULT NULL,
`recaptcha_enabled` INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS `idx_user_id_forms` ON `forms` (`user_id`);
CREATE INDEX IF NOT EXISTS `idx_uuid_forms` ON `forms` (`uuid`);
CREATE TABLE IF NOT EXISTS `submissions` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`form_uuid` TEXT NOT NULL,
`user_id` INTEGER NOT NULL,
`data` TEXT NOT NULL, -- Storing JSON as TEXT
`ip_address` TEXT NULL,
`submitted_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`form_uuid`) REFERENCES `forms`(`uuid`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS `idx_form_uuid_submissions` ON `submissions` (`form_uuid`);
CREATE INDEX IF NOT EXISTS `idx_user_id_submissions` ON `submissions` (`user_id`);
CREATE INDEX IF NOT EXISTS `idx_submitted_at_submissions` ON `submissions` (`submitted_at`);
-- Rate limiting table for enhanced security (Simplified for SQLite)
-- Note: TIMESTAMP logic for window_start and expires_at might need application-level management
-- depending on how it was used with MySQL.
CREATE TABLE IF NOT EXISTS `rate_limits` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`identifier` TEXT NOT NULL,
`action` TEXT NOT NULL,
`count` INTEGER DEFAULT 1,
`window_start` DATETIME DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
UNIQUE (`identifier`, `action`)
);
CREATE INDEX IF NOT EXISTS `idx_identifier_action_rate_limits` ON `rate_limits` (`identifier`, `action`);
CREATE INDEX IF NOT EXISTS `idx_expires_at_rate_limits` ON `rate_limits` (`expires_at`);
-- Create default admin user (password will be set on first login)
-- You should change this immediately after first login
INSERT OR IGNORE INTO users (email, password_hash, first_name, last_name, role, is_verified, is_active, must_change_password, uuid)
VALUES ('admin@formies.local', 'NEEDS_TO_BE_SET_ON_FIRST_LOGIN', 'Admin', 'User', 'super_admin', 1, 1, 1, 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'); -- Placeholder UUID, generate dynamically in app if needed
-- API Keys table for user-generated API access
CREATE TABLE IF NOT EXISTS `api_keys` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`uuid` TEXT NOT NULL UNIQUE,
`user_id` INTEGER NOT NULL,
`key_name` TEXT DEFAULT NULL,
`api_key_identifier` TEXT NOT NULL UNIQUE, -- Public, non-secret identifier for lookup
`hashed_api_key_secret` TEXT NOT NULL, -- Hashed version of the secret part of the API key
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME NULL DEFAULT NULL,
`expires_at` DATETIME NULL DEFAULT NULL, -- For future use
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS `idx_user_id_api_keys` ON `api_keys` (`user_id`);
CREATE INDEX IF NOT EXISTS `idx_api_key_identifier_api_keys` ON `api_keys` (`api_key_identifier`);
-- Trigger to update 'updated_at' timestamp on users table (optional, can be handled in app code)
CREATE TRIGGER IF NOT EXISTS update_users_updated_at
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
END;
-- Trigger to update 'updated_at' timestamp on forms table (optional, can be handled in app code)
CREATE TRIGGER IF NOT EXISTS update_forms_updated_at
AFTER UPDATE ON forms
FOR EACH ROW
BEGIN
UPDATE forms SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
END;

28
jest.config.js Normal file
View File

@ -0,0 +1,28 @@
// jest.config.js
module.exports = {
testEnvironment: "node",
verbose: true,
coveragePathIgnorePatterns: [
"/node_modules/",
"/__tests__/setup/", // Ignore setup files from coverage
"/src/config/", // Often configuration files don't need testing
"/config/", // logger config
],
// Automatically clear mock calls and instances between every test
clearMocks: true,
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: './__tests__/setup/globalSetup.js', // Optional: If you need global setup
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: './__tests__/setup/globalTeardown.js', // Optional: If you need global teardown
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: [
"src/**/*.js",
"!server.js", // Usually the main server start file is hard to unit test directly
"!src/app.js", // If you extract Express app setup to app.js for testability
],
setupFilesAfterEnv: ["./__tests__/setup/jest.setup.js"], // For things like extending expect
};

View File

@ -0,0 +1,31 @@
const logger = require("../config/logger");
const errorHandler = (err, req, res, next) => {
logger.error(err.message, {
stack: err.stack,
path: req.path,
method: req.method,
});
// If the error is a known type, customize the response
// Otherwise, send a generic server error
if (err.isOperational) {
// You can add an 'isOperational' property to your custom errors
res.status(err.statusCode || 500).json({
error: {
message: err.message,
code: err.errorCode || "INTERNAL_SERVER_ERROR",
},
});
} else {
// For unexpected errors, don't leak details to the client
res.status(500).json({
error: {
message: "An unexpected error occurred.",
code: "INTERNAL_SERVER_ERROR",
},
});
}
};
module.exports = errorHandler;

340
notes.md Normal file
View File

@ -0,0 +1,340 @@
## Task 2.1: User Dashboard & Form Management UI (Replacing current "admin")
- Mindset Shift: This is no longer an admin panel. It's the user's control center.
### Subtask 2.1.1: Design User Dashboard Layout
- **Wireframe basic layout:**
- **Navigation Bar:**
- Logo/App Name (e.g., "Formies")
- My Forms (Active Link)
- Create New Form
- Account Settings (e.g., "Hi, [User Name]" dropdown with "Settings", "Logout")
- **Main Content Area (for "My Forms" view):**
- Header: "My Forms"
- Button: "+ Create New Form"
- Forms List Table:
- Columns: Form Name, Submissions (count), Endpoint URL, Created Date, Actions
- Actions per row: View Submissions, Settings, Archive/Delete
- Pagination for the forms list if it becomes long.
- **Main Content Area (for "Create New Form" view - initial thought, might be a separate page/modal):**
- Header: "Create New Form"
- Form fields: Form Name
- Button: "Create Form"
- **Main Content Area (for "Account Settings" - placeholder for now):**
- Header: "Account Settings"
- Placeholder content.
- **Frontend Tech Decision:**
- EJS for templating, made dynamic with client-side JavaScript. This aligns with the existing structure and MVP scope. We will enhance EJS views to be more interactive.
[X] Wireframe basic layout: List forms, create form, account settings (placeholder). - _Textual wireframe defined above_
[X] Decide on frontend tech (EJS is fine for MVP if you make it dynamic with client-side JS, or consider a simple SPA framework if comfortable). - _Decision made: EJS with client-side JS_
- Created `views/dashboard.ejs` as the main layout.
- Created `views/partials/_forms_table.ejs` for displaying the list of forms.
### Subtask 2.1.2: "My Forms" View:
- Objective: Fetch and display forms owned by the logged-in user.
- Show key info: name, submission count, endpoint URL, created date, status (Active/Archived).
- Links/Actions: View Submissions, Settings, Archive/Unarchive, Delete.
- Frontend: `views/dashboard.ejs` with `view = 'my_forms'` and `views/partials/_forms_table.ejs` will handle this.
- Backend:
- Need a new route, e.g., `GET /dashboard`, protected by authentication (e.g., `requireAuth` from `authMiddleware.js`).
- This route handler will:
- Fetch forms for `req.user.id` from the database.
- Query should include `name`, `uuid`, `created_at`, `is_archived`, and `submission_count`.
- Render `views/dashboard.ejs` passing the forms data, `user` object, `appUrl`, and `view = 'my_forms'`.
- Implemented in `src/routes/dashboard.js` via GET `/`.
[X] Fetch and display forms owned by the logged-in user.
[X] Show key info: name, submission count, endpoint URL, created date.
[X] Links to: view submissions, edit settings, delete. (Links are present in `_forms_table.ejs`, functionality for all to be built out in subsequent tasks)
### Subtask 2.1.3: "Create New Form" Functionality (for logged-in user):
- UI: `dashboard.ejs` (with `view = 'create_form'`) provides the form input.
- Route `GET /dashboard/create-form` in `src/routes/dashboard.js` renders this view.
- Backend: `POST /dashboard/forms/create` route in `src/routes/dashboard.js` handles form submission.
- Associates form with `req.user.id`.
- Redirects to `/dashboard` on success.
- Handles errors and re-renders create form view with an error message.
[X] UI and backend logic. Associates form with req.user.id.
### Subtask 2.1.4: "View Form Submissions" (Scoped & Paginated):
- Objective: Allow users to view submissions for their specific forms, with pagination.
- UI:
- `views/partials/_submissions_view.ejs` created to display submissions list and pagination.
- `views/dashboard.ejs` updated to include this partial when `view = 'form_submissions'`.
- Backend:
- Route: `GET /dashboard/submissions/:formUuid` added to `src/routes/dashboard.js`.
- Verifies that `req.user.id` owns the `formUuid`.
- Fetches paginated submissions for the given `formUuid`.
- Renders `dashboard.ejs` with `view = 'form_submissions'`, passing submissions data, form details, and pagination info.
- Error handling improved to render user-friendly messages within the dashboard view.
[X] UI and backend for a user to view submissions for their specific form.
[X] Pagination is critical here (as you have).
### Subtask 2.1.5: Form Settings UI (Basic):
- Objective: Allow users to update basic form settings, starting with the form name.
- UI:
- A new view/section in `dashboard.ejs` (e.g., when `view = 'form_settings'`).
- This view will display a form with an input for the form name.
- It will also be a placeholder for future settings (thank you URL, notifications).
- Backend:
- Route: `GET /dashboard/forms/:formUuid/settings` to display the settings page.
- Implemented in `src/routes/dashboard.js`.
- Verifies form ownership by `req.user.id`.
- Fetches current form details (name).
- Renders the `form_settings` view in `dashboard.ejs`.
- Route: `POST /dashboard/forms/:formUuid/settings/update-name` to handle the update.
- Implemented in `src/routes/dashboard.js`.
- Verifies form ownership.
- Updates the form name in the database.
- Redirects back to form settings page with a success/error message via query parameters.
[X] Allow users to update form name.
[X] Placeholder for future settings (thank you URL, notifications) - (Placeholders added in EJS).
### Subtask 2.1.6: Delete Form/Submission (with soft delete/archival consideration):
- Objective: Implement form archival (soft delete) and permanent deletion for users.
- Users should be able to archive/unarchive their forms.
- True delete should be a confirmed, rare operation.
- The `is_archived` field in the `forms` table will be used.
- Submissions deletion is already partially handled in `_submissions_view.ejs` via a POST to `/dashboard/submissions/delete/:submissionId`. We need to implement this backend route.
- **Form Archival/Unarchival:**
- UI: Buttons for "Archive" / "Unarchive" are already in `views/partials/_forms_table.ejs`.
- Archive action: `POST /dashboard/forms/archive/:formUuid`
- Unarchive action: `POST /dashboard/forms/unarchive/:formUuid`
- Backend:
- Create these two POST routes in `src/routes/dashboard.js`.
- Must verify form ownership by `req.user.id`.
- Fetch current form details (name).
- Render the settings view.
- Route: `POST /dashboard/forms/:formUuid/settings` (or `/dashboard/forms/:formUuid/update-name`) to handle the update.
- Must verify form ownership.
- Update the form name in the database.
- Redirect back to form settings page or main dashboard with a success message.
* **Submission Deletion (User-scoped):**
- UI: "Delete" button per submission in `views/partials/_submissions_view.ejs` (with `confirm()` dialog).
- Action: `POST /dashboard/submissions/delete/:submissionId`
- Backend (in `src/routes/dashboard.js`):
- Implemented `POST /dashboard/submissions/delete/:submissionId`:
- Verifies the `req.user.id` owns the form to which the submission belongs.
- Deletes the specific submission.
- Redirects back to the form's submissions view (`/dashboard/submissions/:formUuid`) with message.
[X] You have is_archived. Solidify this. Users should be able to archive/unarchive.
[X] True delete should be a confirmed, rare operation.
[X] Implement user-scoped submission deletion.
## Task 2.2: Per-Form Configuration by User
- Mindset Shift: Empower users to customize their form behavior.
### Subtask 2.2.1: Database Schema Updates for forms Table:
- Objective: Add new fields to the `forms` table to support per-form email notification settings.
- Review existing fields (`thank_you_url`, `thank_you_message`, `ntfy_enabled`, `allowed_domains`) - these are good as per plan.
- **New fields to add:**
- `email_notifications_enabled` (BOOLEAN, DEFAULT FALSE, NOT NULL)
- `notification_email_address` (VARCHAR(255), NULL) - This will store an override email address. If NULL, the user's primary email will be used.
[X] Review existing fields (thank_you_url, thank_you_message, ntfy_enabled, allowed_domains). These are good.
[X] Add email_notifications_enabled (boolean). (Added to `init.sql`)
[X] Add notification_email_address (string, defaults to user's email, but allow override). (Added to `init.sql`)
### Subtask 2.2.2: UI for Form Settings Page:
- Objective: Enhance the form settings page to allow users to configure these new email notification options.
- The existing form settings page is `dashboard.ejs` with `view = 'form_settings'` (created in Subtask 2.1.5).
- **UI Elements to add to this page:**
- **Email Notifications Section:**
- Checkbox/Toggle: "Enable Email Notifications for new submissions"
- Controls `email_notifications_enabled`.
- Input Field (text, email type): "Notification Email Address"
- Controls `notification_email_address`.
- Should be pre-filled with the user's primary email if `notification_email_address` is NULL/empty in the DB.
- Label should indicate that if left blank, notifications will go to the account email.
- The `GET /dashboard/forms/:formUuid/settings` route will need to fetch these new fields.
- The form on this page will need to be updated to submit these new fields. The POST route will likely be `/dashboard/forms/:formUuid/settings/update-notifications` or similar, or a general update to the existing `/dashboard/forms/:formUuid/settings/update-name` to become a general settings update route.
[X] Create a dedicated page/modal for each form's settings. (Using existing settings section in `dashboard.ejs`)
[X] Allow users to edit: Name, Email Notification toggle, Notification Email Address. (Thank You URL, Thank You Message, Allowed Domains are placeholders for now as per 2.1.5).
_ UI elements added to `dashboard.ejs` in the `form_settings` view.
_ `GET /dashboard/forms/:formUuid/settings` in `src/routes/dashboard.js` updated to fetch and pass these settings. \* `POST /dashboard/forms/:formUuid/settings/update-notifications` in `src/routes/dashboard.js` created to save these settings.
### Subtask 2.2.3: Backend to Save and Apply Settings:
- Objective: Ensure the backend API endpoints correctly save and the submission logic uses these settings.
- API endpoints to update settings for a specific form (owned by user):
- `POST .../update-name` (Done in 2.1.5)
- `POST .../update-notifications` (Done in 2.2.2)
- Future: endpoints for Thank You URL, Message, Allowed Domains.
- Logic in `/submit/:formUuid` to use these form-specific settings:
- When a form is submitted to `/submit/:formUuid`:
- Fetch the form's settings from the DB, including `email_notifications_enabled` and `notification_email_address`.
- This logic is now implemented in `src/routes/public.js` as part of Task 2.3.2 integration.
[X] API endpoints to update these settings for a specific form (owned by user). (Name and Email Notification settings covered so far)
[X] Logic in /submit/:formUuid to use these form-specific settings. (Addressed as part of 2.3.2)
## Task 2.3: Email Notifications for Submissions (Core Feature)
- Mindset Shift: Ntfy is cool for you. Users expect email.
### Subtask 2.3.1: Integrate Transactional Email Service:
- Objective: Set up a third-party email service to send submission notifications.
- **Action for you (USER):**
- Choose a transactional email service (e.g., SendGrid, Mailgun, AWS SES). Many offer free tiers.
- Sign up for the service and obtain an API Key.
- Securely store this API Key as an environment variable in your `.env` file.
- For example, if you choose SendGrid, you might use `SENDGRID_API_KEY=your_actual_api_key`.
- Also, note the sender email address you configure with the service (e.g., `EMAIL_FROM_ADDRESS=notifications@yourdomain.com`).
- Once you have these, let me know which service you've chosen so I can help with installing the correct SDK and writing the integration code.
- User selected: Resend
- API Key ENV Var: `RESEND_API_KEY`
- From Email ENV Var: `EMAIL_FROM_ADDRESS`
[X] Sign up for SendGrid, Mailgun, AWS SES (free tiers available). (User selected Resend)
[X] Install their SDK. (npm install resend done)
[X] Store API key securely (env vars). (User confirmed `RESEND_API_KEY` and `EMAIL_FROM_ADDRESS` are set up)
### Subtask 2.3.2: Email Sending Logic:
- Objective: Create a reusable service/function to handle the sending of submission notification emails.
- This service will use the Resend SDK and the configured API key.
- **Create a new service file:** `src/services/emailService.js`
- It should export a function, e.g., `sendSubmissionNotification(form, submissionData, userEmail)`.
- `form`: An object containing form details (`name`, `email_notifications_enabled`, `notification_email_address`).
- `submissionData`: The actual data submitted to the form.
- `userEmail`: The email of the user who owns the form (to be used if `form.notification_email_address` is not set).
- Inside the function:
- Check if `form.email_notifications_enabled` is true.
- Determine the recipient: `form.notification_email_address` or `userEmail`.
- Construct the email subject and body (using a basic template for now - Subtask 2.3.3).
- Use the Resend SDK to send the email.
- Include error handling (Subtask 2.3.4).
[X] Create a service/function sendSubmissionNotification(form, submissionData, userEmail) - (`src/services/emailService.js` created with this function).
[X] If email_notifications_enabled for the form, send an email to notification_email_address (or user's email). - (Logic implemented in `emailService.js` and integrated into `/submit/:formUuid` route in `src/routes/public.js`).
### Subtask 2.3.3: Basic Email Template:
- Objective: Define a simple, clear email template for notifications.
- The current `createEmailHtmlBody` function in `src/services/emailService.js` provides a very basic HTML template:
- Subject: "New Submission for [Form Name]"
- Body: Lists submitted data (key-value pairs).
- This fulfills the MVP requirement.
[X] Simple, clear email: "New Submission for [Form Name]", list submitted data. (Implemented in `emailService.js`)
### Subtask 2.3.4: Error Handling for Email Sending:
- Objective: Ensure email sending failures don't break the submission flow and are logged.
- In `src/services/emailService.js`, within `sendSubmissionNotification`:
- Errors from `resend.emails.send()` are caught and logged.
- The function does not throw an error that would halt the caller, allowing the submission to be considered successful even if the email fails.
- In `src/routes/public.js` (`/submit/:formUuid` route):
- The call to `sendSubmissionNotification` is followed by `.catch()` to log any unexpected errors from the email sending promise itself, ensuring the main response to the user is not blocked.
[X] Log errors if email fails to send; don't let it break the submission flow. (Implemented in `emailService.js` and `public.js` route)
## Task 2.4: Enhanced Spam Protection (Beyond Basic Honeypot)
- Mindset Shift: Your honeypot is step 1. Real services need more.
### Subtask 2.4.1: Integrate CAPTCHA (e.g., Google reCAPTCHA):
- Objective: Add server-side CAPTCHA validation to the form submission process.
- We'll use Google reCAPTCHA v2 ("I'm not a robot" checkbox) for this MVP.
- **Action for you (USER):**
- Go to the [Google reCAPTCHA admin console](https://www.google.com/recaptcha/admin/create).
- Register your site: Choose reCAPTCHA v2, then "I'm not a robot" Checkbox.
- Add your domain(s) (e.g., `localhost` for development, and your production domain).
- Accept the terms of service.
- You will receive a **Site Key** and a **Secret Key**.
- Store these securely in your `.env` file:
- `RECAPTCHA_V2_SITE_KEY=your_site_key`
- `RECAPTCHA_V2_SECRET_KEY=your_secret_key`
- Let me know once you have these keys set up in your `.env` file.
- **Frontend Changes (Illustrative - User will implement on their actual forms):**
- User needs to include the reCAPTCHA API script in their HTML form page: `<script src="https://www.google.com/recaptcha/api.js" async defer></script>`
- User needs to add the reCAPTCHA widget div where the checkbox should appear: `<div class="g-recaptcha" data-sitekey="YOUR_RECAPTCHA_V2_SITE_KEY"></div>` (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.

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "formies",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "NODE_ENV=test jest --runInBand --detectOpenHandles --forceExit",
"test:watch": "NODE_ENV=test jest --watch",
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"dotenv": "^16.5.0",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-rate-limit": "^7.1.5",
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.8",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0",
"resend": "^4.5.1",
"sqlite3": "^5.1.7",
"uuid": "^11.1.0",
"winston": "^3.17.0"
},
"devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0",
"supertest": "^7.0.0"
}
}

File diff suppressed because it is too large Load Diff

195
server.js Normal file
View File

@ -0,0 +1,195 @@
require("dotenv").config();
const express = require("express");
const path = require("path");
const fs = require("fs"); // Added for fs operations
const db = require("./src/config/database"); // SQLite db instance
const helmet = require("helmet");
const session = require("express-session");
const passport = require("./src/config/passport");
const logger = require("./config/logger");
const errorHandler = require("./middleware/errorHandler");
const { connectRedis, closeRedis } = require("./src/config/redis");
// Import routes
const publicRoutes = require("./src/routes/public");
const authRoutes = require("./src/routes/auth");
const dashboardRoutes = require("./src/routes/dashboard");
const apiV1Routes = require("./src/routes/api_v1");
const app = express();
const PORT = process.env.PORT || 3000;
// Function to initialize the database
async function initializeDatabase() {
const dbPath = path.resolve(__dirname, "formies.sqlite");
const dbExists = fs.existsSync(dbPath);
if (!dbExists) {
logger.info("Database file not found, creating and initializing...");
try {
// The 'db' instance from './src/config/database' should already create the file.
// Now, run the init.sql script.
const initSql = fs.readFileSync(
path.resolve(__dirname, "init.sql"),
"utf8"
);
// SQLite driver's `exec` method can run multiple statements
await new Promise((resolve, reject) => {
db.exec(initSql, (err) => {
if (err) {
logger.error("Failed to initialize database:", err);
return reject(err);
}
logger.info("Database initialized successfully.");
resolve();
});
});
} catch (error) {
logger.error("Error during database initialization:", error);
process.exit(1); // Exit if DB initialization fails
}
} else {
logger.info("Database file found.");
}
}
// Initialize Redis connection and Database
async function initializeApp() {
// Initialize Redis first, but don't block on failure
connectRedis().catch(() => {
logger.warn(
"Redis connection failed, continuing with in-memory rate limiting"
);
});
try {
await initializeDatabase(); // Initialize SQLite database
} catch (error) {
logger.error("Failed to initialize database:", error);
process.exit(1); // Exit if DB initialization fails
}
// Middleware
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
})
);
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Session configuration (for development only, use Redis in production)
app.use(
session({
secret:
process.env.SESSION_SECRET || "fallback-secret-change-in-production",
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
})
);
// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());
// Set view engine
app.set("view engine", "ejs");
// API Routes
app.use("/api/auth", authRoutes);
// API V1 Routes
app.use("/api/v1", apiV1Routes);
// User Dashboard Routes
app.use("/dashboard", dashboardRoutes);
// Existing routes (maintaining backward compatibility)
app.use("/", publicRoutes);
// Health check endpoint
app.get("/health", (req, res) => {
res.json({
status: "healthy",
timestamp: new Date().toISOString(),
version: "1.0.0",
});
});
// Global error handler - should be the last middleware
app.use(errorHandler);
// 404 handler
app.use((req, res) => {
logger.warn(
`404 - Endpoint not found: ${req.originalUrl} - Method: ${req.method} - IP: ${req.ip}`
);
res.status(404).json({
error: {
message: "Endpoint not found",
code: "NOT_FOUND",
},
});
});
// Start server
app.listen(PORT, () => {
logger.info(`Server running on http://localhost:${PORT}`);
// Environment checks
if (!process.env.JWT_SECRET) {
logger.warn(
"WARNING: JWT_SECRET not set. Authentication will not work properly."
);
}
if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
logger.info(
`Ntfy notifications enabled for topic: ${process.env.NTFY_TOPIC_URL}`
);
} else {
logger.info("Ntfy notifications disabled or topic not configured.");
}
// Start cleanup of expired sessions every hour
setInterval(
() => {
const jwtService = require("./src/services/jwtService");
jwtService.cleanupExpiredSessions();
},
60 * 60 * 1000
);
});
// Graceful shutdown
process.on("SIGINT", async () => {
logger.info("Received SIGINT, shutting down gracefully...");
await closeRedis();
process.exit(0);
});
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM, shutting down gracefully...");
await closeRedis();
process.exit(0);
});
}
// Initialize the application
initializeApp().catch((error) => {
logger.error("Failed to initialize application:", error);
process.exit(1);
});

View File

@ -1,119 +0,0 @@
// src/auth.rs
use super::AppState;
use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; // Specific error types
use actix_web::{
dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest,
HttpRequest,
};
use chrono::Utc;
use futures::future::{ready, Ready};
use log; // Use the log crate
use rusqlite::params;
use rusqlite::Connection;
use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely)
// Represents an authenticated user via token
pub struct Auth {
pub user_id: String,
pub role: String,
}
impl FromRequest for Auth {
// Use actix_web::Error for consistency in error handling within Actix
type Error = ActixWebError;
// Use Ready from futures 0.3
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
// Extract database connection pool from application data
// Extract the *whole* AppState first
let app_state_result = req.app_data::<web::Data<AppState>>();
// Get the Arc<Mutex<Connection>> from AppState
let db_arc_mutex = match app_state_result {
// Access the 'db' field within the AppState
Some(data) => data.db.clone(), // Clone the Arc, not the Mutex or Connection
None => {
log::error!("Database connection missing in application data configuration.");
return ready(Err(ErrorInternalServerError(
"Internal server error (app configuration)",
)));
}
};
// Extract Authorization header
let auth_header = req.headers().get(AUTHORIZATION);
if let Some(auth_header_value) = auth_header {
// Convert header value to string
if let Ok(auth_str) = auth_header_value.to_str() {
// Check if it starts with "Bearer "
if auth_str.starts_with("Bearer ") {
// Extract the token part
let token = &auth_str[7..];
// Lock the mutex to get access to the connection
// Handle potential mutex poisoning explicitly
let conn_guard = match db_arc_mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => {
log::error!("Database mutex poisoned: {}", poisoned);
// Return internal server error if mutex is poisoned
return ready(Err(ErrorInternalServerError(
"Internal server error (database lock)",
)));
}
};
// Get user_id and role from token
let user_result = conn_guard
.query_row(
"SELECT u.id, u.role FROM users u WHERE u.token = ?1 AND u.token_expires_at > ?2",
params![token, Utc::now().to_rfc3339()],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional();
match user_result {
Ok(Some((user_id, role))) => {
log::debug!(
"Token validated successfully for user_id: {} with role: {}",
user_id,
role
);
ready(Ok(Auth { user_id, role }))
}
Ok(None) => {
log::warn!("Invalid or expired token received");
ready(Err(ErrorUnauthorized("Invalid or expired token")))
}
Err(e) => {
log::error!("Database error during token validation: {:?}", e);
ready(Err(ErrorUnauthorized("Token validation failed")))
}
}
} else {
// Header present but not "Bearer " format
log::warn!("Invalid Authorization header format (not Bearer)");
ready(Err(ErrorUnauthorized("Invalid token format")))
}
} else {
// Header value contains invalid characters
log::warn!("Authorization header contains invalid characters");
ready(Err(ErrorUnauthorized("Invalid token value")))
}
} else {
// Authorization header is missing
log::warn!("Missing Authorization header");
ready(Err(ErrorUnauthorized("Missing authorization token")))
}
}
}
// Helper function to check if a user has admin role
pub fn require_admin(auth: &Auth) -> Result<(), ActixWebError> {
if auth.role != "admin" {
return Err(ErrorUnauthorized("Admin access required"));
}
Ok(())
}

20
src/config/database.js Normal file
View File

@ -0,0 +1,20 @@
const sqlite3 = require("sqlite3").verbose();
const path = require("path");
const dbPath = path.resolve(__dirname, "../../formies.sqlite");
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error("Error opening database", err.message);
} else {
console.log("Connected to the SQLite database.");
// Enable foreign key support
db.run("PRAGMA foreign_keys = ON;", (pragmaErr) => {
if (pragmaErr) {
console.error("Failed to enable foreign keys:", pragmaErr.message);
}
});
}
});
module.exports = db;

170
src/config/passport.js Normal file
View File

@ -0,0 +1,170 @@
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const JwtStrategy = require("passport-jwt").Strategy;
const ExtractJwt = require("passport-jwt").ExtractJwt;
const bcrypt = require("bcryptjs");
const User = require("../models/User");
// Local Strategy for email/password authentication
passport.use(
new LocalStrategy(
{
usernameField: "email",
passwordField: "password",
},
async (email, password, done) => {
try {
// Find user by email
const user = await User.findByEmail(email);
if (!user) {
return done(null, false, { message: "Invalid email or password" });
}
// Check if account is locked
if (
user.account_locked_until &&
new Date() < user.account_locked_until
) {
return done(null, false, {
message:
"Account temporarily locked due to multiple failed login attempts",
});
}
// Check if account is active
if (!user.is_active) {
return done(null, false, { message: "Account has been deactivated" });
}
// Check if email is verified (for non-admin users)
if (!user.is_verified && user.role !== "super_admin") {
return done(null, false, {
message: "Please verify your email address before logging in",
});
}
// Verify password
const isValidPassword = await bcrypt.compare(
password,
user.password_hash
);
if (!isValidPassword) {
// Increment failed login attempts
await User.incrementFailedLoginAttempts(user.id);
return done(null, false, { message: "Invalid email or password" });
}
// Reset failed login attempts and update last login
await User.resetFailedLoginAttempts(user.id);
await User.updateLastLogin(user.id);
// Remove sensitive information before returning user
const userSafe = {
id: user.id,
uuid: user.uuid,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_verified: user.is_verified,
is_active: user.is_active,
created_at: user.created_at,
last_login: user.last_login,
must_change_password: user.must_change_password,
};
return done(null, userSafe);
} catch (error) {
return done(error);
}
}
)
);
// JWT Strategy for token-based authentication
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET || "trhrtjtzmkjt56fgdfg3tcvv",
issuer: process.env.JWT_ISSUER || "formies",
audience: process.env.JWT_AUDIENCE || "formies-users",
},
async (payload, done) => {
try {
// Check if token is blacklisted
const isBlacklisted = await User.isTokenBlacklisted(payload.jti);
if (isBlacklisted) {
return done(null, false, { message: "Token has been revoked" });
}
// Find user by ID
const user = await User.findById(payload.sub);
if (!user) {
return done(null, false, { message: "User not found" });
}
// Check if account is active
if (!user.is_active) {
return done(null, false, { message: "Account has been deactivated" });
}
// Remove sensitive information before returning user
const userSafe = {
id: user.id,
uuid: user.uuid,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_verified: user.is_verified,
is_active: user.is_active,
created_at: user.created_at,
last_login: user.last_login,
must_change_password: user.must_change_password,
};
return done(null, userSafe);
} catch (error) {
return done(error);
}
}
)
);
// Serialize user for session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize user from session
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
if (user) {
const userSafe = {
id: user.id,
uuid: user.uuid,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_verified: user.is_verified,
is_active: user.is_active,
created_at: user.created_at,
last_login: user.last_login,
must_change_password: user.must_change_password,
};
done(null, userSafe);
} else {
done(null, false);
}
} catch (error) {
done(error);
}
});
module.exports = passport;

110
src/config/redis.js Normal file
View File

@ -0,0 +1,110 @@
const { createClient } = require("redis");
let redisClient = null;
let connectionAttempted = false;
let isRedisAvailable = false;
const connectRedis = async () => {
if (redisClient) {
return redisClient;
}
// If we already tried and failed, don't keep trying
if (connectionAttempted && !isRedisAvailable) {
return null;
}
connectionAttempted = true;
const redisHost = process.env.REDIS_HOST || "localhost";
const redisPort = process.env.REDIS_PORT || 6379;
const redisPassword = process.env.REDIS_PASSWORD || "";
const config = {
socket: {
host: redisHost,
port: redisPort,
connectTimeout: 1000, // Reduced timeout to 1 second
lazyConnect: true,
},
// Disable automatic reconnection to prevent spam
retry_unfulfilled_commands: false,
enable_offline_queue: false,
};
// Add password if provided
if (redisPassword) {
config.password = redisPassword;
}
redisClient = createClient(config);
// Only log the first error, not subsequent ones
let errorLogged = false;
redisClient.on("error", (err) => {
if (!errorLogged) {
console.warn("Redis connection failed:", err.code || err.message);
console.warn("Falling back to in-memory rate limiting");
errorLogged = true;
}
isRedisAvailable = false;
});
redisClient.on("connect", () => {
console.log("Connected to Redis");
isRedisAvailable = true;
});
redisClient.on("disconnect", () => {
if (isRedisAvailable) {
console.log("Disconnected from Redis");
}
isRedisAvailable = false;
});
try {
await redisClient.connect();
console.log("Redis client connected successfully");
isRedisAvailable = true;
} catch (error) {
console.warn("Failed to connect to Redis:", error.code || error.message);
console.warn("Continuing with in-memory rate limiting");
isRedisAvailable = false;
redisClient = null;
return null;
}
return redisClient;
};
const getRedisClient = () => {
if (!redisClient || !isRedisAvailable) {
throw new Error("Redis client not available");
}
return redisClient;
};
const closeRedis = async () => {
if (redisClient && isRedisAvailable) {
try {
await redisClient.quit();
console.log("Redis connection closed");
} catch (error) {
// Ignore errors during shutdown
}
}
redisClient = null;
isRedisAvailable = false;
connectionAttempted = false;
};
const isRedisConnected = () => {
return isRedisAvailable && redisClient && redisClient.isOpen;
};
module.exports = {
connectRedis,
getRedisClient,
closeRedis,
isRedisConnected,
};

473
src/db.rs
View File

@ -1,473 +0,0 @@
// src/db.rs
use anyhow::{anyhow, Context, Result as AnyhowResult};
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{Duration as ChronoDuration, Utc}; // Use Utc for timestamps
use log; // Use the log crate
use rusqlite::{params, Connection, OptionalExtension};
use std::env;
use uuid::Uuid;
use crate::models;
// Configurable token lifetime (e.g., from environment variable or default)
const TOKEN_LIFETIME_HOURS: i64 = 24; // Default to 24 hours
// Initialize the database connection and create tables if they don't exist
pub fn init_db(database_url: &str) -> AnyhowResult<Connection> {
log::info!("Attempting to open or create database at: {}", database_url);
let conn = Connection::open(database_url)
.context(format!("Failed to open the database at {}", database_url))?;
log::debug!("Creating 'users' table if not exists...");
conn.execute(
"CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, -- Stores bcrypt hashed password
role TEXT NOT NULL DEFAULT 'user', -- 'admin' or 'user'
token TEXT UNIQUE, -- Stores the current session token (UUID)
token_expires_at DATETIME, -- Timestamp when the token expires
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.context("Failed to create 'users' table")?;
log::debug!("Creating 'forms' table if not exists...");
conn.execute(
"CREATE TABLE IF NOT EXISTS forms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fields TEXT NOT NULL, -- Stores JSON definition of form fields
owner_id TEXT NOT NULL, -- Reference to the user who created the form
notify_email TEXT, -- Optional email address for notifications
notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
)",
[],
)
.context("Failed to create 'forms' table")?;
// Add notify_email column if it doesn't exist (for backward compatibility)
match conn.execute("ALTER TABLE forms ADD COLUMN notify_email TEXT", []) {
Ok(_) => log::info!("Added notify_email column to forms table"),
Err(e) => {
if !e.to_string().contains("duplicate column name") {
return Err(anyhow!("Failed to add notify_email column: {}", e));
}
// If it already exists, that's fine
}
}
// Add notify_ntfy_topic column if it doesn't exist (for backward compatibility)
match conn.execute("ALTER TABLE forms ADD COLUMN notify_ntfy_topic TEXT", []) {
Ok(_) => log::info!("Added notify_ntfy_topic column to forms table"),
Err(e) => {
if !e.to_string().contains("duplicate column name") {
return Err(anyhow!("Failed to add notify_ntfy_topic column: {}", e));
}
// If it already exists, that's fine
}
}
log::debug!("Creating 'submissions' table if not exists...");
conn.execute(
"CREATE TABLE IF NOT EXISTS submissions (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL,
data TEXT NOT NULL, -- Stores JSON submission data
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (form_id) REFERENCES forms (id) ON DELETE CASCADE
)",
[],
)
.context("Failed to create 'submissions' table")?;
// Setup the initial admin user if it doesn't exist, using environment variables
setup_initial_admin(&conn).context("Failed to setup initial admin user")?;
log::info!("Database initialization complete.");
Ok(conn)
}
// Sets up the initial admin user from *required* environment variables if it doesn't exist
fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> {
// CRITICAL SECURITY CHANGE: Remove default credentials. Require env vars.
let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME")
.map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_USERNAME environment variable is not set."))?;
let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD")
.map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_PASSWORD environment variable is not set."))?;
if initial_admin_username.is_empty() || initial_admin_password.is_empty() {
return Err(anyhow!(
"FATAL: INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD must not be empty."
));
}
// Check password complexity? (Optional enhancement)
add_user_if_not_exists(
conn,
&initial_admin_username,
&initial_admin_password,
Some("admin"),
)
.context("Failed during initial admin user setup")?;
Ok(())
}
// Adds a user with a hashed password if the username doesn't exist
pub fn add_user_if_not_exists(
conn: &Connection,
username: &str,
password: &str,
role: Option<&str>, // Optional role parameter
) -> AnyhowResult<bool> {
// Check if user already exists
let user_exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = ?1)",
params![username],
|row| row.get::<_, i32>(0),
)
.context(format!("Failed to check existence of user '{}'", username))?
== 1;
if user_exists {
log::debug!("User '{}' already exists, skipping creation.", username);
return Ok(false); // User already exists, nothing added
}
// Generate a UUID for the new user
let user_id = Uuid::new_v4().to_string();
// Hash the password using bcrypt
// Ensure the cost factor is appropriate for your security needs and hardware.
// Higher cost means slower hashing and verification, but better resistance to brute-force.
log::debug!(
"Hashing password for user '{}' with cost {}",
username,
DEFAULT_COST
);
let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?;
// Use provided role or default to "user"
let role = role.unwrap_or("user");
// Insert the new user
log::info!(
"Creating new user '{}' with ID: {} and role: {}",
username,
user_id,
role
);
conn.execute(
"INSERT INTO users (id, username, password, role) VALUES (?1, ?2, ?3, ?4)",
params![user_id, username, hashed_password, role],
)
.context(format!("Failed to insert user '{}'", username))?;
Ok(true) // User was added
}
// Validate a session token and return the associated user ID if valid and not expired
pub fn validate_token(conn: &Connection, token: &str) -> AnyhowResult<Option<String>> {
log::debug!("Validating received token (existence and expiration)...");
let mut stmt = conn.prepare(
// Select user ID only if token matches AND it hasn't expired
"SELECT id FROM users WHERE token = ?1 AND token_expires_at IS NOT NULL AND token_expires_at > ?2"
).context("Failed to prepare query for validating token")?;
let now_ts = Utc::now().to_rfc3339(); // Use ISO 8601 / RFC 3339 format compatible with SQLite DATETIME
let user_id_option: Option<String> = stmt
.query_row(params![token, now_ts], |row| row.get(0))
.optional() // Makes it return Option instead of erroring on no rows
.context("Failed to execute query for validating token")?;
if user_id_option.is_some() {
log::debug!("Token validation successful.");
} else {
// This covers token not found OR token expired
log::debug!("Token validation failed (token not found or expired).");
}
Ok(user_id_option)
}
// Invalidate a user's token (e.g., on logout) by setting it to NULL and clearing expiration
pub fn invalidate_token(conn: &Connection, user_id: &str) -> AnyhowResult<()> {
log::debug!("Invalidating token for user_id {}", user_id);
conn.execute(
"UPDATE users SET token = NULL, token_expires_at = NULL WHERE id = ?1",
params![user_id],
)
.context(format!(
"Failed to invalidate token for user_id {}",
user_id
))?;
Ok(())
}
// Authenticate a user by username and password, returning user ID and hash if successful
pub fn authenticate_user(
conn: &Connection,
username: &str,
password: &str,
) -> AnyhowResult<Option<models::UserAuthData>> {
log::debug!("Attempting to authenticate user: {}", username);
let mut stmt = conn
.prepare("SELECT id, password FROM users WHERE username = ?1")
.context("Failed to prepare query for authenticating user")?;
let result = stmt
.query_row(params![username], |row| {
Ok(models::UserAuthData {
id: row.get(0)?,
hashed_password: row.get(1)?,
})
})
.optional()
.context(format!(
"Failed to execute query to fetch auth data for user '{}'",
username
))?;
match result {
Some(user_data) => {
// Verify the provided password against the stored hash
let is_valid = verify(password, &user_data.hashed_password)
.context("Failed to verify password hash")?;
if is_valid {
log::info!("Authentication successful for user: {}", username);
Ok(Some(user_data)) // Return user ID and hash
} else {
log::warn!(
"Authentication failed for user '{}' (invalid password)",
username
);
Ok(None) // Invalid password
}
}
None => {
log::warn!(
"Authentication failed for user '{}' (user not found)",
username
);
Ok(None) // User not found
}
}
}
// Generate and save a new session token (with expiration) for a user
pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> AnyhowResult<String> {
let new_token = Uuid::new_v4().to_string();
// Calculate expiration time
let expires_at = Utc::now() + ChronoDuration::hours(TOKEN_LIFETIME_HOURS);
let expires_at_ts = expires_at.to_rfc3339(); // Store as string
log::debug!(
"Generating new token for user_id {} expiring at {}",
user_id,
expires_at_ts
);
conn.execute(
"UPDATE users SET token = ?1, token_expires_at = ?2 WHERE id = ?3",
params![new_token, expires_at_ts, user_id],
)
.context(format!("Failed to update token for user_id {}", user_id))?;
Ok(new_token)
}
// Fetch a specific form definition by its ID
pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Option<models::Form>> {
let mut stmt = conn
.prepare("SELECT id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1")
.context("Failed to prepare query for fetching form")?;
let result = stmt
.query_row(params![form_id], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let fields_str: String = row.get(2)?;
let owner_id: String = row.get(3)?;
let notify_email: Option<String> = row.get(4)?;
let notify_ntfy_topic: Option<String> = row.get(5)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(6)?;
// Parse the fields JSON string
let fields = serde_json::from_str(&fields_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
2, // Index of 'fields' column
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
Ok(models::Form {
id: Some(id),
name,
fields,
owner_id,
notify_email,
notify_ntfy_topic,
created_at,
})
})
.optional()
.context(format!("Failed to fetch form with ID: {}", form_id))?;
Ok(result)
}
// Add a function to save a form
impl models::Form {
pub fn save(&self, conn: &Connection) -> AnyhowResult<()> {
let id = self
.id
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string());
let fields_json = serde_json::to_string(&self.fields)?;
conn.execute(
"INSERT INTO forms (id, name, fields, owner_id, notify_email, notify_ntfy_topic, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
fields = excluded.fields,
owner_id = excluded.owner_id,
notify_email = excluded.notify_email,
notify_ntfy_topic = excluded.notify_ntfy_topic",
params![
id,
self.name,
fields_json,
self.owner_id,
self.notify_email,
self.notify_ntfy_topic,
self.created_at
],
)?;
Ok(())
}
pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult<Self> {
get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id))
}
}
// Add a function to save a submission
impl models::Submission {
pub fn save(&self, conn: &Connection) -> AnyhowResult<()> {
let data_json = serde_json::to_string(&self.data)?;
conn.execute(
"INSERT INTO submissions (id, form_id, data, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![self.id, self.form_id, data_json, self.created_at],
)?;
Ok(())
}
}
// Get user by ID
pub fn get_user_by_id(conn: &Connection, user_id: &str) -> AnyhowResult<Option<models::User>> {
let mut stmt =
conn.prepare("SELECT id, username, role, created_at FROM users WHERE id = ?1")?;
let result = stmt
.query_row(params![user_id], |row| {
Ok(models::User {
id: row.get(0)?,
username: row.get(1)?,
password: None, // Never return password
role: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
Ok(result)
}
// Get user by username
pub fn get_user_by_username(
conn: &Connection,
username: &str,
) -> AnyhowResult<Option<models::User>> {
let mut stmt =
conn.prepare("SELECT id, username, role, created_at FROM users WHERE username = ?1")?;
let result = stmt
.query_row(params![username], |row| {
Ok(models::User {
id: row.get(0)?,
username: row.get(1)?,
password: None, // Never return password
role: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
Ok(result)
}
// List all users (for admin use)
pub fn list_users(conn: &Connection) -> AnyhowResult<Vec<models::User>> {
let mut stmt = conn.prepare("SELECT id, username, role, created_at FROM users")?;
let users_iter = stmt.query_map([], |row| {
Ok(models::User {
id: row.get(0)?,
username: row.get(1)?,
password: None, // Never return password
role: row.get(2)?,
created_at: row.get(3)?,
})
})?;
let mut users = Vec::new();
for user_result in users_iter {
users.push(user_result?);
}
Ok(users)
}
// Update user
pub fn update_user(
conn: &Connection,
user_id: &str,
update: &models::UserUpdate,
) -> AnyhowResult<()> {
if let Some(username) = &update.username {
conn.execute(
"UPDATE users SET username = ?1 WHERE id = ?2",
params![username, user_id],
)?;
}
if let Some(password) = &update.password {
let hashed_password = hash(password, DEFAULT_COST)?;
conn.execute(
"UPDATE users SET password = ?1 WHERE id = ?2",
params![hashed_password, user_id],
)?;
}
Ok(())
}
// Delete user
pub fn delete_user(conn: &Connection, user_id: &str) -> AnyhowResult<bool> {
let rows_affected = conn.execute("DELETE FROM users WHERE id = ?1", params![user_id])?;
Ok(rows_affected > 0)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,320 +0,0 @@
// src/main.rs
use actix_cors::Cors;
use actix_files as fs;
use actix_route_rate_limiter::{Limiter, RateLimiter};
use actix_web::{http::header, middleware::Logger, web, App, HttpServer};
use config::{Config, Environment};
use dotenv::dotenv;
use std::env;
use std::io::Result as IoResult;
use std::process;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// Added for throttling map
use std::collections::HashMap;
// Import modules
mod auth;
mod db;
mod handlers;
mod models;
mod notifications;
use notifications::{NotificationConfig, NotificationService};
// --- CAPTCHA Configuration ---
#[derive(Clone, Debug)]
pub struct CaptchaConfig {
pub enabled: bool,
pub secret_key: String,
pub verification_url: String, // e.g., "https://hcaptcha.com/siteverify"
}
impl CaptchaConfig {
// Function to load from environment variables
pub fn from_env() -> Result<Self, std::env::VarError> {
// Return VarError for simplicity
let enabled = std::env::var("CAPTCHA_ENABLED")
.map(|v| v.parse().unwrap_or(false))
.unwrap_or(false); // Default to false if not set or parse error
// Use Ok variant of Result for keys, default to empty if not found
let secret_key = std::env::var("CAPTCHA_SECRET_KEY").unwrap_or_default();
let verification_url = std::env::var("CAPTCHA_VERIFICATION_URL").unwrap_or_default();
// Basic validation: if enabled, secret key and URL must be present
if enabled && (secret_key.is_empty() || verification_url.is_empty()) {
warn!("CAPTCHA_ENABLED is true, but CAPTCHA_SECRET_KEY or CAPTCHA_VERIFICATION_URL is missing. CAPTCHA will be effectively disabled.");
Ok(Self {
enabled: false, // Force disable if config is incomplete
secret_key,
verification_url,
})
} else {
Ok(Self {
enabled,
secret_key,
verification_url,
})
}
}
}
// --- End CAPTCHA Configuration ---
// Application state that will be shared across all routes
pub struct AppState {
db: Arc<Mutex<rusqlite::Connection>>,
notification_service: Arc<NotificationService>,
captcha_config: CaptchaConfig,
// Map form_id to the Instant of the last notification attempt for that form
last_notification_times: Arc<Mutex<HashMap<String, Instant>>>,
// Map form_id -> ip_address -> (last_attempt_time, count) for rate limiting
form_submission_attempts: Arc<Mutex<HashMap<String, HashMap<String, (Instant, u32)>>>>,
}
#[actix_web::main]
async fn main() -> IoResult<()> {
// Load environment variables from .env file
dotenv().ok();
// Initialize Sentry for error tracking
let _guard = sentry::init((
env::var("SENTRY_DSN").unwrap_or_default(),
sentry::ClientOptions {
release: sentry::release_name!(),
..Default::default()
},
));
// Initialize structured logging
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
// Load configuration
let settings = Config::builder()
.add_source(Environment::default())
.build()
.unwrap_or_else(|e| {
error!("Failed to load configuration: {}", e);
process::exit(1);
});
// --- Configuration (Environment Variables) ---
let database_url = settings.get_string("DATABASE_URL").unwrap_or_else(|_| {
warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'.");
"form_data.db".to_string()
});
let bind_address = settings.get_string("BIND_ADDRESS").unwrap_or_else(|_| {
warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'.");
"127.0.0.1:8080".to_string()
});
// Read allowed origins as a comma-separated string, defaulting to empty
let allowed_origins_str = env::var("ALLOWED_ORIGIN").unwrap_or_else(|_| {
warn!("ALLOWED_ORIGIN environment variable not set. CORS will be restrictive.");
String::new() // Default to empty string if not set
});
// Split the string into a vector of origins
let allowed_origins_list: Vec<String> = if allowed_origins_str.is_empty() {
Vec::new() // Return an empty vector if the string is empty
} else {
allowed_origins_str
.split(',')
.map(|s| s.trim().to_string()) // Trim whitespace and convert to String
.filter(|s| !s.is_empty()) // Remove empty strings resulting from extra commas
.collect()
};
info!(" --- Formies Backend Configuration ---");
info!("Required Environment Variables:");
info!(" - DATABASE_URL (Current: {})", database_url);
info!(" - BIND_ADDRESS (Current: {})", bind_address);
info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)");
info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)");
info!("Optional Environment Variables:");
if !allowed_origins_list.is_empty() {
info!(
" - ALLOWED_ORIGIN (Set: {})",
allowed_origins_list.join(", ") // Log the list nicely
);
} else {
warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive");
}
info!(" - RUST_LOG (e.g., 'info,formies_be=debug')");
info!(" --- End Configuration ---");
// Initialize database connection
let db_connection = match db::init_db(&database_url) {
Ok(conn) => conn,
Err(e) => {
if e.to_string().contains("INITIAL_ADMIN_USERNAME")
|| e.to_string().contains("INITIAL_ADMIN_PASSWORD")
{
error!("FATAL: {}", e);
error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables.");
} else {
error!(
"FATAL: Failed to initialize database at {}: {:?}",
database_url, e
);
}
process::exit(1);
}
};
// Initialize rate limiter using the correct fields
let limiter = Limiter {
ip_addresses: std::collections::HashMap::new(), // Stores IP request counts
duration: chrono::TimeDelta::from_std(Duration::from_secs(60)).expect("Invalid duration"), // Convert std::time::Duration
num_requests: 100, // Max requests allowed in the duration
};
// Create the cloneable Arc<Mutex<Limiter>> outside the closure
let limiter_data = Arc::new(Mutex::new(limiter));
// Initialize notification service
let notification_config = NotificationConfig::from_env().unwrap_or_else(|e| {
warn!(
"Failed to load notification configuration: {}. Notifications will not be available.",
e
);
NotificationConfig::default()
});
let notification_service = Arc::new(NotificationService::new(notification_config));
// Load CAPTCHA Configuration
let captcha_config = CaptchaConfig::from_env().unwrap_or_else(|e| {
warn!(
"Failed to load CAPTCHA configuration: {}. CAPTCHA will be disabled.",
e
);
// Ensure default is truly disabled
CaptchaConfig {
enabled: false,
secret_key: String::new(),
verification_url: String::new(),
}
});
if captcha_config.enabled {
info!("CAPTCHA verification is ENABLED.");
} else {
info!("CAPTCHA verification is DISABLED (or required env vars missing).");
}
// Create AppState with all services
let app_state = web::Data::new(AppState {
db: Arc::new(Mutex::new(db_connection)),
notification_service: notification_service.clone(),
captcha_config: captcha_config.clone(),
last_notification_times: Arc::new(Mutex::new(HashMap::new())),
form_submission_attempts: Arc::new(Mutex::new(HashMap::new())), // Initialize rate limit map
});
info!("Starting server at http://{}", bind_address);
HttpServer::new(move || {
let app_state = app_state.clone(); // This now includes captcha_config
let allowed_origins = allowed_origins_list.clone();
let rate_limiter = RateLimiter::new(limiter_data.clone());
// Configure CORS
let cors = if !allowed_origins.is_empty() {
info!("Configuring CORS for origins: {:?}", allowed_origins);
let mut cors = Cors::default();
for origin in allowed_origins {
cors = cors.allowed_origin(&origin); // Add each origin
}
cors.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
.allowed_headers(vec![
header::AUTHORIZATION,
header::ACCEPT,
header::CONTENT_TYPE,
header::ORIGIN,
header::ACCESS_CONTROL_REQUEST_METHOD,
header::ACCESS_CONTROL_REQUEST_HEADERS,
])
.supports_credentials()
.max_age(3600)
} else {
warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set.");
Cors::default() // Keep restrictive default if no origins are provided
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
.allowed_headers(vec![
header::AUTHORIZATION,
header::ACCEPT,
header::CONTENT_TYPE,
header::ORIGIN,
header::ACCESS_CONTROL_REQUEST_METHOD,
header::ACCESS_CONTROL_REQUEST_HEADERS,
])
.supports_credentials()
.max_age(3600)
};
// Configure JSON payload limits (e.g., 1MB)
let json_config = web::JsonConfig::default().limit(1024 * 1024);
App::new()
.wrap(cors)
.wrap(Logger::default())
.wrap(tracing_actix_web::TracingLogger::default())
.wrap(rate_limiter)
.app_data(app_state) // Share app state (db, notifications, captcha)
.app_data(json_config) // Add JSON payload configuration
.service(
web::scope("/api")
// Health check endpoint
.route("/health", web::get().to(handlers::health_check))
// Public routes
.route("/login", web::post().to(handlers::login))
.route("/register", web::post().to(handlers::register))
.route(
"/forms/{form_id}/submissions",
web::post().to(handlers::submit_form),
)
// Protected routes
.route("/logout", web::post().to(handlers::logout))
.route("/forms", web::post().to(handlers::create_form))
.route("/forms", web::get().to(handlers::get_forms))
.route(
"/forms/{form_id}/submissions",
web::get().to(handlers::get_submissions),
)
.route(
"/forms/{form_id}/notifications",
web::get().to(handlers::get_notification_settings),
)
.route(
"/forms/{form_id}/notifications",
web::put().to(handlers::update_notification_settings),
)
// User management routes
.route("/users", web::get().to(handlers::list_users))
.route("/users/{user_id}", web::get().to(handlers::get_user))
.route("/users/{user_id}", web::put().to(handlers::update_user))
.route("/users/{user_id}", web::delete().to(handlers::delete_user)),
)
.service(
fs::Files::new("/", "./frontend/")
.index_file("index.html")
.use_last_modified(true)
.default_handler(fs::NamedFile::open("./frontend/index.html").unwrap_or_else(
|_| {
error!("Fallback file not found: ../frontend/index.html");
process::exit(1);
},
)),
)
})
.bind(&bind_address)?
.run()
.await
}

View File

@ -0,0 +1,101 @@
const pool = require("../config/database");
const { compareApiKeySecret } = require("../utils/apiKeyHelper");
async function apiAuthMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res
.status(401)
.json({
error:
"Unauthorized: Missing or malformed API key. Expected Bearer token.",
});
}
const fullApiKey = authHeader.substring(7); // Remove "Bearer "
const parts = fullApiKey.split("_");
// Expects key format: prefix_identifierRandomPart_secretPart
// So, identifier is parts[0] + '_' + parts[1]
// And secret is parts[2]
if (parts.length < 3) {
// Basic check for fsk_random_secret format
return res
.status(401)
.json({ error: "Unauthorized: Invalid API key format." });
}
// Reconstruct identifier: e.g., parts[0] = 'fsk', parts[1] = 'randompart' -> 'fsk_randompart'
const apiKeyIdentifier = `${parts[0]}_${parts[1]}`;
const providedSecret = parts.slice(2).join("_"); // secret part could contain underscores if generated differently, though unlikely with current helper
if (!apiKeyIdentifier || !providedSecret) {
return res
.status(401)
.json({ error: "Unauthorized: Invalid API key structure." });
}
try {
const [apiKeyRecords] = await pool.query(
"SELECT ak.id, ak.user_id, ak.hashed_api_key_secret, ak.expires_at, u.is_active as user_is_active, u.role as user_role FROM api_keys ak JOIN users u ON ak.user_id = u.id WHERE ak.api_key_identifier = ?",
[apiKeyIdentifier]
);
if (apiKeyRecords.length === 0) {
return res.status(401).json({ error: "Unauthorized: Invalid API key." });
}
const apiKeyRecord = apiKeyRecords[0];
if (!apiKeyRecord.user_is_active) {
return res
.status(403)
.json({ error: "Forbidden: User account is inactive." });
}
// Check for expiration (if implemented and expires_at is not null)
if (
apiKeyRecord.expires_at &&
new Date(apiKeyRecord.expires_at) < new Date()
) {
return res.status(403).json({ error: "Forbidden: API key has expired." });
}
const isValid = await compareApiKeySecret(
providedSecret,
apiKeyRecord.hashed_api_key_secret
);
if (!isValid) {
return res.status(401).json({ error: "Unauthorized: Invalid API key." });
}
// Attach user information and API key ID to request for use in controllers/routes
req.user = {
id: apiKeyRecord.user_id,
role: apiKeyRecord.user_role, // Add other relevant user fields if needed
// Potentially add more fields from the user table if fetched in the JOIN
};
req.apiKeyId = apiKeyRecord.id;
// Update last_used_at (fire and forget, no need to await or block)
pool
.query(
"UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
[apiKeyRecord.id]
)
.catch((err) =>
console.error("Failed to update API key last_used_at:", err)
);
next();
} catch (error) {
console.error("API Authentication error:", error);
return res
.status(500)
.json({ error: "Internal Server Error during API authentication." });
}
}
module.exports = apiAuthMiddleware;

View File

@ -0,0 +1,263 @@
const passport = require("../config/passport");
const jwtService = require("../services/jwtService");
const rateLimit = require("express-rate-limit");
// JWT Authentication middleware
const authenticateJWT = (req, res, next) => {
passport.authenticate("jwt", { session: false }, (err, user, info) => {
if (err) {
return res.status(500).json({
success: false,
message: "Authentication error",
error: err.message,
});
}
if (!user) {
return res.status(401).json({
success: false,
message: info?.message || "Authentication required",
});
}
req.user = user;
next();
})(req, res, next);
};
// Optional JWT Authentication (doesn't fail if no token)
const authenticateJWTOptional = (req, res, next) => {
const authHeader = req.headers.authorization;
const token = jwtService.extractTokenFromHeader(authHeader);
if (!token) {
return next(); // No token provided, continue without user
}
passport.authenticate("jwt", { session: false }, (err, user, info) => {
if (!err && user) {
req.user = user;
}
// Continue regardless of authentication result
next();
})(req, res, next);
};
// Role-based authorization middleware
const requireRole = (roles) => {
if (typeof roles === "string") {
roles = [roles];
}
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: "Insufficient permissions",
});
}
next();
};
};
// Check if user is admin or super admin
const requireAdmin = requireRole(["admin", "super_admin"]);
// Check if user is super admin
const requireSuperAdmin = requireRole(["super_admin"]);
// Check if user owns the resource or is admin
const requireOwnershipOrAdmin = (getResourceUserId) => {
return async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
// Super admins can access everything
if (req.user.role === "super_admin") {
return next();
}
// Get the user ID that owns the resource
const resourceUserId = await getResourceUserId(req);
// Check if user owns the resource or is admin
if (
req.user.id === resourceUserId ||
["admin", "super_admin"].includes(req.user.role)
) {
return next();
}
return res.status(403).json({
success: false,
message: "Access denied. You can only access your own resources.",
});
} catch (error) {
return res.status(500).json({
success: false,
message: "Authorization error",
error: error.message,
});
}
};
};
// Check if account is verified
const requireVerifiedAccount = (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
// Super admins don't need verification
if (req.user.role === "super_admin") {
return next();
}
if (!req.user.is_verified) {
return res.status(403).json({
success: false,
message: "Please verify your email address to access this resource",
requiresVerification: true,
});
}
next();
};
// Rate limiting middleware for authentication endpoints
const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: {
success: false,
message: "Too many authentication attempts, please try again later",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Use IP and email if available for more granular rate limiting
return req.ip + (req.body?.email || "");
},
});
// Rate limiting for password reset
const passwordResetRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // Limit each IP to 3 password reset attempts per hour
message: {
success: false,
message: "Too many password reset attempts, please try again later",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
return req.ip + (req.body?.email || "");
},
});
// Rate limiting for registration
const registrationRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // Limit each IP to 3 registrations per hour
message: {
success: false,
message: "Too many registration attempts, please try again later",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
return req.ip;
},
});
// Middleware to check if user is active
const requireActiveAccount = (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
if (!req.user.is_active) {
return res.status(403).json({
success: false,
message: "Your account has been deactivated. Please contact support.",
});
}
next();
};
// Combine common authentication checks
const requireAuth = [authenticateJWT, requireActiveAccount];
const requireVerifiedAuth = [
authenticateJWT,
requireActiveAccount,
requireVerifiedAccount,
];
// Legacy basic auth middleware (for backward compatibility during transition)
const basicAuth = require("basic-auth");
const httpAuthMiddleware = (req, res, next) => {
if (!process.env.ADMIN_USER || !process.env.ADMIN_PASSWORD) {
console.warn(
"ADMIN_USER or ADMIN_PASSWORD not set. Admin routes are unprotected."
);
return next();
}
const user = basicAuth(req);
if (
!user ||
user.name !== process.env.ADMIN_USER ||
user.pass !== process.env.ADMIN_PASSWORD
) {
res.set("WWW-Authenticate", 'Basic realm="Admin Area"');
return res.status(401).send("Authentication required.");
}
return next();
};
module.exports = {
// JWT Authentication
authenticateJWT,
authenticateJWTOptional,
// Authorization
requireRole,
requireAdmin,
requireSuperAdmin,
requireOwnershipOrAdmin,
requireVerifiedAccount,
requireActiveAccount,
// Combined middleware
requireAuth,
requireVerifiedAuth,
// Rate limiting
authRateLimit,
passwordResetRateLimit,
registrationRateLimit,
// Legacy (for backward compatibility)
httpAuthMiddleware,
};

View File

@ -0,0 +1,48 @@
const domainChecker = async (req, res, next) => {
const formUuid = req.params.formUuid;
const referer = req.headers.referer || req.headers.origin;
try {
const [rows] = await req.db.query(
"SELECT allowed_domains FROM forms WHERE uuid = ?",
[formUuid]
);
if (rows.length === 0) {
return res.status(404).json({ error: "Form not found" });
}
const form = rows[0];
// If no domains are specified or it's empty/null, allow all
if (!form.allowed_domains || form.allowed_domains.trim() === "") {
return next();
}
const allowedDomains = form.allowed_domains.split(",").map((d) => d.trim());
if (!referer) {
return res.status(403).json({ error: "Referer header is required" });
}
const refererUrl = new URL(referer);
const isAllowed = allowedDomains.some(
(domain) =>
refererUrl.hostname === domain ||
refererUrl.hostname.endsWith("." + domain)
);
if (!isAllowed) {
return res
.status(403)
.json({ error: "Submission not allowed from this domain" });
}
next();
} catch (error) {
console.error("Domain check error:", error);
res.status(500).json({ error: "Internal server error" });
}
};
module.exports = domainChecker;

View File

@ -0,0 +1,146 @@
const rateLimit = require("express-rate-limit");
const RedisStore = require("rate-limit-redis").default;
const { getRedisClient, isRedisConnected } = require("../config/redis");
// Track if we've already logged the fallback warning
let fallbackWarningLogged = false;
// Simple in-memory store as fallback when Redis is not available
class MemoryStore {
constructor() {
this.hits = new Map();
this.resetTime = new Map();
// Clean up old entries periodically to prevent memory leaks
this.cleanupInterval = setInterval(
() => {
const now = Date.now();
for (const [key, resetTime] of this.resetTime.entries()) {
if (now > resetTime) {
this.hits.delete(key);
this.resetTime.delete(key);
}
}
},
5 * 60 * 1000
); // Clean up every 5 minutes
}
async increment(key, windowMs) {
const now = Date.now();
const resetTime = this.resetTime.get(key);
if (!resetTime || now > resetTime) {
this.hits.set(key, 1);
this.resetTime.set(key, now + windowMs);
return { totalHits: 1, timeToExpire: windowMs };
}
const hits = (this.hits.get(key) || 0) + 1;
this.hits.set(key, hits);
return { totalHits: hits, timeToExpire: resetTime - now };
}
async decrement(key) {
const hits = this.hits.get(key) || 0;
if (hits > 0) {
this.hits.set(key, hits - 1);
}
}
async resetKey(key) {
this.hits.delete(key);
this.resetTime.delete(key);
}
}
// Create store based on Redis availability
const createStore = () => {
try {
if (isRedisConnected()) {
const redisClient = getRedisClient();
return new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
});
} else {
throw new Error("Redis not connected");
}
} catch (error) {
// Only log the warning once to avoid spam
if (!fallbackWarningLogged) {
console.warn("Rate limiting: Using in-memory store (Redis unavailable)");
fallbackWarningLogged = true;
}
return new MemoryStore();
}
};
// Create rate limiter for form submissions
const createSubmissionRateLimiter = () => {
return rateLimit({
store: createStore(),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 requests per windowMs for any form
message: {
error:
"Too many form submissions from this IP address. Please try again later.",
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
keyGenerator: (req) => {
// Generate unique key per IP
return `submit_ip:${req.ip}`;
},
skip: (req) => {
// Skip rate limiting for specific conditions if needed
return false;
},
});
};
// Create more restrictive rate limiter for specific form+IP combinations
const createFormSpecificRateLimiter = () => {
return rateLimit({
store: createStore(),
windowMs: 5 * 60 * 1000, // 5 minutes
max: 3, // Limit each IP to 3 requests per 5 minutes per specific form
message: {
error:
"Too many submissions for this form from your IP address. Please try again later.",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Generate unique key per form+IP combination
const formUuid = req.params.formUuid;
return `submit_form:${formUuid}:${req.ip}`;
},
skip: (req) => {
// Skip rate limiting for specific conditions if needed
return false;
},
});
};
// Create a more aggressive rate limiter for potential abuse
const createStrictRateLimiter = () => {
return rateLimit({
store: createStore(),
windowMs: 60 * 60 * 1000, // 1 hour
max: 50, // Limit each IP to 50 requests per hour across all forms
message: {
error: "Too many requests from this IP address. Please try again later.",
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
return `strict_ip:${req.ip}`;
},
});
};
module.exports = {
createSubmissionRateLimiter,
createFormSpecificRateLimiter,
createStrictRateLimiter,
};

View File

@ -0,0 +1,115 @@
const { body, param, query, validationResult } = require("express-validator");
// Validation error handler
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "Validation failed",
errors: errors.array().map((error) => ({
field: error.path,
message: error.msg,
value: error.value,
})),
});
}
next();
};
// Password validation
const passwordValidation = body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters long")
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
.withMessage(
"Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"
);
// Email validation
const emailValidation = body("email")
.isEmail()
.withMessage("Please provide a valid email address")
.normalizeEmail()
.isLength({ max: 255 })
.withMessage("Email address is too long");
// Registration validation
const validateRegistration = [
emailValidation,
passwordValidation,
body("first_name")
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage("First name must be between 1 and 100 characters"),
body("last_name")
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage("Last name must be between 1 and 100 characters"),
handleValidationErrors,
];
// Login validation
const validateLogin = [
body("email")
.isEmail()
.withMessage("Please provide a valid email address")
.normalizeEmail(),
body("password").notEmpty().withMessage("Password is required"),
handleValidationErrors,
];
// Forgot password validation
const validateForgotPassword = [emailValidation, handleValidationErrors];
// Reset password validation
const validateResetPassword = [
body("token")
.notEmpty()
.withMessage("Reset token is required")
.isLength({ min: 64, max: 64 })
.withMessage("Invalid reset token format"),
passwordValidation,
body("confirmPassword").custom((value, { req }) => {
if (value !== req.body.password) {
throw new Error("Password confirmation does not match password");
}
return true;
}),
handleValidationErrors,
];
// Profile update validation
const validateProfileUpdate = [
body("first_name")
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage("First name must be between 1 and 100 characters"),
body("last_name")
.optional()
.trim()
.isLength({ min: 1, max: 100 })
.withMessage("Last name must be between 1 and 100 characters"),
body("email")
.optional()
.isEmail()
.withMessage("Please provide a valid email address")
.normalizeEmail()
.isLength({ max: 255 })
.withMessage("Email address is too long"),
handleValidationErrors,
];
module.exports = {
validateRegistration,
validateLogin,
validateForgotPassword,
validateResetPassword,
validateProfileUpdate,
handleValidationErrors,
passwordValidation,
emailValidation,
};

View File

@ -1,101 +0,0 @@
// src/models.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// Consider adding chrono for DateTime types if needed in responses
// use chrono::{DateTime, Utc};
// Represents the structure for defining a form
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Form {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub name: String,
/// Stores the structure defining the form fields.
/// Expected to be a JSON array of field definition objects.
/// Example field definition object:
/// ```json
/// {
/// "name": "email", // String, required: Unique identifier for the field
/// "type": "email", // String, required: "string", "number", "boolean", "email", "url", "object", "array"
/// "label": "Email Address", // String, optional: User-friendly label
/// "required": true, // Boolean, optional (default: false): If the field must have a value
/// "placeholder": "you@example.com", // String, optional: Placeholder text
/// "minLength": 5, // Number, optional: Minimum length for strings
/// "maxLength": 100, // Number, optional: Maximum length for strings
/// "min": 0, // Number, optional: Minimum value for numbers
/// "max": 100, // Number, optional: Maximum value for numbers
/// "pattern": "^\\S+@\\S+\\.\\S+$" // String, optional: Regex pattern for strings (e.g., email, url types might use this implicitly or explicitly)
/// // Add other properties like "options" for select/radio, etc.
/// }
/// ```
pub fields: serde_json::Value,
pub owner_id: String,
pub notify_email: Option<String>,
pub notify_ntfy_topic: Option<String>,
pub created_at: DateTime<Utc>,
}
// Represents a single submission for a specific form
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Submission {
pub id: String,
pub form_id: String,
/// Stores the data submitted by the user.
/// Expected to be a JSON object where keys are field names (`name`) from the form definition's `fields` array.
/// Example: `{ "email": "user@example.com", "age": 30 }`
pub data: serde_json::Value,
pub created_at: DateTime<Utc>,
}
// Used for the /login endpoint request body
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginCredentials {
pub username: String,
pub password: String,
}
// Used for the /login endpoint response body
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginResponse {
pub token: String, // The session token (UUID)
}
// Used internally to represent a user fetched from the DB for authentication check
// Not serialized, only used within db.rs and handlers.rs
#[derive(Debug)]
pub struct UserAuthData {
pub id: String,
pub hashed_password: String,
// Note: Token and expiry are handled separately and not needed in this specific struct
}
// Used for the GET/PUT /forms/{form_id}/notifications endpoints
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NotificationSettingsPayload {
pub notify_email: Option<String>,
pub notify_ntfy_topic: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub username: String,
#[serde(skip_serializing)] // Never send password in responses
pub password: Option<String>,
pub role: String,
pub created_at: DateTime<Utc>,
}
// Used for user registration
#[derive(Debug, Serialize, Deserialize)]
pub struct UserRegistration {
pub username: String,
pub password: String,
}
// Used for user profile updates
#[derive(Debug, Serialize, Deserialize)]
pub struct UserUpdate {
pub username: Option<String>,
pub password: Option<String>,
}

400
src/models/User.js Normal file
View File

@ -0,0 +1,400 @@
const bcrypt = require("bcryptjs");
const crypto = require("crypto");
const { v4: uuidv4 } = require("uuid");
const db = require("../config/database"); // db is now an instance of sqlite3.Database
class User {
// Helper to run queries with promises
static _run(query, params = []) {
return new Promise((resolve, reject) => {
db.run(query, params, function (err) {
if (err) {
reject(err);
} else {
resolve(this); // { lastID, changes }
}
});
});
}
static _get(query, params = []) {
return new Promise((resolve, reject) => {
db.get(query, params, (err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
}
static _all(query, params = []) {
return new Promise((resolve, reject) => {
db.all(query, params, (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
// Create a new user
static async create(userData) {
const {
email,
password,
first_name,
last_name,
role = "user",
is_verified = 0, // SQLite uses 0 for false
} = userData;
const saltRounds = 12;
const password_hash = await bcrypt.hash(password, saltRounds);
const verification_token = crypto.randomBytes(32).toString("hex");
const uuid = uuidv4();
const query = `
INSERT INTO users (uuid, email, password_hash, first_name, last_name, role, is_verified, verification_token, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`;
const values = [
uuid,
email,
password_hash,
first_name,
last_name,
role,
is_verified,
verification_token,
];
try {
const result = await User._run(query, values);
return {
id: result.lastID,
uuid,
email,
first_name,
last_name,
role,
is_verified,
verification_token,
};
} catch (error) {
if (error.message && error.message.includes("UNIQUE constraint failed")) {
// Check for specific constraint if possible, e.g., error.message.includes("users.email")
throw new Error("Email already exists");
}
throw error;
}
}
// Find user by email
static async findByEmail(email) {
const query = "SELECT * FROM users WHERE email = ? AND is_active = 1";
return User._get(query, [email]);
}
// Find user by ID
static async findById(id) {
const query = "SELECT * FROM users WHERE id = ? AND is_active = 1";
return User._get(query, [id]);
}
// Find user by UUID
static async findByUuid(uuid) {
const query = "SELECT * FROM users WHERE uuid = ? AND is_active = 1";
return User._get(query, [uuid]);
}
// Find user by verification token
static async findByVerificationToken(token) {
const query = "SELECT * FROM users WHERE verification_token = ?";
return User._get(query, [token]);
}
// Find user by password reset token
static async findByPasswordResetToken(token) {
const query = `
SELECT * FROM users
WHERE password_reset_token = ?
AND password_reset_expires > datetime('now')
AND is_active = 1
`;
return User._get(query, [token]);
}
// Verify email
static async verifyEmail(token) {
const query = `
UPDATE users
SET is_verified = 1, verification_token = NULL, updated_at = datetime('now')
WHERE verification_token = ?
`;
const result = await User._run(query, [token]);
return result.changes > 0;
}
// Update password
static async updatePassword(id, newPassword) {
const saltRounds = 12;
const password_hash = await bcrypt.hash(newPassword, saltRounds);
const query = `
UPDATE users
SET password_hash = ?, password_reset_token = NULL, password_reset_expires = NULL, updated_at = datetime('now')
WHERE id = ?
`;
const result = await User._run(query, [password_hash, id]);
return result.changes > 0;
}
// Update password and clear must_change_password flag
static async updatePasswordAndClearChangeFlag(id, newPassword) {
const saltRounds = 12;
const password_hash = await bcrypt.hash(newPassword, saltRounds);
const query = `
UPDATE users
SET password_hash = ?,
must_change_password = 0,
password_reset_token = NULL,
password_reset_expires = NULL,
updated_at = datetime('now')
WHERE id = ?
`;
const result = await User._run(query, [password_hash, id]);
return result.changes > 0;
}
// Set password reset token
static async setPasswordResetToken(email) {
const token = crypto.randomBytes(32).toString("hex");
// SQLite expects DATETIME strings, ISO 8601 format is good
const expires = new Date(Date.now() + 3600000).toISOString();
const query = `
UPDATE users
SET password_reset_token = ?, password_reset_expires = ?, updated_at = datetime('now')
WHERE email = ? AND is_active = 1
`;
const result = await User._run(query, [token, expires, email]);
if (result.changes > 0) {
return { token, expires };
}
return null;
}
// Increment failed login attempts
static async incrementFailedLoginAttempts(id) {
// Note: SQLite's CASE WHEN THEN ELSE END syntax is similar to MySQL
// Locking for 30 minutes
const query = `
UPDATE users
SET failed_login_attempts = failed_login_attempts + 1,
account_locked_until = CASE
WHEN failed_login_attempts >= 4 THEN datetime('now', '+30 minutes')
ELSE account_locked_until
END,
updated_at = datetime('now')
WHERE id = ?
`;
await User._run(query, [id]);
}
// Reset failed login attempts
static async resetFailedLoginAttempts(id) {
const query = `
UPDATE users
SET failed_login_attempts = 0, account_locked_until = NULL, updated_at = datetime('now')
WHERE id = ?
`;
await User._run(query, [id]);
}
// Update last login
static async updateLastLogin(id) {
const query =
"UPDATE users SET last_login = datetime('now'), updated_at = datetime('now') WHERE id = ?";
await User._run(query, [id]);
}
// Deactivate user account
static async deactivateUser(id) {
const query =
"UPDATE users SET is_active = 0, updated_at = datetime('now') WHERE id = ?";
const result = await User._run(query, [id]);
return result.changes > 0;
}
// Activate user account
static async activateUser(id) {
const query =
"UPDATE users SET is_active = 1, updated_at = datetime('now') WHERE id = ?";
const result = await User._run(query, [id]);
return result.changes > 0;
}
// Update user profile
static async updateProfile(id, updates) {
const allowedFields = ["first_name", "last_name", "email"];
const fieldsToUpdate = [];
const values = [];
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key) && value !== undefined) {
fieldsToUpdate.push(`\`${key}\` = ?`); // Use backticks for field names just in case
values.push(value);
}
}
if (fieldsToUpdate.length === 0) {
throw new Error("No valid fields to update");
}
values.push(id); // for the WHERE clause
const query = `UPDATE users SET ${fieldsToUpdate.join(
", "
)}, updated_at = datetime('now') WHERE id = ?`;
try {
const result = await User._run(query, values);
return result.changes > 0;
} catch (error) {
if (error.message && error.message.includes("UNIQUE constraint failed")) {
// Check for specific constraint if possible, e.g., error.message.includes("users.email")
throw new Error("Email already exists");
}
throw error;
}
}
// Session management for JWT tokens
static async saveSession(
userId,
tokenJti,
expiresAt, // Should be an ISO string or Unix timestamp
userAgent = null,
ipAddress = null
) {
const query = `
INSERT INTO user_sessions (user_id, token_jti, expires_at, user_agent, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
`;
// Ensure expiresAt is in a format SQLite understands (e.g., ISO string)
const expiresAtFormatted = new Date(expiresAt).toISOString();
const values = [userId, tokenJti, expiresAtFormatted, userAgent, ipAddress];
const result = await User._run(query, values);
return result.lastID;
}
static async isTokenBlacklisted(tokenJti) {
const query =
"SELECT 1 FROM user_sessions WHERE token_jti = ? AND expires_at > datetime('now')";
const row = await User._get(query, [tokenJti]);
return !!row; // True if a non-expired session with this JTI exists
}
static async revokeSession(tokenJti) {
// Instead of deleting, we can mark as expired or delete. Deleting is simpler.
const query = "DELETE FROM user_sessions WHERE token_jti = ?";
const result = await User._run(query, [tokenJti]);
return result.changes > 0;
}
static async revokeAllUserSessions(userId) {
const query = "DELETE FROM user_sessions WHERE user_id = ?";
const result = await User._run(query, [userId]);
return result.changes > 0;
}
static async revokeAllUserSessionsExcept(userId, exceptJti) {
const query =
"DELETE FROM user_sessions WHERE user_id = ? AND token_jti != ?";
const result = await User._run(query, [userId, exceptJti]);
return result.changes > 0;
}
static async getUserActiveSessions(userId) {
const query =
"SELECT id, token_jti, user_agent, ip_address, created_at, expires_at FROM user_sessions WHERE user_id = ? AND expires_at > datetime('now') ORDER BY created_at DESC";
return User._all(query, [userId]);
}
static async getSessionByJti(jti) {
const query = "SELECT * FROM user_sessions WHERE token_jti = ?";
return User._get(query, [jti]);
}
// Cleanup expired sessions (can be run periodically)
static async cleanupExpiredSessions() {
const query =
"DELETE FROM user_sessions WHERE expires_at <= datetime('now')";
const result = await User._run(query);
console.log("Cleaned up " + result.changes + " expired sessions.");
return result.changes;
}
// Get user statistics (example, adapt as needed)
static async getUserStats(userId) {
// This is a placeholder. You'll need to adjust based on actual needs and tables.
// For example, count forms or submissions associated with the user.
// const formsQuery = "SELECT COUNT(*) as form_count FROM forms WHERE user_id = ?";
// const submissionsQuery = "SELECT COUNT(*) as submission_count FROM submissions WHERE user_id = ?";
// const [formsResult] = await User._all(formsQuery, [userId]);
// const [submissionsResult] = await User._all(submissionsQuery, [userId]);
return {
// form_count: formsResult ? formsResult.form_count : 0,
// submission_count: submissionsResult ? submissionsResult.submission_count : 0,
// Add other relevant stats
};
}
// Find all users with pagination and filtering (example)
static async findAll(page = 1, limit = 20, filters = {}) {
let query =
"SELECT id, uuid, email, first_name, last_name, role, is_verified, is_active, created_at, last_login FROM users";
const queryParams = [];
const whereClauses = [];
if (filters.role) {
whereClauses.push("role = ?");
queryParams.push(filters.role);
}
if (filters.is_active !== undefined) {
whereClauses.push("is_active = ?");
queryParams.push(filters.is_active ? 1 : 0);
}
// Add more filters as needed
if (whereClauses.length > 0) {
query += " WHERE " + whereClauses.join(" AND ");
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?";
queryParams.push(limit, (page - 1) * limit);
const users = await User._all(query, queryParams);
// For total count, need a separate query without limit/offset
let countQuery = "SELECT COUNT(*) as total FROM users";
if (whereClauses.length > 0) {
// Reuse queryParams for filters, but not for limit/offset
const filterParams = queryParams.slice(0, whereClauses.length);
countQuery += " WHERE " + whereClauses.join(" AND ");
const countResult = await User._get(countQuery, filterParams);
return { users, total: countResult.total, page, limit };
} else {
const countResult = await User._get(countQuery);
return { users, total: countResult.total, page, limit };
}
}
// Add other user methods as needed
}
module.exports = User;

View File

@ -1,148 +0,0 @@
use anyhow::Result;
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use serde::Serialize;
use std::env;
#[derive(Debug, Serialize)]
pub struct NotificationConfig {
smtp_host: String,
smtp_port: u16,
smtp_username: String,
smtp_password: String,
from_email: String,
ntfy_topic: String,
ntfy_server: String,
}
impl Default for NotificationConfig {
fn default() -> Self {
Self {
smtp_host: String::new(),
smtp_port: 587,
smtp_username: String::new(),
smtp_password: String::new(),
from_email: String::new(),
ntfy_topic: String::new(),
ntfy_server: "https://ntfy.sh".to_string(),
}
}
}
impl NotificationConfig {
pub fn from_env() -> Result<Self> {
Ok(Self {
smtp_host: env::var("SMTP_HOST")?,
smtp_port: env::var("SMTP_PORT")?.parse()?,
smtp_username: env::var("SMTP_USERNAME")?,
smtp_password: env::var("SMTP_PASSWORD")?,
from_email: env::var("FROM_EMAIL")?,
ntfy_topic: env::var("NTFY_TOPIC")?,
ntfy_server: env::var("NTFY_SERVER").unwrap_or_else(|_| "https://ntfy.sh".to_string()),
})
}
pub fn is_email_configured(&self) -> bool {
!self.smtp_host.is_empty()
&& !self.smtp_username.is_empty()
&& !self.smtp_password.is_empty()
&& !self.from_email.is_empty()
}
pub fn is_ntfy_configured(&self) -> bool {
!self.ntfy_topic.is_empty()
}
}
pub struct NotificationService {
config: NotificationConfig,
}
impl NotificationService {
pub fn new(config: NotificationConfig) -> Self {
Self { config }
}
pub async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
if !self.config.is_email_configured() {
return Ok(());
}
let email = Message::builder()
.from(self.config.from_email.parse()?)
.to(to.parse()?)
.subject(subject)
.header(ContentType::TEXT_PLAIN)
.body(body.to_string())?;
let creds = Credentials::new(
self.config.smtp_username.clone(),
self.config.smtp_password.clone(),
);
let mailer = SmtpTransport::relay(&self.config.smtp_host)?
.port(self.config.smtp_port)
.credentials(creds)
.build();
mailer.send(&email)?;
Ok(())
}
pub fn send_ntfy(&self, title: &str, message: &str, priority: Option<u8>) -> Result<()> {
if !self.config.is_ntfy_configured() {
return Ok(());
}
let url = format!("{}/{}", self.config.ntfy_server, self.config.ntfy_topic);
let mut request = ureq::post(&url).set("Title", title);
if let Some(p) = priority {
request = request.set("Priority", &p.to_string());
}
request.send_string(message)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_notification_config() {
std::env::set_var("SMTP_HOST", "smtp.example.com");
std::env::set_var("SMTP_PORT", "587");
std::env::set_var("SMTP_USERNAME", "test@example.com");
std::env::set_var("SMTP_PASSWORD", "password");
std::env::set_var("FROM_EMAIL", "noreply@example.com");
std::env::set_var("NTFY_TOPIC", "my-topic");
let config = NotificationConfig::from_env().unwrap();
assert_eq!(config.smtp_host, "smtp.example.com");
assert_eq!(config.smtp_port, 587);
assert_eq!(config.ntfy_server, "https://ntfy.sh");
}
#[test]
fn test_config_validation() {
let default_config = NotificationConfig::default();
assert!(!default_config.is_email_configured());
assert!(!default_config.is_ntfy_configured());
let config = NotificationConfig {
smtp_host: "smtp.example.com".to_string(),
smtp_port: 587,
smtp_username: "user".to_string(),
smtp_password: "pass".to_string(),
from_email: "test@example.com".to_string(),
ntfy_topic: "topic".to_string(),
ntfy_server: "https://ntfy.sh".to_string(),
};
assert!(config.is_email_configured());
assert!(config.is_ntfy_configured());
}
}

97
src/routes/api_v1.js Normal file
View File

@ -0,0 +1,97 @@
const express = require("express");
const pool = require("../config/database");
const apiAuthMiddleware = require("../middleware/apiAuthMiddleware");
const router = express.Router();
// All routes in this file will be protected by API key authentication
router.use(apiAuthMiddleware);
// GET /api/v1/forms - List forms for the authenticated user
router.get("/forms", async (req, res) => {
try {
const [forms] = await pool.query(
`SELECT uuid, name, created_at, is_archived,
(SELECT COUNT(*) FROM submissions WHERE form_uuid = f.uuid) as submission_count
FROM forms f
WHERE f.user_id = ?
ORDER BY f.created_at DESC`,
[req.user.id] // req.user.id is attached by apiAuthMiddleware
);
res.json({ success: true, forms });
} catch (error) {
console.error("API Error fetching forms for user:", req.user.id, error);
res.status(500).json({ success: false, error: "Failed to fetch forms." });
}
});
// GET /api/v1/forms/:formUuid/submissions - List submissions for a specific form
router.get("/forms/:formUuid/submissions", async (req, res) => {
const { formUuid } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 25; // Default 25 submissions per page for API
const offset = (page - 1) * limit;
try {
// First, verify the user (from API key) owns the form
const [formDetails] = await pool.query(
"SELECT user_id, name FROM forms WHERE uuid = ?",
[formUuid]
);
if (formDetails.length === 0) {
return res.status(404).json({ success: false, error: "Form not found." });
}
if (formDetails[0].user_id !== req.user.id) {
return res
.status(403)
.json({
success: false,
error: "Access denied. You do not own this form.",
});
}
// Get total count of submissions for pagination
const [countResult] = await pool.query(
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = ?",
[formUuid]
);
const totalSubmissions = countResult[0].total;
const totalPages = Math.ceil(totalSubmissions / limit);
// Fetch paginated submissions
const [submissions] = await pool.query(
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
[formUuid, limit, offset]
);
res.json({
success: true,
formName: formDetails[0].name,
formUuid,
pagination: {
currentPage: page,
totalPages: totalPages,
totalSubmissions: totalSubmissions,
limit: limit,
perPage: limit, // Alias for limit
count: submissions.length,
},
submissions,
});
} catch (error) {
console.error(
"API Error fetching submissions for form:",
formUuid,
"user:",
req.user.id,
error
);
res
.status(500)
.json({ success: false, error: "Failed to fetch submissions." });
}
});
module.exports = router;

765
src/routes/auth.js Normal file
View File

@ -0,0 +1,765 @@
const express = require("express");
const passport = require("../config/passport");
const User = require("../models/User");
const jwtService = require("../services/jwtService");
const emailService = require("../services/emailService");
const { body } = require("express-validator");
const {
validateRegistration,
validateLogin,
validateForgotPassword,
validateResetPassword,
validateProfileUpdate,
handleValidationErrors,
} = require("../middleware/validation");
const {
authRateLimit,
passwordResetRateLimit,
registrationRateLimit,
requireAuth,
requireVerifiedAuth,
} = require("../middleware/authMiddleware");
const router = express.Router();
// Register new user
router.post(
"/register",
registrationRateLimit,
validateRegistration,
async (req, res) => {
try {
const { email, password, first_name, last_name } = req.body;
// Check if user already exists
const existingUser = await User.findByEmail(email);
if (existingUser) {
return res.status(409).json({
success: false,
message: "An account with this email address already exists",
});
}
// Create new user
const newUser = await User.create({
email,
password,
first_name,
last_name,
role: "user",
is_verified: false,
});
// Send verification email
if (emailService.isAvailable()) {
await emailService.sendVerificationEmail(
newUser.email,
newUser.first_name,
newUser.verification_token
);
}
res.status(201).json({
success: true,
message:
"Account created successfully. Please check your email to verify your account.",
data: {
user: {
id: newUser.id,
uuid: newUser.uuid,
email: newUser.email,
first_name: newUser.first_name,
last_name: newUser.last_name,
is_verified: newUser.is_verified,
},
},
});
} catch (error) {
console.error("Registration error:", error);
res.status(500).json({
success: false,
message: error.message || "Registration failed",
});
}
}
);
// Login user
router.post("/login", authRateLimit, validateLogin, (req, res, next) => {
passport.authenticate(
"local",
{ session: false },
async (err, user, info) => {
try {
if (err) {
return res.status(500).json({
success: false,
message: "Authentication error",
error: err.message,
});
}
if (!user) {
return res.status(401).json({
success: false,
message: info.message || "Invalid credentials",
});
}
// Check if password change is required
if (user.must_change_password) {
// Generate a temporary token that's only valid for password change
// This step depends on how you want to handle the forced change flow.
// For now, we'll just send a specific response.
// A more robust solution might involve a temporary, restricted token.
return res.status(403).json({
// 403 Forbidden, but with a specific reason
success: false,
message: "Password change required.",
code: "MUST_CHANGE_PASSWORD",
data: {
user: {
// Send minimal user info
id: user.id,
uuid: user.uuid,
email: user.email,
role: user.role,
},
},
});
}
// Generate JWT tokens
const sessionInfo = {
userAgent: req.get("User-Agent"),
ipAddress: req.ip,
};
const tokens = jwtService.generateTokenPair(user, sessionInfo);
res.json({
success: true,
message: "Login successful",
data: {
user: {
id: user.id,
uuid: user.uuid,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_verified: user.is_verified,
last_login: user.last_login,
},
...tokens,
},
});
} catch (error) {
console.error("Login error:", error);
res.status(500).json({
success: false,
message: "Login failed",
});
}
}
)(req, res, next);
});
// Refresh access token
router.post("/refresh", async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({
success: false,
message: "Refresh token is required",
});
}
const sessionInfo = {
userAgent: req.get("User-Agent"),
ipAddress: req.ip,
};
const result = await jwtService.refreshAccessToken(
refreshToken,
sessionInfo
);
res.json({
success: true,
message: "Token refreshed successfully",
data: result,
});
} catch (error) {
console.error("Token refresh error:", error);
res.status(401).json({
success: false,
message: error.message || "Token refresh failed",
});
}
});
// Logout user
router.post("/logout", requireAuth, async (req, res) => {
try {
const authHeader = req.headers.authorization;
const token = jwtService.extractTokenFromHeader(authHeader);
if (token) {
await jwtService.revokeToken(token);
}
res.json({
success: true,
message: "Logged out successfully",
});
} catch (error) {
console.error("Logout error:", error);
res.status(500).json({
success: false,
message: "Logout failed",
});
}
});
// Logout from all devices
router.post("/logout-all", requireAuth, async (req, res) => {
try {
const revokedCount = await jwtService.revokeAllUserTokens(req.user.id);
res.json({
success: true,
message: `Logged out from ${revokedCount} devices successfully`,
});
} catch (error) {
console.error("Logout all error:", error);
res.status(500).json({
success: false,
message: "Logout from all devices failed",
});
}
});
// Verify email
router.get("/verify-email", async (req, res) => {
try {
const { token } = req.query;
if (!token) {
return res.status(400).json({
success: false,
message: "Verification token is required",
});
}
const user = await User.findByVerificationToken(token);
if (!user) {
return res.status(400).json({
success: false,
message: "Invalid or expired verification token",
});
}
if (user.is_verified) {
return res.status(400).json({
success: false,
message: "Email is already verified",
});
}
const verified = await User.verifyEmail(token);
if (!verified) {
return res.status(400).json({
success: false,
message: "Email verification failed",
});
}
// Send welcome email
if (emailService.isAvailable()) {
await emailService.sendWelcomeEmail(user.email, user.first_name);
}
res.json({
success: true,
message: "Email verified successfully! You can now access all features.",
});
} catch (error) {
console.error("Email verification error:", error);
res.status(500).json({
success: false,
message: "Email verification failed",
});
}
});
// Resend verification email
router.post("/resend-verification", authRateLimit, async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({
success: false,
message: "Email is required",
});
}
const user = await User.findByEmail(email);
if (!user) {
// Don't reveal if email exists or not
return res.json({
success: true,
message:
"If an account with this email exists and is not verified, a verification email has been sent.",
});
}
if (user.is_verified) {
return res.status(400).json({
success: false,
message: "Email is already verified",
});
}
// Send verification email
if (emailService.isAvailable() && user.verification_token) {
await emailService.sendVerificationEmail(
user.email,
user.first_name,
user.verification_token
);
}
res.json({
success: true,
message:
"If an account with this email exists and is not verified, a verification email has been sent.",
});
} catch (error) {
console.error("Resend verification error:", error);
res.status(500).json({
success: false,
message: "Failed to resend verification email",
});
}
});
// Forgot password - Request password reset
router.post(
"/forgot-password",
passwordResetRateLimit,
validateForgotPassword,
async (req, res) => {
try {
const { email } = req.body;
// Don't reveal if email exists or not for security
const user = await User.findByEmail(email);
if (user) {
// Generate password reset token
const resetData = await User.setPasswordResetToken(email);
if (resetData && emailService.isAvailable()) {
await emailService.sendPasswordResetEmail(
user.email,
user.first_name,
resetData.token
);
}
}
// Always return success to prevent email enumeration
res.json({
success: true,
message:
"If an account with this email exists, a password reset email has been sent.",
});
} catch (error) {
console.error("Forgot password error:", error);
res.status(500).json({
success: false,
message: "Failed to process password reset request",
});
}
}
);
// Reset password - Change password using reset token
router.post(
"/reset-password",
passwordResetRateLimit,
validateResetPassword,
async (req, res) => {
try {
const { token, password } = req.body;
// Find user by reset token
const user = await User.findByPasswordResetToken(token);
if (!user) {
return res.status(400).json({
success: false,
message: "Invalid or expired reset token",
});
}
// Update password
const updated = await User.updatePassword(user.id, password);
if (!updated) {
return res.status(500).json({
success: false,
message: "Failed to update password",
});
}
// Send password changed notification
if (emailService.isAvailable()) {
await emailService.sendPasswordChangedEmail(
user.email,
user.first_name
);
}
// Revoke all existing sessions for security
await jwtService.revokeAllUserTokens(user.id);
res.json({
success: true,
message:
"Password has been reset successfully. Please log in with your new password.",
});
} catch (error) {
console.error("Reset password error:", error);
res.status(500).json({
success: false,
message: "Failed to reset password",
});
}
}
);
// Get current user profile
router.get("/profile", requireAuth, async (req, res) => {
try {
const stats = await User.getUserStats(req.user.id);
res.json({
success: true,
data: {
user: {
...req.user,
stats,
},
},
});
} catch (error) {
console.error("Profile fetch error:", error);
res.status(500).json({
success: false,
message: "Failed to fetch profile",
});
}
});
// Update user profile
router.put("/profile", requireAuth, validateProfileUpdate, async (req, res) => {
try {
const { first_name, last_name, email } = req.body;
const updates = {};
if (first_name !== undefined) updates.first_name = first_name;
if (last_name !== undefined) updates.last_name = last_name;
if (email !== undefined && email !== req.user.email) {
updates.email = email;
// If email is being changed, user needs to verify the new email
// For now, we'll just update it directly
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({
success: false,
message: "No valid fields to update",
});
}
const updated = await User.updateProfile(req.user.id, updates);
if (!updated) {
return res.status(400).json({
success: false,
message: "Profile update failed",
});
}
// Get updated user data
const updatedUser = await User.findById(req.user.id);
res.json({
success: true,
message: "Profile updated successfully",
data: {
user: {
id: updatedUser.id,
uuid: updatedUser.uuid,
email: updatedUser.email,
first_name: updatedUser.first_name,
last_name: updatedUser.last_name,
role: updatedUser.role,
is_verified: updatedUser.is_verified,
is_active: updatedUser.is_active,
},
},
});
} catch (error) {
console.error("Profile update error:", error);
res.status(500).json({
success: false,
message: error.message || "Profile update failed",
});
}
});
// Get user's active sessions
router.get("/sessions", requireAuth, async (req, res) => {
try {
const sessions = await User.getUserActiveSessions(req.user.id);
res.json({
success: true,
data: {
sessions,
},
});
} catch (error) {
console.error("Get sessions error:", error);
res.status(500).json({
success: false,
message: "Failed to fetch sessions",
});
}
});
// Revoke a specific session
router.delete("/sessions/:jti", requireAuth, async (req, res) => {
try {
const { jti } = req.params;
// Verify the session belongs to the user
const session = await User.getSessionByJti(jti);
if (!session || session.user_id !== req.user.id) {
return res.status(404).json({
success: false,
message: "Session not found",
});
}
const revoked = await User.revokeSession(jti);
if (!revoked) {
return res.status(500).json({
success: false,
message: "Failed to revoke session",
});
}
res.json({
success: true,
message: "Session revoked successfully",
});
} catch (error) {
console.error("Revoke session error:", error);
res.status(500).json({
success: false,
message: "Failed to revoke session",
});
}
});
// Get current session information
router.get("/current-session", requireAuth, async (req, res) => {
try {
const authHeader = req.headers.authorization;
const token = jwtService.extractTokenFromHeader(authHeader);
if (!token) {
return res.status(401).json({
success: false,
message: "No token provided",
});
}
const session = await jwtService.getCurrentSession(token);
res.json({
success: true,
data: {
session,
},
});
} catch (error) {
console.error("Get current session error:", error);
res.status(500).json({
success: false,
message: "Failed to get current session information",
});
}
});
// Change password for logged-in users
router.put(
"/change-password",
requireAuth,
[
body("currentPassword")
.notEmpty()
.withMessage("Current password is required"),
body("newPassword")
.isLength({ min: 8 })
.withMessage("New password must be at least 8 characters long")
.matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/
)
.withMessage(
"New password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"
),
body("confirmNewPassword").custom((value, { req }) => {
if (value !== req.body.newPassword) {
throw new Error("Password confirmation does not match new password");
}
return true;
}),
handleValidationErrors,
],
async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
const bcrypt = require("bcryptjs");
// Get user with password hash
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({
success: false,
message: "User not found",
});
}
// Verify current password
const isCurrentPasswordValid = await bcrypt.compare(
currentPassword,
user.password_hash
);
if (!isCurrentPasswordValid) {
return res.status(400).json({
success: false,
message: "Current password is incorrect",
});
}
// Update password
const updated = await User.updatePassword(user.id, newPassword);
if (!updated) {
return res.status(500).json({
success: false,
message: "Failed to update password",
});
}
// Send password changed notification
if (emailService.isAvailable()) {
await emailService.sendPasswordChangedEmail(
user.email,
user.first_name
);
}
// Revoke all other sessions (keep current session)
const authHeader = req.headers.authorization;
const currentToken = jwtService.extractTokenFromHeader(authHeader);
const decoded = jwtService.verifyToken(currentToken);
// Revoke all sessions except current one
await jwtService.revokeAllUserTokensExcept(user.id, decoded.jti);
res.json({
success: true,
message: "Password changed successfully",
});
} catch (error) {
console.error("Change password error:", error);
res.status(500).json({
success: false,
message: "Failed to change password",
});
}
}
);
// Force password change if must_change_password is true
router.post(
"/force-change-password",
requireAuth, // Ensures user is logged in (even if with must_change_password = true)
[
body("newPassword")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters long"),
],
handleValidationErrors,
async (req, res) => {
try {
const { newPassword } = req.body;
const userId = req.user.id;
// Double check if user still needs to change password
// (req.user might be from a valid token but DB state could have changed)
const currentUser = await User.findById(userId);
if (!currentUser || !currentUser.must_change_password) {
return res.status(400).json({
success: false,
message: "Password change not required or user not found.",
});
}
// Update password and clear the flag
const updated = await User.updatePasswordAndClearChangeFlag(
userId,
newPassword
);
if (!updated) {
return res.status(500).json({
success: false,
message: "Failed to update password.",
});
}
// Log out all other sessions for this user for security
const authHeader = req.headers.authorization;
const currentToken = jwtService.extractTokenFromHeader(authHeader);
const decoded = jwtService.verifyToken(currentToken); // Make sure verifyToken doesn't throw on expired/invalid for this flow if needed or handle it
if (decoded && decoded.jti) {
// Ensure there is a jti in the current token
await jwtService.revokeAllUserTokensExcept(userId, decoded.jti);
} else {
// Fallback if current token has no jti, revoke all including current. User will need to log in again.
await jwtService.revokeAllUserTokens(userId);
}
res.json({
success: true,
message:
"Password changed successfully. Please log in again with your new password.",
});
} catch (error) {
console.error("Force change password error:", error);
res.status(500).json({
success: false,
message: "Failed to change password",
});
}
}
);
module.exports = router;

774
src/routes/dashboard.js Normal file
View File

@ -0,0 +1,774 @@
const express = require("express");
const pool = require("../config/database"); // Assuming database config is here
const { requireAuth } = require("../middleware/authMiddleware"); // Assuming auth middleware
const { v4: uuidv4 } = require("uuid"); // Make sure to require uuid
const { sendNtfyNotification } = require("../services/notification"); // Fixed import path
const {
generateApiKeyParts,
hashApiKeySecret,
} = require("../utils/apiKeyHelper.js"); // Import API key helpers
const router = express.Router();
// All dashboard routes require authentication
router.use(requireAuth);
// GET /dashboard - Main dashboard view (My Forms)
router.get("/", async (req, res) => {
try {
const [forms] = await pool.query(
`SELECT f.uuid, f.name, f.created_at, f.is_archived,
(SELECT COUNT(*) FROM submissions WHERE form_uuid = f.uuid) as submission_count
FROM forms f
WHERE f.user_id = ?
ORDER BY f.created_at DESC`,
[req.user.id]
);
res.render("dashboard", {
user: req.user,
forms: forms,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "my_forms", // To tell dashboard.ejs which section to show
pageTitle: "My Forms",
});
} catch (error) {
console.error("Error fetching user forms:", error);
// res.status(500).send("Error fetching forms"); // Or render an error page
res.render("dashboard", {
user: req.user,
forms: [],
appUrl: `${req.protocol}://${req.get("host")}`,
view: "my_forms",
pageTitle: "My Forms",
error: "Could not load your forms at this time.",
});
}
});
// GET /dashboard/create-form - Display page to create a new form
router.get("/create-form", (req, res) => {
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "create_form", // To tell dashboard.ejs to show the create form section
pageTitle: "Create New Form",
});
});
// POST /dashboard/forms/create - Handle new form creation
router.post("/forms/create", async (req, res) => {
const formName = req.body.formName || "Untitled Form";
const newUuid = uuidv4();
try {
await pool.query(
"INSERT INTO forms (uuid, name, user_id) VALUES (?, ?, ?)",
[newUuid, formName, req.user.id]
);
console.log(
`Form created: ${formName} with UUID: ${newUuid} for user: ${req.user.id}`
);
// Optional: Send a notification (if your ntfy setup is user-specific or global)
// Consider if this notification is still relevant or needs adjustment for user context
if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
try {
await sendNtfyNotification(
"New Form Created (User)",
`Form \"${formName}\" (UUID: ${newUuid}) was created by user ${req.user.email}.`,
"high"
);
} catch (ntfyError) {
console.error(
"Failed to send ntfy notification for new form creation:",
ntfyError
);
}
}
res.redirect("/dashboard"); // Redirect to the user's form list
} catch (error) {
console.error("Error creating form for user:", error);
// Render the create form page again with an error message
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "create_form",
pageTitle: "Create New Form",
error: "Failed to create form. Please try again.",
formNameValue: formName, // Pass back the entered form name
});
}
});
// GET /dashboard/submissions/:formUuid - View submissions for a specific form
router.get("/submissions/:formUuid", async (req, res) => {
const { formUuid } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10; // Default 10 submissions per page
const offset = (page - 1) * limit;
try {
// First, verify the user owns the form
const [formDetails] = await pool.query(
"SELECT name, user_id FROM forms WHERE uuid = ?",
[formUuid]
);
if (formDetails.length === 0) {
// return res.status(404).send("Form not found.");
return res.render("dashboard", {
user: req.user,
view: "my_forms", // Redirect to a safe place or show a specific error view
pageTitle: "Form Not Found",
error: "The form you are looking for does not exist.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [], // Provide empty forms array if redirecting to my_forms with an error
});
}
if (formDetails[0].user_id !== req.user.id) {
// return res.status(403).send("Access denied. You do not own this form.");
return res.render("dashboard", {
user: req.user,
view: "my_forms", // Redirect to a safe place or show a specific error view
pageTitle: "Access Denied",
error: "You do not have permission to view submissions for this form.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [], // Provide empty forms array
});
}
const formName = formDetails[0].name;
// Get total count of submissions for pagination
const [countResult] = await pool.query(
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = ?",
[formUuid]
);
const totalSubmissions = countResult[0].total;
const totalPages = Math.ceil(totalSubmissions / limit);
// Fetch paginated submissions
const [submissions] = await pool.query(
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
[formUuid, limit, offset]
);
res.render("dashboard", {
user: req.user,
view: "form_submissions",
pageTitle: `Submissions for ${formName}`,
submissions: submissions,
formUuid: formUuid,
formName: formName,
appUrl: `${req.protocol}://${req.get("host")}`,
pagination: {
currentPage: page,
totalPages: totalPages,
totalSubmissions: totalSubmissions,
limit: limit,
},
});
} catch (error) {
console.error(
"Error fetching submissions for form:",
formUuid,
"user:",
req.user.id,
error
);
// Render an error state within the dashboard
res.render("dashboard", {
user: req.user,
view: "form_submissions", // Or a dedicated error view component
pageTitle: "Error Loading Submissions",
error:
"Could not load submissions for this form. Please try again later.",
formUuid: formUuid,
formName: "Error", // Placeholder for formName when an error occurs
submissions: [],
appUrl: `${req.protocol}://${req.get("host")}`,
pagination: {
currentPage: 1,
totalPages: 1,
totalSubmissions: 0,
limit: limit,
},
});
}
});
// GET /dashboard/submissions/:formUuid/export - Export submissions to CSV
router.get("/submissions/:formUuid/export", async (req, res) => {
const { formUuid } = req.params;
try {
// First, verify the user owns the form
const [formDetails] = await pool.query(
"SELECT name, user_id FROM forms WHERE uuid = ?",
[formUuid]
);
if (formDetails.length === 0) {
return res.status(404).send("Form not found.");
}
if (formDetails[0].user_id !== req.user.id) {
return res.status(403).send("Access denied. You do not own this form.");
}
const formName = formDetails[0].name;
const [submissions] = await pool.query(
"SELECT data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC",
[formUuid]
);
// Create CSV content
const headers = ["Submitted At", "IP Address"];
const rows = submissions.map((submission) => {
const data = JSON.parse(submission.data);
// Add all form fields as headers
Object.keys(data).forEach((key) => {
if (!headers.includes(key)) {
headers.push(key);
}
});
return {
submitted_at: new Date(submission.submitted_at).toISOString(),
ip_address: submission.ip_address,
...data,
};
});
// Generate CSV content
let csvContent = headers.join(",") + "\n";
rows.forEach((row) => {
const values = headers.map((header) => {
const value = row[header] || "";
// Escape commas and quotes in values
return `"${String(value).replace(/"/g, '""')}"`;
});
csvContent += values.join(",") + "\n";
});
// Set response headers for CSV download
res.setHeader("Content-Type", "text/csv");
res.setHeader(
"Content-Disposition",
`attachment; filename="${formName}-submissions.csv"`
);
res.send(csvContent);
} catch (error) {
console.error(
"Error exporting submissions:",
formUuid,
"user:",
req.user.id,
error
);
res.status(500).send("Error exporting submissions");
}
});
// GET /dashboard/forms/:formUuid/settings - Display form settings page
router.get("/forms/:formUuid/settings", async (req, res) => {
const { formUuid } = req.params;
try {
const [formDetailsArray] = await pool.query(
"SELECT name, user_id, email_notifications_enabled, notification_email_address, recaptcha_enabled, thank_you_url, thank_you_message, allowed_domains FROM forms WHERE uuid = ?",
[formUuid]
);
if (formDetailsArray.length === 0) {
return res.render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Form Not Found",
error: "The form you are trying to access settings for does not exist.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
const formDetails = formDetailsArray[0];
if (formDetails.user_id !== req.user.id) {
return res.render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Access Denied",
error: "You do not have permission to access settings for this form.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
res.render("dashboard", {
user: req.user,
view: "form_settings",
pageTitle: `Settings for ${formDetails.name}`,
formName: formDetails.name, // For the header
currentFormName: formDetails.name, // For the input field value
formUuid: formUuid,
currentEmailNotificationsEnabled: formDetails.email_notifications_enabled,
currentNotificationEmailAddress: formDetails.notification_email_address,
currentRecaptchaEnabled: formDetails.recaptcha_enabled,
currentThankYouUrl: formDetails.thank_you_url,
currentThankYouMessage: formDetails.thank_you_message,
currentAllowedDomains: formDetails.allowed_domains,
appUrl: `${req.protocol}://${req.get("host")}`,
successMessage: req.query.successMessage,
errorMessage: req.query.errorMessage,
});
} catch (error) {
console.error(
"Error fetching form settings for form:",
formUuid,
"user:",
req.user.id,
error
);
res.render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Error",
error: "Could not load settings for this form. Please try again later.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [], // Go back to a safe page
});
}
});
// POST /dashboard/forms/:formUuid/settings/update - Update various form settings
router.post("/forms/:formUuid/settings/update", async (req, res) => {
const { formUuid } = req.params;
const {
formName,
emailNotificationsEnabled,
notificationEmailAddress,
recaptchaEnabled,
thankYouUrl,
thankYouMessage,
allowedDomains,
} = req.body;
// Validate formName (must not be empty if provided)
if (formName !== undefined && formName.trim() === "") {
return res.redirect(
`/dashboard/forms/${formUuid}/settings?errorMessage=Form name cannot be empty.`
);
}
// Convert checkbox values which might come as 'on' or undefined
const finalEmailNotificationsEnabled =
emailNotificationsEnabled === "on" || emailNotificationsEnabled === true;
const finalRecaptchaEnabled =
recaptchaEnabled === "on" || recaptchaEnabled === true;
// If email notifications are enabled, but no specific address is provided,
// and there's no existing specific address, we might want to clear it or use user's default.
// For now, if it's blank, we'll store NULL or an empty string based on DB.
// Let's assume an empty string means "use user's default email" when sending.
const finalNotificationEmailAddress = notificationEmailAddress
? notificationEmailAddress.trim()
: null;
try {
// First, verify the user owns the form
const [formOwnerCheck] = await pool.query(
"SELECT user_id FROM forms WHERE uuid = ?",
[formUuid]
);
if (
formOwnerCheck.length === 0 ||
formOwnerCheck[0].user_id !== req.user.id
) {
// Security: Do not reveal if form exists or not, just deny.
// Or redirect to a generic error page/dashboard.
// For now, let's redirect with a generic error.
return res.redirect(
`/dashboard/forms/${formUuid}/settings?errorMessage=Access denied or form not found.`
);
}
// Build the update query dynamically based on which fields are provided
const updates = {};
if (formName !== undefined) updates.name = formName.trim();
if (emailNotificationsEnabled !== undefined)
updates.email_notifications_enabled = finalEmailNotificationsEnabled;
if (notificationEmailAddress !== undefined)
updates.notification_email_address = finalNotificationEmailAddress; // Allows clearing the address
if (recaptchaEnabled !== undefined)
updates.recaptcha_enabled = finalRecaptchaEnabled;
if (thankYouUrl !== undefined)
updates.thank_you_url = thankYouUrl.trim() || null;
if (thankYouMessage !== undefined)
updates.thank_you_message = thankYouMessage.trim() || null;
if (allowedDomains !== undefined)
updates.allowed_domains = allowedDomains.trim() || null;
if (Object.keys(updates).length === 0) {
// Nothing to update, redirect back, maybe with an info message
return res.redirect(
`/dashboard/forms/${formUuid}/settings?successMessage=No changes were made.`
);
}
updates.updated_at = new Date(); // Explicitly set updated_at
await pool.query("UPDATE forms SET ? WHERE uuid = ? AND user_id = ?", [
updates,
formUuid,
req.user.id, // Ensure user_id match as an extra precaution
]);
console.log(
`Form settings updated for ${formUuid} by user ${req.user.id}:`,
updates
);
res.redirect(
`/dashboard/forms/${formUuid}/settings?successMessage=Settings updated successfully!`
);
} catch (error) {
console.error(
"Error updating form settings for form:",
formUuid,
"user:",
req.user.id,
error
);
res.redirect(
`/dashboard/forms/${formUuid}/settings?errorMessage=Error updating settings. Please try again.`
);
}
});
// POST /dashboard/forms/archive/:formUuid - Archive a form
router.post("/forms/archive/:formUuid", async (req, res) => {
const { formUuid } = req.params;
try {
const [formDetails] = await pool.query(
"SELECT user_id FROM forms WHERE uuid = ?",
[formUuid]
);
if (formDetails.length === 0) {
return res.redirect(
"/dashboard?errorMessage=" + encodeURIComponent("Form not found.")
);
}
if (formDetails[0].user_id !== req.user.id) {
return res.redirect(
"/dashboard?errorMessage=" +
encodeURIComponent("You do not have permission to modify this form.")
);
}
await pool.query(
"UPDATE forms SET is_archived = true WHERE uuid = ? AND user_id = ?",
[formUuid, req.user.id]
);
res.redirect(
"/dashboard?successMessage=" +
encodeURIComponent("Form archived successfully.")
);
} catch (error) {
console.error("Error archiving form:", formUuid, error);
res.redirect(
"/dashboard?errorMessage=" + encodeURIComponent("Failed to archive form.")
);
}
});
// POST /dashboard/forms/unarchive/:formUuid - Unarchive a form
router.post("/forms/unarchive/:formUuid", async (req, res) => {
const { formUuid } = req.params;
try {
const [formDetails] = await pool.query(
"SELECT user_id FROM forms WHERE uuid = ?",
[formUuid]
);
if (formDetails.length === 0) {
return res.redirect(
"/dashboard?errorMessage=" + encodeURIComponent("Form not found.")
);
}
if (formDetails[0].user_id !== req.user.id) {
return res.redirect(
"/dashboard?errorMessage=" +
encodeURIComponent("You do not have permission to modify this form.")
);
}
await pool.query(
"UPDATE forms SET is_archived = false WHERE uuid = ? AND user_id = ?",
[formUuid, req.user.id]
);
res.redirect(
"/dashboard?successMessage=" +
encodeURIComponent("Form unarchived successfully.")
);
} catch (error) {
console.error("Error unarchiving form:", formUuid, error);
res.redirect(
"/dashboard?errorMessage=" +
encodeURIComponent("Failed to unarchive form.")
);
}
});
// POST /dashboard/forms/delete/:formUuid - Permanently delete a form
router.post("/forms/delete/:formUuid", async (req, res) => {
const { formUuid } = req.params;
try {
// Verify ownership first
const [formDetails] = await pool.query(
"SELECT user_id, name FROM forms WHERE uuid = ?",
[formUuid]
);
if (formDetails.length === 0) {
return res.redirect(
"/dashboard?errorMessage=" + encodeURIComponent("Form not found.")
);
}
if (formDetails[0].user_id !== req.user.id) {
return res.redirect(
"/dashboard?errorMessage=" +
encodeURIComponent("You do not have permission to delete this form.")
);
}
// Perform deletion. Assuming ON DELETE CASCADE is set up for submissions.
// If not, delete submissions explicitly first: await pool.query("DELETE FROM submissions WHERE form_uuid = ?", [formUuid]);
const [deleteResult] = await pool.query(
"DELETE FROM forms WHERE uuid = ? AND user_id = ?",
[formUuid, req.user.id]
);
if (deleteResult.affectedRows > 0) {
console.log(
`Form permanently deleted: ${formDetails[0].name} (UUID: ${formUuid}) by user ${req.user.id}`
);
res.redirect(
"/dashboard?successMessage=" +
encodeURIComponent(
`Form '${formDetails[0].name}' and its submissions deleted successfully.`
)
);
} else {
res.redirect(
"/dashboard?errorMessage=" +
encodeURIComponent(
"Failed to delete form. It might have already been deleted."
)
);
}
} catch (error) {
console.error("Error deleting form:", formUuid, error);
res.redirect(
"/dashboard?errorMessage=" +
encodeURIComponent("An error occurred while deleting the form.")
);
}
});
// POST /dashboard/submissions/delete/:submissionId - Delete a specific submission
router.post("/submissions/delete/:submissionId", async (req, res) => {
const { submissionId } = req.params;
const { formUuidForRedirect } = req.body; // Get this from the form body for redirect
if (!formUuidForRedirect) {
console.error(
"formUuidForRedirect not provided for submission deletion redirect"
);
return res.redirect(
"/dashboard?errorMessage=" +
encodeURIComponent(
"Could not determine where to redirect after deletion."
)
);
}
try {
// First, verify the user owns the form to which the submission belongs
const [submissionDetails] = await pool.query(
`SELECT s.form_uuid, f.user_id
FROM submissions s
JOIN forms f ON s.form_uuid = f.uuid
WHERE s.id = ?`,
[submissionId]
);
if (submissionDetails.length === 0) {
return res.redirect(
`/dashboard/submissions/${formUuidForRedirect}?errorMessage=` +
encodeURIComponent("Submission not found.")
);
}
if (submissionDetails[0].user_id !== req.user.id) {
return res.redirect(
`/dashboard/submissions/${formUuidForRedirect}?errorMessage=` +
encodeURIComponent(
"You do not have permission to delete this submission."
)
);
}
// Actual deletion of the submission
const [deleteResult] = await pool.query(
"DELETE FROM submissions WHERE id = ?",
[submissionId]
);
if (deleteResult.affectedRows > 0) {
console.log(
`Submission ID ${submissionId} deleted by user ${req.user.id}`
);
res.redirect(
`/dashboard/submissions/${formUuidForRedirect}?successMessage=` +
encodeURIComponent("Submission deleted successfully.")
);
} else {
res.redirect(
`/dashboard/submissions/${formUuidForRedirect}?errorMessage=` +
encodeURIComponent(
"Failed to delete submission. It might have already been deleted."
)
);
}
} catch (error) {
console.error(
"Error deleting submission:",
submissionId,
"user:",
req.user.id,
error
);
res.redirect(
`/dashboard/submissions/${formUuidForRedirect}?errorMessage=` +
encodeURIComponent("An error occurred while deleting the submission.")
);
}
});
// GET /dashboard/api-keys - Display API key management page
router.get("/api-keys", async (req, res) => {
try {
const [keys] = await pool.query(
"SELECT uuid, key_name, api_key_identifier, created_at, last_used_at, expires_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC",
[req.user.id]
);
res.render("dashboard", {
user: req.user,
view: "api_keys",
pageTitle: "API Keys",
apiKeys: keys,
appUrl: `${req.protocol}://${req.get("host")}`,
// For displaying a newly generated key (one-time)
newlyGeneratedApiKey: req.session.newlyGeneratedApiKey,
newlyGeneratedApiKeyName: req.session.newlyGeneratedApiKeyName,
});
// Clear the newly generated key from session after displaying it once
if (req.session.newlyGeneratedApiKey) {
delete req.session.newlyGeneratedApiKey;
delete req.session.newlyGeneratedApiKeyName;
}
} catch (error) {
console.error("Error fetching API keys for user:", req.user.id, error);
res.render("dashboard", {
user: req.user,
view: "api_keys",
pageTitle: "API Keys",
apiKeys: [],
error: "Could not load your API keys at this time.",
appUrl: `${req.protocol}://${req.get("host")}`,
});
}
});
// POST /dashboard/api-keys/generate - Generate a new API key
router.post("/api-keys/generate", async (req, res) => {
const { keyName } = req.body;
if (!keyName || keyName.trim() === "") {
return res.redirect(
"/dashboard/api-keys?errorMessage=Key name cannot be empty."
);
}
try {
const { fullApiKey, identifier, secret } = generateApiKeyParts();
const hashedSecret = await hashApiKeySecret(secret);
const newApiKeyUuid = uuidv4();
await pool.query(
"INSERT INTO api_keys (uuid, user_id, key_name, api_key_identifier, hashed_api_key_secret) VALUES (?, ?, ?, ?, ?)",
[newApiKeyUuid, req.user.id, keyName.trim(), identifier, hashedSecret]
);
console.log(
`API Key generated for user ${req.user.id}: Name: ${keyName.trim()}, Identifier: ${identifier}`
);
// Store the full API key in session to display it ONCE to the user
// This is a common pattern as the full key should not be retrievable again.
req.session.newlyGeneratedApiKey = fullApiKey;
req.session.newlyGeneratedApiKeyName = keyName.trim();
res.redirect(
"/dashboard/api-keys?successMessage=API Key generated successfully! Make sure to copy it now, you won\'t see it again."
);
} catch (error) {
console.error("Error generating API key for user:", req.user.id, error);
// Check for unique constraint violation on api_key_identifier (rare, but possible)
if (error.code === "ER_DUP_ENTRY") {
return res.redirect(
"/dashboard/api-keys?errorMessage=Failed to generate key due to a conflict. Please try again."
);
}
res.redirect(
"/dashboard/api-keys?errorMessage=Error generating API key. Please try again."
);
}
});
// POST /dashboard/api-keys/:apiKeyUuid/revoke - Revoke (delete) an API key
router.post("/api-keys/:apiKeyUuid/revoke", async (req, res) => {
const { apiKeyUuid } = req.params;
try {
const [keyDetails] = await pool.query(
"SELECT user_id, key_name FROM api_keys WHERE uuid = ? AND user_id = ?",
[apiKeyUuid, req.user.id]
);
if (keyDetails.length === 0) {
return res.redirect(
"/dashboard/api-keys?errorMessage=API Key not found or you do not have permission to revoke it."
);
}
await pool.query("DELETE FROM api_keys WHERE uuid = ? AND user_id = ?", [
apiKeyUuid,
req.user.id,
]);
console.log(
`API Key revoked: UUID ${apiKeyUuid}, Name: ${keyDetails[0].key_name} by user ${req.user.id}`
);
res.redirect(
"/dashboard/api-keys?successMessage=API Key revoked successfully."
);
} catch (error) {
console.error(
"Error revoking API key:",
apiKeyUuid,
"user:",
req.user.id,
error
);
res.redirect(
"/dashboard/api-keys?errorMessage=Error revoking API key. Please try again."
);
}
});
module.exports = router;

214
src/routes/public.js Normal file
View File

@ -0,0 +1,214 @@
const express = require("express");
const pool = require("../config/database");
const { sendNtfyNotification } = require("../services/notification");
const { sendSubmissionNotification } = require("../services/emailService");
const { verifyRecaptchaV2 } = require("../utils/recaptchaHelper");
const {
createSubmissionRateLimiter,
createFormSpecificRateLimiter,
createStrictRateLimiter,
} = require("../middleware/redisRateLimiter");
const domainChecker = require("../middleware/domainChecker");
const router = express.Router();
// Initialize rate limiters
const submissionRateLimit = createSubmissionRateLimiter();
const formSpecificRateLimit = createFormSpecificRateLimiter();
const strictRateLimit = createStrictRateLimiter();
router.get("/health", (req, res) => res.status(200).json({ status: "ok" }));
router.post(
"/submit/:formUuid",
strictRateLimit, // First layer: strict per-IP rate limit across all forms
submissionRateLimit, // Second layer: general submission rate limit per IP
formSpecificRateLimit, // Third layer: specific form+IP rate limit
domainChecker,
async (req, res) => {
const { formUuid } = req.params;
const submissionData = { ...req.body };
const ipAddress = req.ip;
// Extract reCAPTCHA response from submission data
const recaptchaToken = submissionData["g-recaptcha-response"];
// Clean it from submissionData so it's not stored in DB or shown in notifications
delete submissionData["g-recaptcha-response"];
// Honeypot check (early exit)
if (submissionData.honeypot_field && submissionData.honeypot_field !== "") {
console.log(
`Honeypot triggered for ${formUuid} by IP ${ipAddress}. Ignoring submission.`
);
if (submissionData._thankyou) {
return res.redirect(submissionData._thankyou);
}
return res.send(
"<h1>Thank You!</h1><p>Your submission has been received.</p>"
);
}
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return res.send(safeMessage);
}
if (submissionData._thankyou) {
return res.redirect(submissionData._thankyou);
}
res.send(
'<h1>Thank You!</h1><p>Your submission has been received.</p><p><a href="/">Back to form manager</a></p>'
);
} catch (error) {
console.error("Error processing submission:", error);
await sendNtfyNotification(
`Submission Error: ${formNameForNotification}`,
`Failed to process submission for ${formUuid} from IP ${ipAddress}. Error: ${error.message}`,
"max"
);
res.status(500).send("Error processing submission.");
}
}
);
module.exports = router;

View File

@ -0,0 +1,450 @@
const nodemailer = require("nodemailer");
require("dotenv").config(); // Ensure environment variables are loaded
const { Resend } = require("resend");
const logger = require("../../config/logger"); // Adjust path as needed
const resendApiKey = process.env.RESEND_API_KEY;
const emailFromAddress = process.env.EMAIL_FROM_ADDRESS;
if (!resendApiKey) {
logger.warn(
"RESEND_API_KEY is not set. Email notifications will be disabled."
);
}
if (!emailFromAddress) {
logger.warn(
"EMAIL_FROM_ADDRESS is not set. Email notifications may not work correctly."
);
}
const resend = resendApiKey ? new Resend(resendApiKey) : null;
class EmailService {
constructor() {
this.transporter = null;
this.init();
}
async init() {
try {
// Create reusable transporter object using the default SMTP transport
this.transporter = nodemailer.createTransporter({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT || 587,
secure: process.env.SMTP_SECURE === "true", // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// Verify connection configuration
if (this.transporter && process.env.SMTP_HOST) {
await this.transporter.verify();
console.log("Email service initialized successfully");
} else {
console.warn(
"Email service not configured. Set SMTP environment variables."
);
}
} catch (error) {
console.error("Email service initialization failed:", error.message);
this.transporter = null;
}
}
// Check if email service is available
isAvailable() {
return this.transporter !== null;
}
// Send verification email
async sendVerificationEmail(to, firstName, verificationToken) {
if (!this.isAvailable()) {
console.warn("Email service not available. Verification email not sent.");
return false;
}
const verificationUrl = `${process.env.APP_URL}/auth/verify-email?token=${verificationToken}`;
const subject = "Verify Your Email Address - Formies";
const html = this.getVerificationEmailTemplate(firstName, verificationUrl);
try {
await this.transporter.sendMail({
from: `"Formies" <${
process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER
}>`,
to,
subject,
html,
});
console.log(`Verification email sent to ${to}`);
return true;
} catch (error) {
console.error("Failed to send verification email:", error);
return false;
}
}
// Send password reset email
async sendPasswordResetEmail(to, firstName, resetToken) {
if (!this.isAvailable()) {
console.warn(
"Email service not available. Password reset email not sent."
);
return false;
}
const resetUrl = `${process.env.APP_URL}/auth/reset-password?token=${resetToken}`;
const subject = "Password Reset Request - Formies";
const html = this.getPasswordResetEmailTemplate(firstName, resetUrl);
try {
await this.transporter.sendMail({
from: `"Formies" <${
process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER
}>`,
to,
subject,
html,
});
console.log(`Password reset email sent to ${to}`);
return true;
} catch (error) {
console.error("Failed to send password reset email:", error);
return false;
}
}
// Send welcome email
async sendWelcomeEmail(to, firstName) {
if (!this.isAvailable()) {
console.warn("Email service not available. Welcome email not sent.");
return false;
}
const subject = "Welcome to Formies!";
const html = this.getWelcomeEmailTemplate(firstName);
try {
await this.transporter.sendMail({
from: `"Formies" <${
process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER
}>`,
to,
subject,
html,
});
console.log(`Welcome email sent to ${to}`);
return true;
} catch (error) {
console.error("Failed to send welcome email:", error);
return false;
}
}
// Send password changed notification
async sendPasswordChangedEmail(to, firstName) {
if (!this.isAvailable()) {
return false;
}
const subject = "Password Changed Successfully - Formies";
const html = this.getPasswordChangedEmailTemplate(firstName);
try {
await this.transporter.sendMail({
from: `"Formies" <${
process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER
}>`,
to,
subject,
html,
});
return true;
} catch (error) {
console.error("Failed to send password changed email:", error);
return false;
}
}
// Email templates
getVerificationEmailTemplate(firstName, verificationUrl) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9f9f9; }
.button {
display: inline-block;
padding: 12px 30px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Formies!</h1>
</div>
<div class="content">
<h2>Hi ${firstName || "there"},</h2>
<p>Thank you for signing up for Formies! To complete your registration, please verify your email address by clicking the button below:</p>
<p style="text-align: center;">
<a href="${verificationUrl}" class="button">Verify Email Address</a>
</p>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; background: #eee; padding: 10px;">${verificationUrl}</p>
<p><strong>This link will expire in 24 hours.</strong></p>
<p>If you didn't create an account with Formies, you can safely ignore this email.</p>
</div>
<div class="footer">
<p>© 2024 Formies. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
getPasswordResetEmailTemplate(firstName, resetUrl) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #dc3545; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9f9f9; }
.button {
display: inline-block;
padding: 12px 30px;
background: #dc3545;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Password Reset Request</h1>
</div>
<div class="content">
<h2>Hi ${firstName || "there"},</h2>
<p>We received a request to reset your password for your Formies account. If you made this request, click the button below to reset your password:</p>
<p style="text-align: center;">
<a href="${resetUrl}" class="button">Reset Password</a>
</p>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; background: #eee; padding: 10px;">${resetUrl}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request a password reset, you can safely ignore this email. Your password won't be changed.</p>
</div>
<div class="footer">
<p>© 2024 Formies. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
getWelcomeEmailTemplate(firstName) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #28a745; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9f9f9; }
.button {
display: inline-block;
padding: 12px 30px;
background: #28a745;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Formies!</h1>
</div>
<div class="content">
<h2>Hi ${firstName || "there"},</h2>
<p>Welcome to Formies! Your email has been verified and your account is now active.</p>
<p>You can now start creating beautiful forms and collecting submissions. Here's what you can do:</p>
<ul>
<li>Create unlimited forms</li>
<li>Customize form fields and styling</li>
<li>Receive instant notifications</li>
<li>Export your data anytime</li>
</ul>
<p style="text-align: center;">
<a href="${
process.env.APP_URL
}/dashboard" class="button">Go to Dashboard</a>
</p>
</div>
<div class="footer">
<p>© 2024 Formies. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
getPasswordChangedEmailTemplate(firstName) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #17a2b8; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9f9f9; }
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Password Changed</h1>
</div>
<div class="content">
<h2>Hi ${firstName || "there"},</h2>
<p>This email confirms that your password has been successfully changed for your Formies account.</p>
<p><strong>If you didn't make this change, please contact our support team immediately.</strong></p>
<p>For your security, here are some tips:</p>
<ul>
<li>Use a strong, unique password</li>
<li>Don't share your password with anyone</li>
<li>Consider using a password manager</li>
</ul>
</div>
<div class="footer">
<p>© 2024 Formies. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
}
}
/**
* 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 = `<p>You have a new submission for your form: <strong>${formName}</strong>.</p>`;
body += "<p>Here are the details:</p><ul>";
for (const [key, value] of Object.entries(submissionData)) {
// Exclude honeypot and other internal fields if necessary
if (key.toLowerCase() !== "honeypot_field" && key !== "_thankyou") {
body += `<li><strong>${key}:</strong> ${value}</li>`;
}
}
body += "</ul><p>Thank you for using Formies!</p>";
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
};

272
src/services/jwtService.js Normal file
View File

@ -0,0 +1,272 @@
const jwt = require("jsonwebtoken");
const { v4: uuidv4 } = require("uuid");
const User = require("../models/User");
class JWTService {
constructor() {
this.secret = process.env.JWT_SECRET;
this.issuer = process.env.JWT_ISSUER || "formies";
this.audience = process.env.JWT_AUDIENCE || "formies-users";
this.accessTokenExpiry = process.env.JWT_ACCESS_EXPIRY || "15m";
this.refreshTokenExpiry = process.env.JWT_REFRESH_EXPIRY || "7d";
if (!this.secret) {
throw new Error("JWT_SECRET environment variable is required");
}
}
// Generate access token
generateAccessToken(user, sessionInfo = {}) {
const jti = uuidv4(); // JWT ID for token tracking
const payload = {
sub: user.id, // Subject (user ID)
email: user.email,
role: user.role,
jti: jti,
type: "access",
};
const options = {
issuer: this.issuer,
audience: this.audience,
expiresIn: this.accessTokenExpiry,
};
const token = jwt.sign(payload, this.secret, options);
const decoded = jwt.decode(token);
// Save session for token tracking
const expiresAt = new Date(decoded.exp * 1000);
User.saveSession(
user.id,
jti,
expiresAt,
sessionInfo.userAgent,
sessionInfo.ipAddress
).catch(console.error);
return {
token,
expiresAt,
jti,
};
}
// Generate refresh token
generateRefreshToken(user, sessionInfo = {}) {
const jti = uuidv4();
const payload = {
sub: user.id,
jti: jti,
type: "refresh",
};
const options = {
issuer: this.issuer,
audience: this.audience,
expiresIn: this.refreshTokenExpiry,
};
const token = jwt.sign(payload, this.secret, options);
const decoded = jwt.decode(token);
// Save session for token tracking
const expiresAt = new Date(decoded.exp * 1000);
User.saveSession(
user.id,
jti,
expiresAt,
sessionInfo.userAgent,
sessionInfo.ipAddress
).catch(console.error);
return {
token,
expiresAt,
jti,
};
}
// Generate token pair (access + refresh)
generateTokenPair(user, sessionInfo = {}) {
const accessToken = this.generateAccessToken(user, sessionInfo);
const refreshToken = this.generateRefreshToken(user, sessionInfo);
return {
accessToken: accessToken.token,
refreshToken: refreshToken.token,
accessTokenExpiresAt: accessToken.expiresAt,
refreshTokenExpiresAt: refreshToken.expiresAt,
tokenType: "Bearer",
};
}
// Verify and decode token
verifyToken(token, tokenType = "access") {
try {
const options = {
issuer: this.issuer,
audience: this.audience,
};
const decoded = jwt.verify(token, this.secret, options);
// Check token type
if (decoded.type !== tokenType) {
throw new Error(`Invalid token type. Expected ${tokenType}`);
}
return decoded;
} catch (error) {
if (error.name === "TokenExpiredError") {
throw new Error("Token has expired");
} else if (error.name === "JsonWebTokenError") {
throw new Error("Invalid token");
} else if (error.name === "NotBeforeError") {
throw new Error("Token not active yet");
}
throw error;
}
}
// Refresh access token using refresh token
async refreshAccessToken(refreshToken, sessionInfo = {}) {
try {
// Verify refresh token
const decoded = this.verifyToken(refreshToken, "refresh");
// Check if token is blacklisted
const isBlacklisted = await User.isTokenBlacklisted(decoded.jti);
if (isBlacklisted) {
throw new Error("Refresh token has been revoked");
}
// Get user
const user = await User.findById(decoded.sub);
if (!user || !user.is_active) {
throw new Error("User not found or inactive");
}
// Generate new access token
const newAccessToken = this.generateAccessToken(user, sessionInfo);
return {
accessToken: newAccessToken.token,
accessTokenExpiresAt: newAccessToken.expiresAt,
tokenType: "Bearer",
};
} catch (error) {
throw error;
}
}
// Revoke token (add to blacklist)
async revokeToken(token) {
try {
const decoded = jwt.decode(token);
if (!decoded || !decoded.jti) {
throw new Error("Invalid token format");
}
await User.revokeSession(decoded.jti);
return true;
} catch (error) {
console.error("Error revoking token:", error);
return false;
}
}
// Revoke all user tokens
async revokeAllUserTokens(userId) {
try {
const revokedCount = await User.revokeAllUserSessions(userId);
return revokedCount;
} catch (error) {
console.error("Error revoking all user tokens:", error);
return 0;
}
}
// Revoke all user tokens except one
async revokeAllUserTokensExcept(userId, exceptJti) {
try {
const revokedCount = await User.revokeAllUserSessionsExcept(
userId,
exceptJti
);
return revokedCount;
} catch (error) {
console.error("Error revoking user tokens:", error);
return 0;
}
}
// Extract token from Authorization header
extractTokenFromHeader(authHeader) {
if (!authHeader) {
return null;
}
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
}
return parts[1];
}
// Get token info without verification
getTokenInfo(token) {
try {
return jwt.decode(token);
} catch (error) {
return null;
}
}
// Check if token is expired (without verifying signature)
isTokenExpired(token) {
const decoded = this.getTokenInfo(token);
if (!decoded || !decoded.exp) {
return true;
}
return Date.now() >= decoded.exp * 1000;
}
// Cleanup expired sessions (call this periodically)
async cleanupExpiredSessions() {
try {
const cleanedCount = await User.cleanupExpiredSessions();
console.log(`Cleaned up ${cleanedCount} expired sessions`);
return cleanedCount;
} catch (error) {
console.error("Error cleaning up expired sessions:", error);
return 0;
}
}
// Get current session information
async getCurrentSession(token) {
try {
const decoded = this.verifyToken(token);
const session = await User.getSessionByJti(decoded.jti);
if (!session) {
throw new Error("Session not found");
}
return {
jti: session.token_jti,
userAgent: session.user_agent,
ipAddress: session.ip_address,
createdAt: session.created_at,
expiresAt: session.expires_at,
};
} catch (error) {
throw error;
}
}
}
module.exports = new JWTService();

View File

@ -0,0 +1,31 @@
async function sendNtfyNotification(
title,
message,
priority = "default",
tags = ""
) {
if (process.env.NTFY_ENABLED !== "true" || !process.env.NTFY_TOPIC_URL) {
return;
}
try {
const response = await fetch(process.env.NTFY_TOPIC_URL, {
method: "POST",
body: message,
headers: {
Title: title,
Priority: priority,
Tags: tags,
"Content-Type": "text/plain",
},
});
if (!response.ok) {
console.error(`Ntfy error: ${response.status} ${await response.text()}`);
} else {
console.log("Ntfy notification sent successfully.");
}
} catch (error) {
console.error("Failed to send Ntfy notification:", error);
}
}
module.exports = { sendNtfyNotification };

51
src/utils/apiKeyHelper.js Normal file
View File

@ -0,0 +1,51 @@
const crypto = require("crypto");
const bcrypt = require("bcryptjs");
const API_KEY_IDENTIFIER_PREFIX = "fsk"; // Formies Secret Key
const API_KEY_IDENTIFIER_LENGTH = 12; // Length of the random part of the identifier
const API_KEY_SECRET_LENGTH = 32; // Length of the secret part in bytes, results in 2x hex string length
/**
* Generates a new API key parts: the full key (to show to user once) and its components for storage.
* Identifier: A public, non-secret unique string for lookup (e.g., 'fsk_abcdef123').
* Secret: A cryptographically strong random string.
* Full Key: Identifier + '_' + Secret (this is what the user gets).
* @returns {{ fullApiKey: string, identifier: string, secret: string }}
*/
function generateApiKeyParts() {
const randomIdentifierPart = crypto
.randomBytes(Math.ceil(API_KEY_IDENTIFIER_LENGTH / 2))
.toString("hex")
.slice(0, API_KEY_IDENTIFIER_LENGTH);
const identifier = `${API_KEY_IDENTIFIER_PREFIX}_${randomIdentifierPart}`;
const secret = crypto.randomBytes(API_KEY_SECRET_LENGTH).toString("hex");
const fullApiKey = `${identifier}_${secret}`;
return { fullApiKey, identifier, secret };
}
/**
* Hashes an API key secret using bcrypt.
* @param {string} apiKeySecret - The secret part of the API key.
* @returns {Promise<string>} - 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<boolean>} - 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,
};

View File

@ -0,0 +1,56 @@
// Native fetch is available in Node.js 18+ and doesn't need to be imported
// const logger = require("../../config/logger"); // Adjust path as needed
const RECAPTCHA_V2_SECRET_KEY = process.env.RECAPTCHA_V2_SECRET_KEY;
const GOOGLE_RECAPTCHA_VERIFY_URL =
"https://www.google.com/recaptcha/api/siteverify";
/**
* Verifies a Google reCAPTCHA v2 response.
* @param {string} recaptchaToken - The g-recaptcha-response token from the client.
* @param {string} [clientIp] - Optional. The user's IP address.
* @returns {Promise<boolean>} - 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 };

View File

@ -1 +0,0 @@

356
views/dashboard.ejs Normal file
View File

@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>User Dashboard - Formies</title>
<!-- Basic styling - replace with a proper CSS framework or custom styles later -->
<style>
body {
font-family: sans-serif;
margin: 0;
background-color: #f4f7f6;
}
.navbar {
background-color: #333;
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar a {
color: white;
text-decoration: none;
margin-left: 1rem;
}
.navbar .logo {
font-size: 1.5rem;
font-weight: bold;
}
.container {
padding: 2rem;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header-bar h1 {
margin: 0;
}
.btn {
background-color: #007bff;
color: white;
padding: 0.5rem 1rem;
text-decoration: none;
border-radius: 0.25rem;
border: none;
cursor: pointer;
}
.btn-secondary {
background-color: #6c757d;
}
/* Add more styles as needed */
</style>
</head>
<body>
<nav class="navbar">
<div class="logo"><a href="/dashboard">Formies</a></div>
<div>
<a href="/dashboard">My Forms</a>
<a href="/dashboard/create-form">Create New Form</a>
<a href="/dashboard/api-keys">API Keys</a>
<!-- Placeholder for Account Settings -->
<a href="#">Hi, <%= user ? user.email : 'User' %> &#9662;</a>
</div>
</nav>
<div class="container">
<!-- Main content will be injected or decided by the route -->
<% if (view === 'my_forms') { %>
<div class="header-bar">
<h1>My Forms</h1>
<a href="/dashboard/create-form" class="btn">+ Create New Form</a>
</div>
<%- include('partials/_forms_table', { forms: forms, appUrl: appUrl }) %>
<% } else if (view === 'create_form') { %>
<h1>Create New Form</h1>
<form action="/dashboard/forms/create" method="POST">
<div>
<label for="formName">Form Name:</label>
<input
type="text"
id="formName"
name="formName"
value="<%= typeof formNameValue !== 'undefined' ? formNameValue : 'Untitled Form' %>"
required />
</div>
<button type="submit" class="btn">Create Form</button>
<% if (typeof error !== 'undefined' && error) { %>
<p style="color: red; margin-top: 1rem"><%= error %></p>
<% } %>
</form>
<% } else if (view === 'form_submissions') { %> <%-
include('partials/_submissions_view', { submissions: submissions,
formUuid: formUuid, formName: formName, pagination: pagination, appUrl:
appUrl }) %> <% } else if (view === 'account_settings') { %>
<h1>Account Settings</h1>
<p>Account settings will be here.</p>
<% } else if (view === 'form_settings') { %>
<div class="header-bar">
<h1>
Settings for <span style="font-weight: normal"><%= formName %></span>
</h1>
<a href="/dashboard" class="btn btn-secondary">Back to My Forms</a>
</div>
<% if (typeof successMessage !== 'undefined' && successMessage) { %>
<div
style="
background-color: #d4edda;
color: #155724;
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid #c3e6cb;
border-radius: 0.25rem;
">
<%= successMessage %>
</div>
<% } %> <% if (typeof errorMessage !== 'undefined' && errorMessage) { %>
<div
style="
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid #f5c6cb;
border-radius: 0.25rem;
">
<%= errorMessage %>
</div>
<% } %>
<form
action="/dashboard/forms/<%= formUuid %>/settings/update"
method="POST"
style="
background-color: white;
padding: 1.5rem;
border-radius: 0.25rem;
border: 1px solid #ddd;
margin-bottom: 2rem;
">
<h3 style="margin-top: 0; margin-bottom: 1rem">General Settings</h3>
<div style="margin-bottom: 1rem">
<label
for="formNameInput"
style="display: block; margin-bottom: 0.5rem"
>Form Name:</label
>
<input
type="text"
id="formNameInput"
name="formName"
value="<%= currentFormName %>"
required
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
" />
</div>
<h3 style="margin-top: 2rem; margin-bottom: 1rem">Email Notifications</h3>
<div style="margin-bottom: 1rem">
<input type="checkbox" id="emailNotificationsEnabled"
name="emailNotificationsEnabled" value="on" <%=
currentEmailNotificationsEnabled ? 'checked' : '' %>
style="margin-right: 0.5rem;">
<label for="emailNotificationsEnabled"
>Enable Email Notifications for new submissions</label
>
</div>
<div style="margin-bottom: 1rem">
<label
for="notificationEmailAddress"
style="display: block; margin-bottom: 0.5rem"
>Notification Email Address:</label
>
<input
type="email"
id="notificationEmailAddress"
name="notificationEmailAddress"
value="<%= currentNotificationEmailAddress || '' %>"
placeholder="Defaults to your account email (<%= user.email %>)"
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
" />
<small style="display: block; margin-top: 0.25rem; color: #6c757d"
>If left blank, notifications will be sent to your account email:
<%= user.email %></small
>
</div>
<h3 style="margin-top: 2rem; margin-bottom: 1rem">Spam Protection</h3>
<div style="margin-bottom: 1rem">
<input type="checkbox" id="recaptchaEnabled"
name="recaptchaEnabled" value="on" <%=
currentRecaptchaEnabled ? 'checked' : '' %>
style="margin-right: 0.5rem;">
<label for="recaptchaEnabled"
>Enable Google reCAPTCHA v2 ("I'm not a robot")</label
>
<small style="display: block; margin-top: 0.25rem; color: #6c757d"
>Uses the globally configured site and secret keys. Ensure these are set in your server's .env file.</small
>
</div>
<h3 style="margin-top: 2rem; margin-bottom: 1rem">Thank You Page</h3>
<div style="margin-bottom: 1rem;">
<label for="thankYouUrl" style="display: block; margin-bottom: 0.5rem;">Thank You URL (Optional):</label>
<input
type="url"
id="thankYouUrl"
name="thankYouUrl"
value="<%= currentThankYouUrl || '' %>"
placeholder="e.g., https://example.com/thank-you"
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
" />
</div>
<div style="margin-bottom: 1rem;">
<label for="thankYouMessage" style="display: block; margin-bottom: 0.5rem;">Custom Thank You Message (Optional):</label>
<textarea
id="thankYouMessage"
name="thankYouMessage"
rows="3"
placeholder="e.g., Thanks for your submission!"
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
"><%= currentThankYouMessage || '' %></textarea>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
If a "Thank You URL" is provided, it will be used. Otherwise, this custom message will be shown. If both are blank, a default message is used.
</small>
</div>
<h3 style="margin-top: 2rem; margin-bottom: 1rem">Allowed Domains</h3>
<div style="margin-bottom: 1rem;">
<label for="allowedDomains" style="display: block; margin-bottom: 0.5rem;">Allowed Domains:</label>
<textarea
id="allowedDomains"
name="allowedDomains"
rows="3"
placeholder="e.g., example.com, app.example.com"
style="
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 0.25rem;
"><%= currentAllowedDomains || '' %></textarea>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
Comma-separated list of domains. Leave blank to allow submissions from any domain.
</small>
</div>
<button type="submit" class="btn" style="margin-top: 1.5rem;">Save All Settings</button>
</form>
<% } else if (view === 'api_keys') { %>
<div class="header-bar">
<h1>API Keys</h1>
</div>
<% if (typeof successMessage !== 'undefined' && successMessage) { %>
<div style="background-color: #d4edda; color: #155724; padding: 0.75rem 1.25rem; margin-bottom: 1rem; border: 1px solid #c3e6cb; border-radius: 0.25rem;">
<%= successMessage %>
</div>
<% } %>
<% if (typeof errorMessage !== 'undefined' && errorMessage) { %>
<div style="background-color: #f8d7da; color: #721c24; padding: 0.75rem 1.25rem; margin-bottom: 1rem; border: 1px solid #f5c6cb; border-radius: 0.25rem;">
<%= errorMessage %>
</div>
<% } %>
<% if (typeof newlyGeneratedApiKey !== 'undefined' && newlyGeneratedApiKey) { %>
<div style="background-color: #fff3cd; color: #856404; padding: 1rem; margin-bottom: 1.5rem; border: 1px solid #ffeeba; border-radius: 0.25rem;">
<h3 style="margin-top:0;">New API Key Generated: <%= newlyGeneratedApiKeyName %></h3>
<p><strong>Important:</strong> This is the only time you will see this API key. Copy it now and store it securely.</p>
<pre style="background-color: #e9ecef; padding: 0.5rem; border-radius: 0.25rem; word-break: break-all;"><code id="newApiKey"><%= newlyGeneratedApiKey %></code></pre>
<button onclick="copyApiKeyToClipboard()" class="btn btn-secondary" style="margin-top:0.5rem;">Copy to Clipboard</button>
</div>
<% } %>
<div style="background-color: white; padding: 1.5rem; border-radius: 0.25rem; border: 1px solid #ddd; margin-bottom: 2rem;">
<h3 style="margin-top: 0; margin-bottom: 1rem">Generate New API Key</h3>
<form action="/dashboard/api-keys/generate" method="POST">
<div style="margin-bottom: 1rem;">
<label for="keyName" style="display: block; margin-bottom: 0.5rem;">Key Name (for your reference):</label>
<input type="text" id="keyName" name="keyName" required style="width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 0.25rem;">
</div>
<button type="submit" class="btn">Generate Key</button>
</form>
</div>
<div style="background-color: white; padding: 1.5rem; border-radius: 0.25rem; border: 1px solid #ddd;">
<h3 style="margin-top: 0; margin-bottom: 1rem">Your API Keys</h3>
<% if (apiKeys && apiKeys.length > 0) { %>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 1px solid #dee2e6;">
<th style="padding: 0.75rem;">Name</th>
<th style="padding: 0.75rem;">Identifier (Prefix)</th>
<th style="padding: 0.75rem;">Created At</th>
<th style="padding: 0.75rem;">Last Used</th>
<th style="padding: 0.75rem;">Actions</th>
</tr>
</thead>
<tbody>
<% apiKeys.forEach(key => { %>
<tr style="border-bottom: 1px solid #f1f1f1;">
<td style="padding: 0.75rem;"><%= key.key_name %></td>
<td style="padding: 0.75rem;"><%= key.api_key_identifier %></td>
<td style="padding: 0.75rem;"><%= new Date(key.created_at).toLocaleDateString() %></td>
<td style="padding: 0.75rem;"><%= key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' %></td>
<td style="padding: 0.75rem;">
<form action="/dashboard/api-keys/<%= key.uuid %>/revoke" method="POST" onsubmit="return confirm('Are you sure you want to revoke this API key? This cannot be undone.');" style="display: inline;">
<button type="submit" class="btn btn-secondary" style="background-color: #dc3545;">Revoke</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } else { %>
<p>You have not generated any API keys yet.</p>
<% } %>
</div>
<% } %>
</div>
<script>
// Client-side JS for dynamic interactions will go here
function copyApiKeyToClipboard() {
const apiKeyText = document.getElementById('newApiKey').innerText;
navigator.clipboard.writeText(apiKeyText).then(() => {
alert('API Key copied to clipboard!');
}).catch(err => {
console.error('Failed to copy API key: ', err);
alert('Failed to copy API key. Please copy it manually.');
});
}
</script>
</body>
</html>

View File

@ -0,0 +1,51 @@
<% if (forms && forms.length > 0) { %>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #eee;">
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Form Name</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Submissions</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Endpoint URL</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Created Date</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Status</th>
<th style="padding: 0.5rem; border: 1px solid #ddd; text-align: left;">Actions</th>
</tr>
</thead>
<tbody>
<% forms.forEach(form => { %>
<tr>
<td style="padding: 0.5rem; border: 1px solid #ddd;"><%= form.name %></td>
<td style="padding: 0.5rem; border: 1px solid #ddd;"><%= form.submission_count %></td>
<td style="padding: 0.5rem; border: 1px solid #ddd;">
<code><%= appUrl %>/submit/<%= form.uuid %></code>
<button onclick="copyToClipboard('<%= appUrl %>/submit/<%= form.uuid %>')">Copy</button>
</td>
<td style="padding: 0.5rem; border: 1px solid #ddd;"><%= new Date(form.created_at).toLocaleDateString() %></td>
<td style="padding: 0.5rem; border: 1px solid #ddd;"><%= form.is_archived ? 'Archived' : 'Active' %></td>
<td style="padding: 0.5rem; border: 1px solid #ddd;">
<a href="/dashboard/submissions/<%= form.uuid %>" class="btn btn-secondary">View Submissions</a>
<a href="/dashboard/forms/<%= form.uuid %>/settings" class="btn btn-secondary">Settings</a>
<!-- Add Archive/Delete buttons/forms here -->
<form action="/dashboard/forms/<%= form.is_archived ? 'unarchive' : 'archive' %>/<%= form.uuid %>" method="POST" style="display: inline;">
<button type="submit" class="btn btn-secondary"><%= form.is_archived ? 'Unarchive' : 'Archive' %></button>
</form>
<form action="/dashboard/forms/delete/<%= form.uuid %>" method="POST" style="display: inline;" onsubmit="return confirm('Are you sure you want to permanently delete this form and all its submissions?');">
<button type_submit" class="btn btn-danger">Delete</button>
</form>
</td>
</tr>
<% }); %>
</tbody>
</table>
<% } else { %>
<p>You haven't created any forms yet. <a href="/dashboard/create-form">Create one now!</a></p>
<% } %>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
alert('Endpoint URL copied to clipboard!');
}, function(err) {
alert('Could not copy text: ', err);
});
}
</script>

View File

@ -0,0 +1,176 @@
<div class="header-bar">
<h1>
Submissions for <span style="font-weight: normal"><%= formName %></span>
</h1>
<div>
<a href="/dashboard" class="btn btn-secondary">Back to My Forms</a>
<a href="/dashboard/submissions/<%= formUuid %>/export" class="btn"
>Export CSV</a
>
</div>
</div>
<% if (submissions.length === 0) { %>
<div
style="
padding: 1rem;
background-color: #e9ecef;
border-radius: 0.25rem;
text-align: center;
">
No submissions yet for this form.
</div>
<% } else { %> <% submissions.forEach(submission => { %>
<div
style="
border: 1px solid #ddd;
border-radius: 0.25rem;
margin-bottom: 1rem;
background-color: white;
">
<div style="padding: 1rem">
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
">
<div>
<h6 style="margin: 0 0 0.25rem 0; color: #6c757d">
Submitted: <%= new Date(submission.submitted_at).toLocaleString() %>
</h6>
<h6 style="margin: 0; color: #6c757d">
IP: <%= submission.ip_address %>
</h6>
</div>
<form
action="/dashboard/submissions/delete/<%= submission.id %>"
method="POST"
style="display: inline"
onsubmit="return confirm('Are you sure you want to delete this submission?');">
<input
type="hidden"
name="formUuidForRedirect"
value="<%= formUuid %>" />
<button
type="submit"
class="btn btn-secondary"
style="background-color: #dc3545; border-color: #dc3545">
Delete
</button>
</form>
</div>
<hr style="margin-top: 0.5rem; margin-bottom: 1rem" />
<div>
<% let data = {}; try { data = JSON.parse(submission.data); } catch (e) {
console.error("Failed to parse submission data:", submission.data); data =
{ "error": "Could not parse submission data" }; } %> <%
Object.entries(data).forEach(([key, value]) => { %> <% if (key !==
'honeypot_field' && key !== '_thankyou') { %>
<div style="margin-bottom: 0.5rem">
<strong
style="display: inline-block; min-width: 120px; vertical-align: top"
><%= key %>:</strong
>
<span style="white-space: pre-wrap; word-break: break-all"
><%= value %></span
>
</div>
<% } %> <% }); %>
</div>
</div>
</div>
<% }); %>
<!-- Pagination -->
<% if (pagination.totalPages > 1) { %>
<nav
aria-label="Submissions pagination"
style="margin-top: 2rem; margin-bottom: 2rem">
<ul
style="
display: flex;
justify-content: center;
padding-left: 0;
list-style: none;
">
<% if (pagination.currentPage > 1) { %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=<%= pagination.currentPage - 1 %>&limit=<%= pagination.limit %>"
class="btn btn-secondary"
>Previous</a
>
</li>
<% } else { %>
<li style="margin: 0 0.25rem">
<span
class="btn btn-secondary"
style="opacity: 0.65; pointer-events: none"
>Previous</span
>
</li>
<% } %> <% const maxPagesToShow = 5; let startPage = Math.max(1,
pagination.currentPage - Math.floor(maxPagesToShow / 2)); let endPage =
Math.min(pagination.totalPages, startPage + maxPagesToShow - 1); if (endPage
- startPage + 1 < maxPagesToShow) { startPage = Math.max(1, endPage -
maxPagesToShow + 1); } %> <% if (startPage > 1) { %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=1&limit=<%= pagination.limit %>"
class="btn btn-secondary"
>1</a
>
</li>
<% if (startPage > 2) { %>
<li style="margin: 0 0.25rem">
<span class="btn btn-secondary" style="pointer-events: none">...</span>
</li>
<% } %> <% } %> <% for(let i = startPage; i <= endPage; i++) { %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=<%= i %>&limit=<%= pagination.limit %>"
class="btn <%= i === pagination.currentPage ? '' : 'btn-secondary' %>"
><%= i %></a
>
</li>
<% } %> <% if (endPage < pagination.totalPages) { %> <% if (endPage <
pagination.totalPages - 1) { %>
<li style="margin: 0 0.25rem">
<span class="btn btn-secondary" style="pointer-events: none">...</span>
</li>
<% } %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=<%= pagination.totalPages %>&limit=<%= pagination.limit %>"
class="btn btn-secondary"
><%= pagination.totalPages %></a
>
</li>
<% } %> <% if (pagination.currentPage < pagination.totalPages) { %>
<li style="margin: 0 0.25rem">
<a
href="/dashboard/submissions/<%= formUuid %>?page=<%= pagination.currentPage + 1 %>&limit=<%= pagination.limit %>"
class="btn btn-secondary"
>Next</a
>
</li>
<% } else { %>
<li style="margin: 0 0.25rem">
<span
class="btn btn-secondary"
style="opacity: 0.65; pointer-events: none"
>Next</span
>
</li>
<% } %>
</ul>
</nav>
<div style="text-align: center; color: #6c757d">
Showing <%= (pagination.currentPage - 1) * pagination.limit + 1 %> to <%=
Math.min(pagination.currentPage * pagination.limit,
pagination.totalSubmissions) %> of <%= pagination.totalSubmissions %>
submissions
</div>
<% } %> <% } %>