Compare commits

...

3 Commits
main ... 0605

Author SHA1 Message Date
Mohamad.Elsena
a3236ae9d5 Refactor environment configuration for PostgreSQL and enhance application structure
- Updated `.env` and `.env.test` files to include PostgreSQL connection settings and Redis configuration.
- Migrated database from SQLite to PostgreSQL, updating relevant queries and connection logic.
- Enhanced error handling and logging throughout the application.
- Added new test utilities for PostgreSQL integration and updated user model methods.
- Introduced new routes for user authentication and form management, ensuring compatibility with the new database structure.
- Created login and registration views in EJS for user interaction.
2025-05-28 16:16:33 +02:00
Mohamad.Elsena
2927013a6d 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.
2025-05-28 11:18:35 +02:00
Mohamad
1b012b3923 0605 2025-05-06 12:21:01 +02:00
71 changed files with 14741 additions and 8538 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.

38
.env
View File

@ -1,4 +1,34 @@
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
DATABASE_URL=postgresql://formies_owner:npg_VtO2HSgGnI9J@ep-royal-scene-a2961c60-pooler.eu-central-1.aws.neon.tech/formies?sslmode=require
# Redis
REDIS_HOST=your_production_redis_host_or_service_name # e.g., the service name in docker-compose.prod.yml like 'redis'
REDIS_PORT=6379 # Or your production Redis port if different
REDIS_PASSWORD=your_production_redis_password # Ensure this is set for production
# Application specific
NODE_ENV=production
PORT=3000 # Or your desired production port
# Security - VERY IMPORTANT: Use strong, unique secrets for production
SESSION_SECRET=generate_a_very_strong_random_string_for_session_secret
JWT_SECRET=generate_a_very_strong_random_string_for_jwt_secret

32
.env.test Normal file
View File

@ -0,0 +1,32 @@
# .env.test
NODE_ENV=test
PORT=3001 # Different port for test server
DB_HOST=localhost
DB_USER=your_test_pg_user
DB_PASSWORD=your_test_pg_password
DB_NAME=formies_test_db # CRITICAL: MUST BE A TEST DATABASE
DB_PORT=5432
JWT_SECRET=a_different_test_secret_key_that_is_very_long_and_secure
JWT_ISSUER=formies-test
JWT_AUDIENCE=formies-users-test
JWT_ACCESS_EXPIRY=5s
JWT_REFRESH_EXPIRY=10s
SESSION_SECRET=another_test_session_secret
APP_URL=http://localhost:3001
# Mocked or test service creds
RESEND_API_KEY=test_resend_key # For email service mocking
EMAIL_FROM_ADDRESS=test@formies.local
NTFY_ENABLED=false
RECAPTCHA_V2_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MM_sF2s_ # Google's test site key
RECAPTCHA_V2_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe # Google's test secret key
REDIS_HOST=localhost
REDIS_PORT=6379 # Assuming test Redis runs on default port
REDIS_PASSWORD=

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.

4102
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +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"

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

149
README.md
View File

@ -1,149 +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
## Development
1. Clone the repository
2. Install dependencies:
```bash
cargo build
```
3. Set up environment variables:
```bash
cp .env.example .env
# Edit .env with your configuration
```
4. Run the development server:
```bash
cargo run
```
## Production Deployment
### Docker
1. Build the Docker image:
```bash
docker build -t formies-backend .
```
2. Run the container:
```bash
docker run -d \
--name formies-backend \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-e DATABASE_URL=/app/data/form_data.db \
-e BIND_ADDRESS=0.0.0.0:8080 \
-e INITIAL_ADMIN_USERNAME=admin \
-e INITIAL_ADMIN_PASSWORD=your-secure-password \
-e ALLOWED_ORIGIN=https://your-frontend-domain.com \
-e SENTRY_DSN=your-sentry-dsn \
formies-backend
```
### Systemd Service
1. Create a systemd service file at `/etc/systemd/system/formies-backend.service`:
```ini
[Unit]
Description=Formies Backend Service
After=network.target
[Service]
Type=simple
User=formies
WorkingDirectory=/opt/formies-backend
ExecStart=/opt/formies-backend/formies-be
Restart=always
Environment=DATABASE_URL=/opt/formies-backend/data/form_data.db
Environment=BIND_ADDRESS=0.0.0.0:8080
Environment=INITIAL_ADMIN_USERNAME=admin
Environment=INITIAL_ADMIN_PASSWORD=your-secure-password
Environment=ALLOWED_ORIGIN=https://your-frontend-domain.com
Environment=SENTRY_DSN=your-sentry-dsn
[Install]
WantedBy=multi-user.target
```
2. Enable and start the service:
```bash
sudo systemctl enable formies-backend
sudo systemctl start formies-backend
```
## Monitoring
### Health Check
The application exposes a health check endpoint at `/api/health`:
```bash
curl http://localhost:8080/api/health
```
### Metrics
Metrics are available at `/metrics` when enabled in the configuration.
### Logging
Logs are written to the configured log file and can be viewed using:
```bash
tail -f logs/app.log
```
## Security
- All API endpoints are rate-limited
- CORS is configured to only allow specified origins
- JWT tokens are used for authentication
- Passwords are hashed using bcrypt
- SQLite database is protected with proper file permissions
## License
MIT

View File

@ -0,0 +1,197 @@
// __tests__/integration/auth.test.js
const request = require("supertest");
const app = require("../../../server"); // Adjust path to your Express app
const { pool, clearAllTables } = require("../../setup/testDbUtils"); // Adjust path
const User = require("../../../src/models/User"); // Adjust path
describe("Auth API Endpoints", () => {
let server;
beforeAll(() => {
// If your app directly listens, you might not need this.
// If app is just exported, supertest handles starting/stopping.
// server = app.listen(process.env.PORT || 3001); // Use test port
});
afterAll(async () => {
// if (server) server.close();
// await pool.end(); // Already handled by global teardown
});
beforeEach(async () => {
await clearAllTables();
});
describe("POST /api/auth/register", () => {
it("should register a new user successfully", async () => {
const res = await request(app).post("/api/auth/register").send({
email: "newuser@example.com",
password: "Password123!",
first_name: "New",
last_name: "User",
});
expect(res.statusCode).toEqual(201);
expect(res.body.success).toBe(true);
expect(res.body.data.user.email).toBe("newuser@example.com");
const dbUser = await User.findByEmail("newuser@example.com");
expect(dbUser).toBeDefined();
});
it("should return 409 if email already exists", async () => {
await User.create({
email: "existing@example.com",
password: "Password123!",
});
const res = await request(app)
.post("/api/auth/register")
.send({ email: "existing@example.com", password: "Password123!" });
expect(res.statusCode).toEqual(409);
expect(res.body.message).toContain("already exists");
});
// ... more registration tests (validation, etc.)
});
describe("POST /api/auth/login", () => {
let testUser;
beforeEach(async () => {
testUser = await User.create({
email: "login@example.com",
password: "Password123!",
is_verified: 1, // Mark as verified for login
});
});
it("should login an existing verified user and return tokens", async () => {
const res = await request(app)
.post("/api/auth/login")
.send({ email: "login@example.com", password: "Password123!" });
expect(res.statusCode).toEqual(200);
expect(res.body.success).toBe(true);
expect(res.body.data.accessToken).toBeDefined();
expect(res.body.data.refreshToken).toBeDefined();
expect(res.body.data.user.email).toBe("login@example.com");
});
it("should return 401 for invalid credentials", async () => {
const res = await request(app)
.post("/api/auth/login")
.send({ email: "login@example.com", password: "WrongPassword!" });
expect(res.statusCode).toEqual(401);
});
// ... more login tests (unverified, locked, must_change_password for super_admin)
// Example for super_admin must_change_password
it("should return 403 with MUST_CHANGE_PASSWORD for super_admin first login", async () => {
// Ensure the default super_admin exists with must_change_password = TRUE
// and password_hash = 'NEEDS_TO_BE_SET_ON_FIRST_LOGIN'
// This requires the special handling in LocalStrategy as discussed.
// For this test, you might need to manually insert/update the super_admin in testDb.
await pool.query(
`INSERT INTO users (email, password_hash, role, is_verified, is_active, must_change_password, uuid)
VALUES ($1, $2, 'super_admin', TRUE, TRUE, TRUE, $3)
ON CONFLICT (email) DO UPDATE SET password_hash = $2, must_change_password = TRUE`,
[
"admin@formies.local",
"NEEDS_TO_BE_SET_ON_FIRST_LOGIN",
require("uuid").v4(),
]
);
// This also assumes your special login logic for this specific hash exists
const res = await request(app)
.post("/api/auth/login")
.send({ email: "admin@formies.local", password: "anypassword" }); // Password might be ignored by special logic
if (
res.statusCode === 200 &&
res.body?.data?.user?.must_change_password
) {
// This means your special login logic works by issuing a token even if bcrypt would fail,
// and your /login route has a check for user.must_change_password AFTER successful auth by passport.
// The client would then be responsible for triggering the force-change-password flow.
// This is one way to handle it.
console.warn(
"Super admin login with must_change_password=true returned 200, client must handle redirection to force password change."
);
} else {
// The ideal case from previous discussion:
// expect(res.statusCode).toEqual(403);
// expect(res.body.success).toBe(false);
// expect(res.body.code).toBe('MUST_CHANGE_PASSWORD');
// expect(res.body.data.user.email).toBe('admin@formies.local');
// For now, let's check for either the 403, or the 200 with the flag, as implementation details may vary slightly.
expect([200, 403]).toContain(res.statusCode);
if (res.statusCode === 200)
expect(res.body.data.user.must_change_password).toBe(1); // or true
if (res.statusCode === 403)
expect(res.body.code).toBe("MUST_CHANGE_PASSWORD");
}
});
});
describe("POST /api/auth/force-change-password", () => {
let superAdminToken;
beforeEach(async () => {
// Simulate super admin login that requires password change
await pool.query(
`INSERT INTO users (id, email, password_hash, role, is_verified, is_active, must_change_password, uuid)
VALUES (999, $1, $2, 'super_admin', TRUE, TRUE, TRUE, $3)
ON CONFLICT (email) DO UPDATE SET password_hash = $2, must_change_password = TRUE`,
[
"admin@formies.local",
"NEEDS_TO_BE_SET_ON_FIRST_LOGIN",
require("uuid").v4(),
]
);
// This part is tricky: how do you get a token if login itself is blocked?
// Option 1: Special login route for first-time setup (not implemented).
// Option 2: Modify LocalStrategy to issue a temporary token for this specific case.
// Option 3: Assume `must_change_password` doesn't block login fully but returns a flag,
// and a normal token is issued, which is then used here.
// Let's assume Option 3 for this test, where login provides a token.
const loginRes = await request(app)
.post("/api/auth/login")
.send({ email: "admin@formies.local", password: "anypassword" }); // Password will be bypassed by special logic
if (loginRes.body.data && loginRes.body.data.accessToken) {
superAdminToken = loginRes.body.data.accessToken;
} else {
// If login directly returns 403 for MUST_CHANGE_PASSWORD, then this test needs rethinking.
// It implies the client makes this call *without* a token initially, which is unusual for a POST.
// Or, the client gets some other form of temporary credential.
// For now, this test assumes a token is acquired.
console.warn(
"Could not get token for superAdmin requiring password change. /force-change-password test may be invalid."
);
}
});
it("should allow super_admin to change password if must_change_password is true", async () => {
if (!superAdminToken) {
console.warn("Skipping force-change-password test: no superAdminToken");
return; // or expect(superAdminToken).toBeDefined(); to fail if setup is wrong
}
const res = await request(app)
.post("/api/auth/force-change-password")
.set("Authorization", `Bearer ${superAdminToken}`)
.send({ newPassword: "NewSecurePassword123!" });
expect(res.statusCode).toEqual(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toContain("Password changed successfully");
const dbUser = await User.findByEmail("admin@formies.local");
expect(dbUser.must_change_password).toBe(0); // Or FALSE
const isMatch = await require("bcryptjs").compare(
"NewSecurePassword123!",
dbUser.password_hash
);
expect(isMatch).toBe(true);
});
});
// ... tests for /refresh, /logout, /verify-email, /forgot-password, /reset-password, /profile etc.
});

View File

@ -0,0 +1,58 @@
// __tests__/integration/dashboard.test.js
// ... imports ...
describe("GET /dashboard (My Forms)", () => {
let userToken;
let userId;
beforeEach(async () => {
// Create user and login to get token
const user = await User.create({
email: "dash@example.com",
password: "Password123!",
is_verified: 1,
});
userId = user.id;
const loginRes = await request(app)
.post("/api/auth/login")
.send({ email: "dash@example.com", password: "Password123!" });
userToken = loginRes.body.data.accessToken;
// Create some forms for this user
await pool.query(
"INSERT INTO forms (uuid, user_id, name) VALUES ($1, $2, $3), ($4, $2, $5)",
[
require("uuid").v4(),
userId,
"My First Form",
require("uuid").v4(),
"My Second Form",
]
);
// Create a form for another user
const otherUser = await User.create({
email: "other@example.com",
password: "Password123!",
});
await pool.query(
"INSERT INTO forms (uuid, user_id, name) VALUES ($1, $2, $3)",
[require("uuid").v4(), otherUser.id, "Other Users Form"]
);
});
it("should list forms owned by the authenticated user", async () => {
const res = await request(app)
.get("/dashboard")
.set("Authorization", `Bearer ${userToken}`); // Or handle session if dashboard uses sessions
// If dashboard uses sessions, you need to manage login via supertest's agent:
// const agent = request.agent(app);
// await agent.post('/api/auth/login').send({ email: 'dash@example.com', password: 'Password123!' });
// const res = await agent.get('/dashboard');
expect(res.statusCode).toEqual(200);
// For EJS, you'd check for HTML content:
expect(res.text).toContain("My First Form");
expect(res.text).toContain("My Second Form");
expect(res.text).not.toContain("Other Users Form");
});
// ... more dashboard tests for create, settings, submissions view, API keys...
});

View File

@ -0,0 +1,34 @@
// __tests__/setup/jest.setup.js
const {
initializeTestDB,
clearAllTables,
disconnectTestDB,
} = require("./testDbUtils");
// Optional: Runs once before all test suites
beforeAll(async () => {
console.log("Global setup: Initializing test database...");
await initializeTestDB(); // Ensure clean slate for the entire test run
});
// Runs before each test file (or each test if inside describe block)
// For a truly clean slate for each test file or even each test:
beforeEach(async () => {
// console.log('Resetting tables before test...');
// Depending on your needs, you might re-initialize or just clear tables
await clearAllTables(); // This is faster than full re-init if schema doesn't change
});
// Optional: Runs once after all test suites
afterAll(async () => {
console.log("Global teardown: Disconnecting test database pool...");
await disconnectTestDB();
// You might also need to close your main app's DB pool if it's shared or server is kept running
// And close Redis connections if your tests directly interact with them
const { closeRedis } = require("../../src/config/redis"); // Adjust path
await closeRedis();
// If your server is started for integration tests, ensure it's closed.
// This is often handled by supertest if 'app' is imported and not globally started.
// Or if you start server in globalSetup, close it in globalTeardown.
});

View File

@ -0,0 +1,99 @@
// __tests__/setup/testDbUtils.js
const fs = require("fs");
const path = require("path");
const { Pool } = require("pg"); // Use pg directly for setup
// Load .env.test variables
require("dotenv").config({ path: path.resolve(__dirname, "../../.env.test") });
const poolConfig = {
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT || "5432", 10),
};
const pool = new Pool(poolConfig);
const initSql = fs.readFileSync(
path.resolve(__dirname, "../../init.sql"),
"utf8"
);
async function initializeTestDB() {
const client = await pool.connect();
try {
// Drop all tables (order matters due to FK constraints)
// This is a simple way for tests; migrations are better for complex apps.
await client.query("DROP TABLE IF EXISTS user_sessions CASCADE;");
await client.query("DROP TABLE IF EXISTS api_keys CASCADE;");
await client.query("DROP TABLE IF EXISTS submissions CASCADE;");
await client.query("DROP TABLE IF EXISTS forms CASCADE;");
await client.query("DROP TABLE IF EXISTS users CASCADE;");
await client.query("DROP TABLE IF EXISTS rate_limits CASCADE;"); // If you used this table
// Potentially drop extensions or other objects if init.sql creates them and they persist
// Re-run init.sql
// Note: node-postgres pool.query might not execute multi-statement SQL directly from a file easily.
// It's often better to split init.sql or execute statements one by one.
// For simplicity here, assuming init.sql can be run or you adjust this.
// A common approach is to split init.sql by ';' (excluding those in strings/comments)
const statements = initSql
.split(";\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const statement of statements) {
if (statement.toUpperCase().startsWith("CREATE TRIGGER")) {
// pg doesn't like CREATE TRIGGER in multi-statement query via client.query
// Skip or handle differently if complex. For now, we assume init.sql is mostly CREATE TABLE / INSERT
// Or, ensure your init.sql puts CREATE EXTENSION at the very top if needed.
// console.warn("Skipping TRIGGER creation in test setup, ensure DB compatibility or handle manually.");
} else {
await client.query(statement);
}
}
console.log("Test database initialized/reset.");
} catch (err) {
console.error("Error initializing test database:", err);
throw err;
} finally {
client.release();
}
}
async function clearTable(tableName) {
const client = await pool.connect();
try {
await client.query(`DELETE FROM "${tableName}";`); // Or TRUNCATE if preferred and allowed
} finally {
client.release();
}
}
async function clearAllTables() {
const client = await pool.connect();
try {
await client.query("DELETE FROM user_sessions;");
await client.query("DELETE FROM api_keys;");
await client.query("DELETE FROM submissions;");
await client.query("DELETE FROM forms;");
await client.query("DELETE FROM users;");
await client.query("DELETE FROM rate_limits;");
} finally {
client.release();
}
}
async function disconnectTestDB() {
await pool.end();
console.log("Test database pool disconnected.");
}
module.exports = {
pool, // Export the pool for direct use in tests if needed
initializeTestDB,
clearTable,
clearAllTables,
disconnectTestDB,
};

View File

@ -0,0 +1,154 @@
// __tests__/unit/models/User.db.test.js
const User = require("../../../src/models/User"); // Adjust path
const { pool, clearAllTables } = require("../../setup/testDbUtils"); // Adjust path
describe("User Model (PostgreSQL)", () => {
beforeEach(async () => {
await clearAllTables(); // Ensure clean state for each test
});
describe("create", () => {
it("should create a new user with hashed password and verification token", async () => {
const userData = {
email: "test@example.com",
password: "Password123!",
first_name: "Test",
last_name: "User",
};
const user = await User.create(userData);
expect(user.id).toBeDefined();
expect(user.uuid).toBeDefined();
expect(user.email).toBe(userData.email);
expect(user.password_hash).not.toBe(userData.password); // Should be hashed
expect(user.verification_token).toBeDefined();
expect(user.is_verified).toBe(0); // Default for SQLite, ensure it's FALSE for PG
const dbUser = await pool.query("SELECT * FROM users WHERE id = $1", [
user.id,
]);
expect(dbUser.rows[0].email).toBe(userData.email);
expect(dbUser.rows[0].password_hash).not.toBe(userData.password);
});
it("should throw an error if email already exists", async () => {
const userData = {
email: "duplicate@example.com",
password: "Password123!",
};
await User.create(userData);
await expect(User.create(userData)).rejects.toThrow(
"Email already exists"
);
});
});
describe("findByEmail", () => {
it("should find an active user by email", async () => {
const createdUser = await User.create({
email: "findme@example.com",
password: "Password123!",
});
const foundUser = await User.findByEmail("findme@example.com");
expect(foundUser).toBeDefined();
expect(foundUser.id).toBe(createdUser.id);
});
it("should return null if user not found or inactive", async () => {
expect(await User.findByEmail("dontexist@example.com")).toBeNull();
// Add test for inactive user if you implement that logic
});
});
describe("findById", () => {
it("should find an active user by ID", async () => {
const createdUser = await User.create({
email: "findbyid@example.com",
password: "Password123!",
});
const foundUser = await User.findById(createdUser.id);
expect(foundUser).toBeDefined();
expect(foundUser.email).toBe(createdUser.email);
});
});
describe("verifyEmail", () => {
it("should verify a user and nullify the token", async () => {
const user = await User.create({
email: "verify@example.com",
password: "Pass!",
});
const verificationToken = user.verification_token;
const verified = await User.verifyEmail(verificationToken);
expect(verified).toBe(true);
const dbUser = await User.findById(user.id);
expect(dbUser.is_verified).toBe(1); // Or TRUE depending on PG boolean handling
expect(dbUser.verification_token).toBeNull();
});
});
describe("setPasswordResetToken and findByPasswordResetToken", () => {
it("should set and find a valid password reset token", async () => {
const user = await User.create({
email: "reset@example.com",
password: "password",
});
const { token } = await User.setPasswordResetToken(user.email);
expect(token).toBeDefined();
const foundUser = await User.findByPasswordResetToken(token);
expect(foundUser).toBeDefined();
expect(foundUser.id).toBe(user.id);
});
it("should not find an expired password reset token", async () => {
const user = await User.create({
email: "resetexpired@example.com",
password: "password",
});
const { token } = await User.setPasswordResetToken(user.email);
// Manually expire the token in DB for testing
await pool.query(
"UPDATE users SET password_reset_expires = NOW() - INTERVAL '2 hour' WHERE id = $1",
[user.id]
);
const foundUser = await User.findByPasswordResetToken(token);
expect(foundUser).toBeNull();
});
});
// ... more tests for other User model methods (updatePassword, login attempts, etc.) ...
// Example: updatePasswordAndClearChangeFlag
describe("updatePasswordAndClearChangeFlag", () => {
it("should update password and set must_change_password to false", async () => {
const user = await User.create({
email: "changeme@example.com",
password: "oldpassword",
});
// Manually set must_change_password to true for test
await pool.query(
"UPDATE users SET must_change_password = TRUE WHERE id = $1",
[user.id]
);
const newPassword = "NewStrongPassword123!";
const updated = await User.updatePasswordAndClearChangeFlag(
user.id,
newPassword
);
expect(updated).toBe(true);
const dbUser = await User.findById(user.id);
const isMatch = await require("bcryptjs").compare(
newPassword,
dbUser.password_hash
);
expect(isMatch).toBe(true);
expect(dbUser.must_change_password).toBe(0); // Or FALSE
});
});
});

View File

@ -0,0 +1,133 @@
// __tests__/unit/services/emailService.test.js
const emailServiceModule = require("../../../src/services/emailService"); // Adjust path
const { Resend } = require("resend");
const logger = require("../../../config/logger"); // Adjust path
jest.mock("resend"); // Mock the Resend constructor and its methods
jest.mock("../../../config/logger"); // Mock logger to spy on it
describe("Email Service (Resend)", () => {
const mockSend = jest.fn();
const originalResendApiKey = process.env.RESEND_API_KEY;
const originalEmailFrom = process.env.EMAIL_FROM_ADDRESS;
beforeEach(() => {
mockSend.mockClear();
Resend.mockClear();
Resend.mockImplementation(() => ({
emails: { send: mockSend },
}));
// Ensure env vars are set for these tests
process.env.RESEND_API_KEY = "test-resend-api-key";
process.env.EMAIL_FROM_ADDRESS = "sender@example.com";
});
afterAll(() => {
process.env.RESEND_API_KEY = originalResendApiKey;
process.env.EMAIL_FROM_ADDRESS = originalEmailFrom;
});
describe("sendSubmissionNotification", () => {
const form = {
name: "Test Form",
email_notifications_enabled: true,
notification_email_address: "custom@example.com",
};
const submissionData = { name: "John Doe", message: "Hello" };
const userOwnerEmail = "owner@example.com";
it("should send email if notifications enabled and custom address provided", async () => {
mockSend.mockResolvedValue({ data: { id: "email_id_123" }, error: null });
await emailServiceModule.sendSubmissionNotification(
form,
submissionData,
userOwnerEmail
);
expect(Resend).toHaveBeenCalledWith("test-resend-api-key");
expect(mockSend).toHaveBeenCalledWith({
from: "sender@example.com",
to: "custom@example.com",
subject: "New Submission for Form: Test Form",
html: expect.stringContaining("<strong>Test Form</strong>"),
});
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Submission email sent successfully")
);
});
it("should use owner email if custom address not provided", async () => {
const formNoCustomEmail = { ...form, notification_email_address: null };
mockSend.mockResolvedValue({ data: { id: "email_id_123" }, error: null });
await emailServiceModule.sendSubmissionNotification(
formNoCustomEmail,
submissionData,
userOwnerEmail
);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
to: "owner@example.com",
})
);
});
it("should not send email if notifications are disabled", async () => {
const disabledForm = { ...form, email_notifications_enabled: false };
await emailServiceModule.sendSubmissionNotification(
disabledForm,
submissionData,
userOwnerEmail
);
expect(mockSend).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Email notifications are disabled")
);
});
it("should log error if Resend fails", async () => {
const resendError = new Error("Resend API Error");
mockSend.mockResolvedValue({ data: null, error: resendError }); // Resend SDK might return error in object
// OR mockSend.mockRejectedValue(resendError); if it throws
await emailServiceModule.sendSubmissionNotification(
form,
submissionData,
userOwnerEmail
);
expect(logger.error).toHaveBeenCalledWith(
"Error sending submission email via Resend:",
resendError
);
});
it("should not send if RESEND_API_KEY is missing", async () => {
delete process.env.RESEND_API_KEY; // Temporarily remove
// Re-require or re-instantiate the service if it checks env vars at import time
// For this structure, the check is at the top of the file, so it might already be 'null'
// A better approach would be for the service to have an isConfigured() method.
// Forcing a re-import for the test is tricky without specific Jest features for module reloading.
// Let's assume the check inside sendSubmissionNotification handles the 'resend' object being null.
// To test this properly, we might need to re-import the module after changing env var
jest.resetModules(); // Clears module cache
process.env.RESEND_API_KEY = undefined;
const freshEmailService = require("../../../src/services/emailService");
await freshEmailService.sendSubmissionNotification(
form,
submissionData,
userOwnerEmail
);
expect(mockSend).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Resend SDK not initialized")
);
process.env.RESEND_API_KEY = "test-resend-api-key"; // Restore
jest.resetModules(); // Clean up
});
});
// You would add similar tests for sendVerificationEmail, etc. from the old Nodemailer-based service
// if you intend to keep that functionality (currently it's commented out or separate)
});

View File

@ -0,0 +1,126 @@
// __tests__/unit/services/jwtService.test.js
const jwtService = require("../../../src/services/jwtService"); // Adjust path
const User = require("../../../src/models/User"); // Adjust path
const jwt = require("jsonwebtoken");
jest.mock("../../../src/models/User"); // Mock the User model
describe("JWT Service", () => {
const mockUser = { id: 1, email: "test@example.com", role: "user" };
const originalJwtSecret = process.env.JWT_SECRET;
beforeAll(() => {
process.env.JWT_SECRET = "test-secret-for-jwt-service"; // Use a fixed secret for tests
});
afterAll(() => {
process.env.JWT_SECRET = originalJwtSecret; // Restore original
});
beforeEach(() => {
User.saveSession.mockClear();
User.isTokenBlacklisted.mockClear();
User.revokeSession.mockClear();
});
describe("generateAccessToken", () => {
it("should generate a valid access token and save session", async () => {
User.saveSession.mockResolvedValue(true);
const { token, expiresAt, jti } =
jwtService.generateAccessToken(mockUser);
expect(token).toBeDefined();
expect(jti).toBeDefined();
const decoded = jwt.verify(token, process.env.JWT_SECRET);
expect(decoded.sub).toBe(mockUser.id);
expect(decoded.type).toBe("access");
expect(decoded.jti).toBe(jti);
expect(User.saveSession).toHaveBeenCalledWith(
mockUser.id,
jti,
expiresAt,
undefined,
undefined
);
});
});
describe("generateRefreshToken", () => {
it("should generate a valid refresh token and save session", async () => {
User.saveSession.mockResolvedValue(true);
const { token } = jwtService.generateRefreshToken(mockUser);
const decoded = jwt.verify(token, process.env.JWT_SECRET);
expect(decoded.sub).toBe(mockUser.id);
expect(decoded.type).toBe("refresh");
});
});
describe("verifyToken", () => {
it("should verify a valid token", () => {
const { token } = jwtService.generateAccessToken(mockUser);
const decoded = jwtService.verifyToken(token, "access");
expect(decoded.sub).toBe(mockUser.id);
});
it("should throw error for an expired token", () => {
// Generate token with 0s expiry (sign options need to be passed to jwt.sign)
const expiredToken = jwt.sign(
{ sub: mockUser.id, type: "access" },
process.env.JWT_SECRET,
{ expiresIn: "0s" }
);
// Wait a bit for it to actually expire
return new Promise((resolve) => {
setTimeout(() => {
expect(() => jwtService.verifyToken(expiredToken, "access")).toThrow(
"Token has expired"
);
resolve();
}, 10);
});
});
it("should throw error for an invalid token type", () => {
const { token } = jwtService.generateAccessToken(mockUser); // This is an 'access' token
expect(() => jwtService.verifyToken(token, "refresh")).toThrow(
"Invalid token type. Expected refresh"
);
});
});
describe("refreshAccessToken", () => {
it("should refresh access token with a valid refresh token", async () => {
const { token: rToken, jti: refreshJti } =
jwtService.generateRefreshToken(mockUser);
User.isTokenBlacklisted.mockResolvedValue(false); // Not blacklisted
User.findById.mockResolvedValue(mockUser); // User exists
User.saveSession.mockResolvedValue(true); // For the new access token
const { accessToken } = await jwtService.refreshAccessToken(rToken);
expect(accessToken).toBeDefined();
const decodedAccess = jwt.verify(accessToken, process.env.JWT_SECRET);
expect(decodedAccess.type).toBe("access");
expect(User.isTokenBlacklisted).toHaveBeenCalledWith(refreshJti);
});
it("should throw if refresh token is blacklisted", async () => {
const { token: rToken, jti: refreshJti } =
jwtService.generateRefreshToken(mockUser);
User.isTokenBlacklisted.mockResolvedValue(true); // Blacklisted
await expect(jwtService.refreshAccessToken(rToken)).rejects.toThrow(
"Refresh token has been revoked"
);
expect(User.isTokenBlacklisted).toHaveBeenCalledWith(refreshJti);
});
});
describe("revokeToken", () => {
it("should call User.revokeSession with JTI", async () => {
const { token, jti } = jwtService.generateAccessToken(mockUser);
User.revokeSession.mockResolvedValue(true);
await jwtService.revokeToken(token);
expect(User.revokeSession).toHaveBeenCalledWith(jti);
});
});
// ... more tests ...
});

View File

@ -0,0 +1,33 @@
// __tests__/unit/utils/apiKeyHelper.test.js
const {
generateApiKeyParts,
hashApiKeySecret,
compareApiKeySecret,
API_KEY_IDENTIFIER_PREFIX,
} = require("../../../src/utils/apiKeyHelper"); // Adjust path
describe("API Key Helper", () => {
describe("generateApiKeyParts", () => {
it("should generate an API key with correct prefix, identifier, and secret", () => {
const { fullApiKey, identifier, secret } = generateApiKeyParts();
expect(identifier).toMatch(
new RegExp(`^${API_KEY_IDENTIFIER_PREFIX}_[a-f0-9]{12}$`)
);
expect(secret).toMatch(/^[a-f0-9]{64}$/); // 32 bytes = 64 hex chars
expect(fullApiKey).toBe(`${identifier}_${secret}`);
});
});
describe("hashApiKeySecret and compareApiKeySecret", () => {
it("should correctly hash and compare a secret", async () => {
const secret = "mySuperSecretApiKeyPart";
const hashedSecret = await hashApiKeySecret(secret);
expect(hashedSecret).not.toBe(secret);
expect(await compareApiKeySecret(secret, hashedSecret)).toBe(true);
expect(await compareApiKeySecret("wrongSecret", hashedSecret)).toBe(
false
);
});
});
});

View File

@ -0,0 +1,82 @@
// __tests__/unit/utils/recaptchaHelper.test.js
const { verifyRecaptchaV2 } = require("../../../src/utils/recaptchaHelper"); // Adjust path
// Mock global fetch
global.fetch = jest.fn();
describe("reCAPTCHA Helper", () => {
const RECAPTCHA_V2_SECRET_KEY_ORIG = process.env.RECAPTCHA_V2_SECRET_KEY;
beforeEach(() => {
fetch.mockClear();
// Ensure a secret key is set for these tests
process.env.RECAPTCHA_V2_SECRET_KEY = "test-secret-key";
});
afterAll(() => {
process.env.RECAPTCHA_V2_SECRET_KEY = RECAPTCHA_V2_SECRET_KEY_ORIG; // Restore original
});
it("should return true for a successful verification", async () => {
fetch.mockResolvedValueOnce({
json: async () => ({ success: true }),
});
const result = await verifyRecaptchaV2("valid-token", "127.0.0.1");
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("response=valid-token"),
{ method: "POST" }
);
});
it("should return false for a failed verification", async () => {
fetch.mockResolvedValueOnce({
json: async () => ({
success: false,
"error-codes": ["invalid-input-response"],
}),
});
const result = await verifyRecaptchaV2("invalid-token");
expect(result).toBe(false);
});
it("should return false if reCAPTCHA secret key is not set", async () => {
delete process.env.RECAPTCHA_V2_SECRET_KEY; // Temporarily remove for this test
const consoleWarnSpy = jest
.spyOn(console, "warn")
.mockImplementation(() => {});
const result = await verifyRecaptchaV2("any-token");
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("RECAPTCHA_V2_SECRET_KEY is not set")
);
process.env.RECAPTCHA_V2_SECRET_KEY = "test-secret-key"; // Restore for other tests
consoleWarnSpy.mockRestore();
});
it("should return false if no token is provided", async () => {
const consoleWarnSpy = jest
.spyOn(console, "warn")
.mockImplementation(() => {});
const result = await verifyRecaptchaV2("");
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith(
"No reCAPTCHA token provided by client."
);
consoleWarnSpy.mockRestore();
});
it("should return false if fetch throws an error", async () => {
fetch.mockRejectedValueOnce(new Error("Network error"));
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const result = await verifyRecaptchaV2("any-token");
expect(result).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error during reCAPTCHA verification request:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});

156
combined.log Normal file
View File

@ -0,0 +1,156 @@
{"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"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"code":"XX000","length":73,"level":"error","message":"Error checking for users table: connection is insecure (try using `sslmode=require`)","name":"error","service":"user-service","severity":"ERROR","stack":"error: connection is insecure (try using `sslmode=require`)\n at C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\node_modules\\pg-pool\\index.js:45:11\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async initializeDatabase (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:26:3)\n at async initializeApp (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:65:3)"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 11:50:05 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Users table not found, attempting to initialize database...","service":"user-service"}
{"level":"info","message":"Database initialized successfully from init.sql.","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":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","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":"PostgreSQL current time: Wed May 28 2025 11:51:38 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 12:31:18 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"warn","message":"Failed to initialize RedisStore, falling back to MemoryStore for sessions. Redis client not available","service":"user-service","stack":"Error: Redis client not available\n at getRedisClient (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\src\\config\\redis.js:82:9)\n at initializeApp (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:99:24)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)"}
{"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"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 12:43:16 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","service":"user-service"}
{"level":"warn","message":"Failed to initialize RedisStore, falling back to MemoryStore for sessions. Redis client not available","service":"user-service","stack":"Error: Redis client not available\n at getRedisClient (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\src\\config\\redis.js:82:9)\n at initializeApp (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:99:24)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)"}
{"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":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 12:43:40 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","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":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 12:43:59 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","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: /api/auth - 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":"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":"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":"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":"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":"warn","message":"404 - Endpoint not found: /favicon.ico - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Cleaned up 0 expired user sessions.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Cleaned up 0 expired user sessions.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Cleaned up 0 expired user sessions.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 16:05:43 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","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: /register - Method: GET - IP: ::1","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"Successfully connected to PostgreSQL database via pool.","service":"user-service"}
{"level":"info","message":"New client connected to the PostgreSQL database","service":"user-service"}
{"level":"info","message":"PostgreSQL current time: Wed May 28 2025 16:10:57 GMT+0200 (Central European Summer Time)","service":"user-service"}
{"level":"info","message":"Database tables appear to exist. Skipping initialization.","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: /.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"}

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

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

@ -0,0 +1,58 @@
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: postgres:15-alpine
ports:
- "5432:5432" # Standard PostgreSQL port
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- pg_data:/var/lib/postgresql/data # Persist database data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql # Run init script on startup
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME} -h localhost"]
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:
pg_data:
redis_data:

44
docker-compose.yml Normal file
View File

@ -0,0 +1,44 @@
version: "3.8"
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DB_HOST=postgres
- 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:
- postgres
- redis
postgres:
image: postgres:15-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
volumes:
- pg_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
pg_data:
redis_data:

19
env.development.template Normal file
View File

@ -0,0 +1,19 @@
DATABASE_URL=your_neon_development_connection_string_with_sslmode_require # e.g., postgresql://user:password@host:port/dbname?sslmode=require
# DB_HOST=localhost
# DB_PORT=5432
# DB_USER=your_postgres_user
# DB_PASSWORD=your_postgres_password
# DB_NAME=your_postgres_database_name
# Redis - if you keep using it
REDIS_HOST=localhost
REDIS_PORT=6379
# REDIS_PASSWORD=your_redis_password # Uncomment if your Redis has a password
# Application specific
NODE_ENV=development
PORT=3000
# Example for JWT secrets, session secrets, etc.
# SESSION_SECRET=your_strong_session_secret
# JWT_SECRET=your_strong_jwt_secret

24
env.production.template Normal file
View File

@ -0,0 +1,24 @@
DATABASE_URL=your_neon_production_connection_string_with_sslmode_require # e.g., postgresql://user:password@host:port/dbname?sslmode=require
# DB_HOST=your_production_db_host_or_service_name # e.g., the service name in docker-compose.prod.yml like 'db'
# DB_PORT=5432
# DB_USER=your_production_postgres_user
# DB_PASSWORD=your_production_postgres_password
# DB_NAME=your_production_postgres_database_name
# Redis
REDIS_HOST=your_production_redis_host_or_service_name # e.g., the service name in docker-compose.prod.yml like 'redis'
REDIS_PORT=6379 # Or your production Redis port if different
REDIS_PASSWORD=your_production_redis_password # Ensure this is set for production
# Application specific
NODE_ENV=production
PORT=3000 # Or your desired production port
# Security - VERY IMPORTANT: Use strong, unique secrets for production
SESSION_SECRET=generate_a_very_strong_random_string_for_session_secret
JWT_SECRET=generate_a_very_strong_random_string_for_jwt_secret
# Other production settings
# For example, if you have specific logging levels or API keys for production
# LOG_LEVEL=warn
# THIRD_PARTY_API_KEY=your_production_api_key

4
error.log Normal file
View File

@ -0,0 +1,4 @@
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}
{"code":"XX000","length":73,"level":"error","message":"Error checking for users table: connection is insecure (try using `sslmode=require`)","name":"error","service":"user-service","severity":"ERROR","stack":"error: connection is insecure (try using `sslmode=require`)\n at C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\node_modules\\pg-pool\\index.js:45:11\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async initializeDatabase (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:26:3)\n at async initializeApp (C:\\Users\\Mohamad.Elsena\\Desktop\\dev\\mooo\\mo\\formies\\server.js:65:3)"}
{"level":"error","message":"Failed to connect to PostgreSQL database:","service":"user-service"}

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 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;
}
}

147
init.sql Normal file
View File

@ -0,0 +1,147 @@
-- init.sql for PostgreSQL
-- Attempt to create the database if it doesn't exist.
-- Note: CREATE DATABASE IF NOT EXISTS is not standard SQL for all clients.
-- This might need to be handled outside the script or by connecting to a default db like 'postgres' first.
-- For docker-entrypoint-initdb.d, this script is typically run after the DB specified by POSTGRES_DB is created.
-- Enable pgcrypto extension for gen_random_uuid() if not already enabled
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Users table for authentication and authorization
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
first_name VARCHAR(255) DEFAULT NULL,
last_name VARCHAR(255) DEFAULT NULL,
role VARCHAR(50) DEFAULT 'user' CHECK(role IN ('user', 'admin', 'super_admin')),
is_verified BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
verification_token TEXT DEFAULT NULL,
password_reset_token TEXT DEFAULT NULL,
password_reset_expires TIMESTAMPTZ NULL DEFAULT NULL,
last_login TIMESTAMPTZ NULL DEFAULT NULL,
failed_login_attempts INTEGER DEFAULT 0,
account_locked_until TIMESTAMPTZ NULL DEFAULT NULL,
must_change_password BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
-- Removed redundant UNIQUE constraints as they are already on id, uuid, email
);
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 SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
token_jti TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
user_agent TEXT DEFAULT NULL,
ip_address VARCHAR(255) 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);
-- Forms table
CREATE TABLE IF NOT EXISTS forms (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
name VARCHAR(255) DEFAULT 'My Form',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
thank_you_url TEXT DEFAULT NULL,
thank_you_message TEXT DEFAULT NULL,
ntfy_enabled BOOLEAN DEFAULT TRUE,
is_archived BOOLEAN DEFAULT FALSE,
allowed_domains TEXT DEFAULT NULL, -- Consider array of VARCHARs or separate table for multi-domain
email_notifications_enabled BOOLEAN NOT NULL DEFAULT FALSE,
notification_email_address VARCHAR(255) DEFAULT NULL,
recaptcha_enabled BOOLEAN NOT NULL DEFAULT FALSE,
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);
-- Submissions table
CREATE TABLE IF NOT EXISTS submissions (
id SERIAL PRIMARY KEY,
form_uuid UUID NOT NULL,
user_id INTEGER NOT NULL, -- Assuming submissions are tied to a user account that owns the form
data JSONB NOT NULL, -- Storing JSON as JSONB
ip_address VARCHAR(255) NULL,
submitted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (form_uuid) REFERENCES forms(uuid) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -- Or remove if submissions are anonymous to users table
);
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
CREATE TABLE IF NOT EXISTS rate_limits (
id SERIAL PRIMARY KEY,
identifier TEXT NOT NULL,
action TEXT NOT NULL,
count INTEGER DEFAULT 1,
window_start TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ 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);
-- API Keys table
CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
key_name VARCHAR(255) DEFAULT NULL,
api_key_identifier TEXT NOT NULL UNIQUE,
hashed_api_key_secret TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMPTZ NULL DEFAULT NULL,
expires_at TIMESTAMPTZ NULL DEFAULT NULL,
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);
-- Function and Trigger to update 'updated_at' timestamp
CREATE OR REPLACE FUNCTION trigger_set_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger for users table
CREATE TRIGGER set_timestamp_users
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
-- Trigger for forms table
CREATE TRIGGER set_timestamp_forms
BEFORE UPDATE ON forms
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
-- Create default super admin user
-- Using ON CONFLICT to prevent error if user already exists.
-- UUID is now generated by default by the database.
INSERT INTO users (email, password_hash, first_name, last_name, role, is_verified, is_active, must_change_password)
VALUES ('admin@formies.local', 'NEEDS_TO_BE_SET_ON_FIRST_LOGIN', 'Admin', 'User', 'super_admin', TRUE, TRUE, TRUE)
ON CONFLICT (email) DO NOTHING;
-- Note: PRAGMA foreign_keys = ON; is not needed in PostgreSQL. FKs are enforced by default if defined.
-- Note: Backticks for table/column names are generally not needed unless using reserved words or special chars.
-- Standard SQL double quotes can be used if necessary, but unquoted is often preferred.

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/",
"/src/config/", // DB, Passport, Redis configs
"/config/", // Logger config
],
clearMocks: true,
coverageDirectory: "coverage",
collectCoverage: true,
collectCoverageFrom: [
"src/**/*.js",
"!server.js",
"!src/app.js", // If you create an app.js
"!src/config/database.js", // Usually not directly tested
"!src/config/passport.js", // Tested via auth integration tests
"!src/config/redis.js", // Tested via rate limiter integration tests
"!src/services/notification.js", // External, consider mocking if tested
],
setupFilesAfterEnv: ["./__tests__/setup/jest.setup.js"],
// Stop tests after first failure if desired for faster feedback during dev
// bail: 1,
// Force exit after tests are complete if you have open handles (use with caution)
// forceExit: true, // Usually indicates something isn't being torn down correctly
};

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.

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"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",
"connect-redis": "^8.1.0",
"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",
"pg": "^8.16.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": {
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"supertest": "^7.0.0"
}
}

6621
repomix-output.xml Normal file

File diff suppressed because it is too large Load Diff

196
server.js Normal file
View File

@ -0,0 +1,196 @@
require("dotenv").config();
const express = require("express");
const path = require("path");
const fs = require("fs"); // Added for fs operations
const pool = require("./src/config/database"); // Changed to pg pool
const helmet = require("helmet");
const session = require("express-session");
const passport = require("./src/config/passport");
const logger = require("./config/logger"); // Corrected logger path back to original
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 with PostgreSQL
async function initializeDatabase() {
try {
// Check if a key table exists (e.g., users) to see if DB is initialized
await pool.query("SELECT 1 FROM users LIMIT 1");
logger.info("Database tables appear to exist. Skipping initialization.");
} catch (tableCheckError) {
// Specific error code for undefined_table in PostgreSQL is '42P01'
if (tableCheckError.code === "42P01") {
logger.info(
"Users table not found, attempting to initialize database..."
);
try {
const initSql = fs.readFileSync(
path.resolve(__dirname, "init.sql"),
"utf8"
);
// Execute the entire init.sql script.
// pg library can usually handle multi-statement queries if separated by semicolons.
await pool.query(initSql);
logger.info("Database initialized successfully from init.sql.");
} catch (initError) {
logger.error("Failed to initialize database with init.sql:", initError);
process.exit(1); // Exit if DB initialization fails
}
} else {
// Another error occurred during the table check
logger.error("Error checking for users table:", tableCheckError);
process.exit(1); // Exit on other DB errors during startup
}
}
}
// 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 PostgreSQL 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);
});
module.exports = app;

View File

@ -1,101 +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 futures::future::{ready, Ready};
use log; // Use the log crate
use rusqlite::Connection;
use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely)
// Represents an authenticated user via token
pub struct Auth {
pub user_id: String,
}
impl FromRequest for Auth {
// Use actix_web::Error for consistency in error handling within Actix
type Error = ActixWebError;
// Use Ready from futures 0.3
type Future = Ready<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)",
)));
}
};
// Validate the token against the database (now includes expiration check)
match super::db::validate_token(&conn_guard, token) {
// Token is valid and not expired, return Ok with Auth struct
Ok(Some(user_id)) => {
log::debug!("Token validated successfully for user_id: {}", user_id);
ready(Ok(Auth { user_id }))
}
// Token is invalid, not found, or expired
Ok(None) => {
log::warn!("Invalid or expired token received"); // Avoid logging token
ready(Err(ErrorUnauthorized("Invalid or expired token")))
}
// Database error during token validation
Err(e) => {
log::error!("Database error during token validation: {:?}", e);
// Return Unauthorized to avoid leaking internal error details
// Consider mapping specific DB errors if needed, but Unauthorized is generally safe
ready(Err(ErrorUnauthorized("Token validation failed")))
}
}
} else {
// Header present but not "Bearer " format
log::warn!("Invalid Authorization header format (not Bearer)");
ready(Err(ErrorUnauthorized("Invalid token format")))
}
} else {
// Header value contains invalid characters
log::warn!("Authorization header contains invalid characters");
ready(Err(ErrorUnauthorized("Invalid token value")))
}
} else {
// Authorization header is missing
log::warn!("Missing Authorization header");
ready(Err(ErrorUnauthorized("Missing authorization token")))
}
}
}

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

@ -0,0 +1,53 @@
const { Pool } = require("pg");
const logger = require("../../config/logger"); // Corrected logger path
// Load environment variables
// require('dotenv').config(); // Call this at the very start of your app, e.g. in server.js
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: {
rejectUnauthorized: false, // Necessary for some cloud providers, including Neon
},
// user: process.env.DB_USER,
// host: process.env.DB_HOST,
// database: process.env.DB_NAME,
// password: process.env.DB_PASSWORD,
// port: process.env.DB_PORT || 5432, // Default PostgreSQL port
// Optional: Add more pool configuration options if needed
// max: 20, // Max number of clients in the pool
// idleTimeoutMillis: 30000, // How long a client is allowed to remain idle before being closed
// connectionTimeoutMillis: 2000, // How long to wait for a connection from the pool
});
pool.on("connect", (client) => {
logger.info("New client connected to the PostgreSQL database");
// You can set session-level parameters here if needed, e.g.:
// client.query('SET TIMEZONE="UTC";');
});
pool.on("error", (err, client) => {
logger.error("Unexpected error on idle PostgreSQL client", {
error: err.message,
clientInfo: client ? `Client connected for ${client.processID}` : "N/A",
});
// process.exit(-1); // Consider if you want to exit on idle client errors
});
// Test the connection (optional, but good for startup diagnostics)
async function testConnection() {
try {
const client = await pool.connect();
logger.info("Successfully connected to PostgreSQL database via pool.");
const res = await client.query("SELECT NOW()");
logger.info(`PostgreSQL current time: ${res.rows[0].now}`);
client.release();
} catch (err) {
logger.error("Failed to connect to PostgreSQL database:", err.stack);
// process.exit(1); // Exit if DB connection is critical for startup
}
}
testConnection();
module.exports = pool; // Export the pool

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,
};

356
src/db.rs
View File

@ -1,356 +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
token TEXT UNIQUE, -- Stores the current session token (UUID)
token_expires_at DATETIME -- Timestamp when the token expires
)",
[],
)
.context("Failed to create 'users' table")?;
log::debug!("Creating 'forms' table if not exists...");
conn.execute(
"CREATE TABLE IF NOT EXISTS forms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fields TEXT NOT NULL, -- Stores JSON definition of form fields
notify_email TEXT, -- Optional email address for notifications
notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.context("Failed to create 'forms' table")?;
// Add notify_email column if it doesn't exist (for backward compatibility)
match conn.execute("ALTER TABLE forms ADD COLUMN notify_email TEXT", []) {
Ok(_) => log::info!("Added notify_email column to forms table"),
Err(e) => {
if !e.to_string().contains("duplicate column name") {
return Err(anyhow!("Failed to add notify_email column: {}", e));
}
// If it already exists, that's fine
}
}
// Add notify_ntfy_topic column if it doesn't exist (for backward compatibility)
match conn.execute("ALTER TABLE forms ADD COLUMN notify_ntfy_topic TEXT", []) {
Ok(_) => log::info!("Added notify_ntfy_topic column to forms table"),
Err(e) => {
if !e.to_string().contains("duplicate column name") {
return Err(anyhow!("Failed to add notify_ntfy_topic column: {}", e));
}
// If it already exists, that's fine
}
}
log::debug!("Creating 'submissions' table if not exists...");
conn.execute(
"CREATE TABLE IF NOT EXISTS submissions (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL,
data TEXT NOT NULL, -- Stores JSON submission data
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (form_id) REFERENCES forms (id) ON DELETE CASCADE
)",
[],
)
.context("Failed to create 'submissions' table")?;
// Setup the initial admin user if it doesn't exist, using environment variables
setup_initial_admin(&conn).context("Failed to setup initial admin user")?;
log::info!("Database initialization complete.");
Ok(conn)
}
// Sets up the initial admin user from *required* environment variables if it doesn't exist
fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> {
// CRITICAL SECURITY CHANGE: Remove default credentials. Require env vars.
let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME")
.map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_USERNAME environment variable is not set."))?;
let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD")
.map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_PASSWORD environment variable is not set."))?;
if initial_admin_username.is_empty() || initial_admin_password.is_empty() {
return Err(anyhow!(
"FATAL: INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD must not be empty."
));
}
// Check password complexity? (Optional enhancement)
add_user_if_not_exists(conn, &initial_admin_username, &initial_admin_password)
.context("Failed during initial admin user setup")?;
Ok(())
}
// Adds a user with a hashed password if the username doesn't exist
pub fn add_user_if_not_exists(
conn: &Connection,
username: &str,
password: &str,
) -> AnyhowResult<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")?;
// Insert the new user (token and expiry are initially NULL)
log::info!("Creating new user '{}' with ID: {}", username, user_id);
conn.execute(
"INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)",
params![user_id, username, hashed_password],
)
.context(format!("Failed to insert user '{}'", username))?;
Ok(true) // User was added
}
// Validate a session token and return the associated user ID if valid and not expired
pub fn validate_token(conn: &Connection, token: &str) -> AnyhowResult<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, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1")
.context("Failed to prepare query for fetching form")?;
let result = stmt
.query_row(params![form_id], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let fields_str: String = row.get(2)?;
let notify_email: Option<String> = row.get(3)?;
let notify_ntfy_topic: Option<String> = row.get(4)?; // Get the new field
let created_at: chrono::DateTime<chrono::Utc> = row.get(5)?;
// Parse the fields JSON string
let fields = serde_json::from_str(&fields_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
2, // Index of 'fields' column
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
Ok(models::Form {
id: Some(id),
name,
fields,
notify_email,
notify_ntfy_topic, // Include the new field
created_at,
})
})
.optional()
.context(format!("Failed to fetch form with ID: {}", form_id))?;
Ok(result)
}
// Add a function to save a form
impl models::Form {
pub fn save(&self, conn: &Connection) -> AnyhowResult<()> {
let id = self
.id
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string());
let fields_json = serde_json::to_string(&self.fields)?;
conn.execute(
"INSERT INTO forms (id, name, fields, notify_email, notify_ntfy_topic, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
fields = excluded.fields,
notify_email = excluded.notify_email,
notify_ntfy_topic = excluded.notify_ntfy_topic", // Update the new field on conflict
params![
id,
self.name,
fields_json,
self.notify_email,
self.notify_ntfy_topic, // Add the new field to params
self.created_at
],
)?;
Ok(())
}
pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult<Self> {
get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id))
// Added ID to error
}
}
// Add a function to save a submission
impl models::Submission {
pub fn save(&self, conn: &Connection) -> AnyhowResult<()> {
let data_json = serde_json::to_string(&self.data)?;
conn.execute(
"INSERT INTO submissions (id, form_id, data, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![self.id, self.form_id, data_json, self.created_at],
)?;
Ok(())
}
}

View File

@ -1,751 +0,0 @@
use crate::auth::Auth;
use crate::models::{Form, LoginCredentials, LoginResponse, Submission};
use crate::AppState;
use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
use chrono; // Only import the module since we use it qualified
use log;
use regex::Regex; // For pattern validation
use rusqlite::{params, Connection};
use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
// --- Helper Function for Validation ---
/// Validates submission data against the form field definitions with enhanced checks.
///
/// Expected field definition properties:
/// - `name`: string (required)
/// - `type`: string (e.g., "string", "number", "boolean", "email", "url", "object", "array") (required)
/// - `required`: boolean (optional, default: false)
/// - `maxLength`: number (for "string" type)
/// - `minLength`: number (for "string" type)
/// - `min`: number (for "number" type)
/// - `max`: number (for "number" type)
/// - `pattern`: string (regex for "string", "email", "url" types)
///
/// Returns `Ok(())` if valid, or `Err(JsonValue)` containing validation errors.
fn validate_submission_against_definition(
submission_data: &JsonValue,
form_definition_fields: &JsonValue,
) -> Result<(), JsonValue> {
let mut errors: HashMap<String, String> = HashMap::new();
// Ensure 'fields' in the definition is a JSON array
let field_definitions = match form_definition_fields.as_array() {
Some(defs) => defs,
None => {
log::error!(
"Form definition 'fields' is not a JSON array. Def: {:?}",
form_definition_fields
);
errors.insert(
"_internal".to_string(),
"Invalid form definition format (not an array)".to_string(),
);
return Err(json!({ "validation_errors": errors }));
}
};
// Ensure the submission data is a JSON object
let data_map = match submission_data.as_object() {
Some(map) => map,
None => {
errors.insert(
"_submission".to_string(),
"Submission data must be a JSON object".to_string(),
);
return Err(json!({ "validation_errors": errors }));
}
};
// Build a map of valid field names to their definitions from the definition for quick lookup
let defined_field_names: HashMap<String, &Map<String, JsonValue>> = field_definitions
.iter()
.filter_map(|val| val.as_object())
.filter_map(|def| {
def.get("name")
.and_then(JsonValue::as_str)
.map(|name| (name.to_string(), def))
})
.collect();
// 1. Check for submitted fields that are NOT in the definition
for submitted_key in data_map.keys() {
if !defined_field_names.contains_key(submitted_key) {
errors.insert(
submitted_key.clone(),
"Unexpected field submitted".to_string(),
);
}
}
// Exit early if unexpected fields were found
if !errors.is_empty() {
log::warn!("Submission validation failed: Unexpected fields submitted.");
return Err(json!({ "validation_errors": errors }));
}
// 2. Iterate through each field definition and validate corresponding submitted data
for (field_name, field_def) in &defined_field_names {
// Extract properties using helper functions for clarity
let field_type = field_def
.get("type")
.and_then(JsonValue::as_str)
.unwrap_or("string"); // Default to "string" if type is missing or not a string
let is_required = field_def
.get("required")
.and_then(JsonValue::as_bool)
.unwrap_or(false); // Default to false if required is missing or not a boolean
let min_length = field_def.get("minLength").and_then(JsonValue::as_u64);
let max_length = field_def.get("maxLength").and_then(JsonValue::as_u64);
let min_value = field_def.get("min").and_then(JsonValue::as_f64); // Use f64 for flexibility
let max_value = field_def.get("max").and_then(JsonValue::as_f64);
let pattern = field_def.get("pattern").and_then(JsonValue::as_str);
match data_map.get(field_name) {
Some(submitted_value) if !submitted_value.is_null() => {
// Field is present and not null, perform type and constraint checks
let mut type_error = None;
let mut constraint_errors = vec![];
match field_type {
"string" | "email" | "url" => {
if let Some(s) = submitted_value.as_str() {
if let Some(min) = min_length {
if (s.chars().count() as u64) < min {
// Use chars().count() for UTF-8 correctness
constraint_errors
.push(format!("Must be at least {} characters long", min));
}
}
if let Some(max) = max_length {
if (s.chars().count() as u64) > max {
constraint_errors.push(format!(
"Must be no more than {} characters long",
max
));
}
}
if let Some(pat) = pattern {
// Consider caching compiled Regex if performance is critical
// and patterns are reused frequently across requests.
match Regex::new(pat) {
Ok(re) => {
if !re.is_match(s) {
constraint_errors.push(format!("Does not match required pattern"));
}
}
Err(e) => log::warn!("Invalid regex pattern '{}' in form definition for field '{}': {}", pat, field_name, e), // Log regex compilation error
}
}
// Specific checks for email/url
if field_type == "email" {
// Basic email regex (adjust for stricter needs or use a validation crate)
// This regex is very basic and allows many technically invalid addresses.
// Consider crates like `validator` for more robust validation.
let email_regex =
Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); // Safe unwrap for known valid regex
if !email_regex.is_match(s) {
constraint_errors
.push("Must be a valid email address".to_string());
}
}
if field_type == "url" {
// Basic URL check (consider `url` crate for robustness)
if url::Url::parse(s).is_err() {
constraint_errors.push("Must be a valid URL".to_string());
}
}
} else {
type_error = Some(format!("Expected a string for '{}'", field_name));
}
}
"number" => {
// Use as_f64 for flexibility (handles integers and floats)
if let Some(num) = submitted_value.as_f64() {
if let Some(min) = min_value {
if num < min {
constraint_errors.push(format!("Must be at least {}", min));
}
}
if let Some(max) = max_value {
if num > max {
constraint_errors.push(format!("Must be no more than {}", max));
}
}
} else {
type_error = Some(format!("Expected a number for '{}'", field_name));
}
}
"boolean" => {
if !submitted_value.is_boolean() {
type_error = Some(format!(
"Expected a boolean (true/false) for '{}'",
field_name
));
}
}
"object" => {
if !submitted_value.is_object() {
type_error =
Some(format!("Expected a JSON object for '{}'", field_name));
}
// TODO: Could add deeper validation for object structure here if needed based on definition
}
"array" => {
if !submitted_value.is_array() {
type_error =
Some(format!("Expected a JSON array for '{}'", field_name));
}
// TODO: Could add validation for array elements here if needed based on definition
}
_ => {
// Log unsupported types during development/debugging if necessary
log::trace!(
"Unsupported field type '{}' encountered during validation for field '{}'. Treating as valid.",
field_type,
field_name
);
// Assume valid if type is not specifically handled or unknown
}
}
// Record errors found for this field
if let Some(err) = type_error {
errors.insert(field_name.clone(), err);
} else if !constraint_errors.is_empty() {
// Combine multiple constraint errors if necessary
errors.insert(field_name.clone(), constraint_errors.join("; "));
}
} // End check for present and non-null value
Some(_) => {
// Value is present but explicitly null (e.g., "fieldName": null)
if is_required {
errors.insert(
field_name.clone(),
"This field is required and cannot be null".to_string(),
);
}
// Otherwise, null is considered a valid (empty) value for non-required fields
}
None => {
// Field is missing entirely from the submission object
if is_required {
errors.insert(field_name.clone(), "This field is required".to_string());
}
// Missing is valid for non-required fields
}
} // End match data_map.get(field_name)
} // End loop through field definitions
// Check if any errors were collected
if errors.is_empty() {
Ok(()) // Validation passed
} else {
log::info!(
"Submission validation failed with {} error(s).", // Log only the count for brevity
errors.len()
);
// Return a JSON object containing the specific validation errors
Err(json!({ "validation_errors": errors }))
}
}
// Helper function to convert anyhow::Error to actix_web::Error
fn anyhow_to_actix_error(e: anyhow::Error) -> ActixWebError {
actix_web::error::ErrorInternalServerError(e.to_string())
}
// --- Public Handlers ---
// POST /login
pub async fn login(
app_state: web::Data<AppState>, // Expect AppState like other handlers
creds: web::Json<LoginCredentials>,
) -> ActixResult<impl Responder> {
// Clone the Arc<Mutex<Connection>> from AppState
let db_conn_arc = app_state.db.clone();
let username = creds.username.clone();
let password = creds.password.clone();
// Wrap the blocking database operations in web::block
let auth_result = web::block(move || {
// Use the cloned Arc here
let conn = db_conn_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?;
crate::db::authenticate_user(&conn, &username, &password)
})
.await
.map_err(|e| {
log::error!("web::block error during authentication: {:?}", e);
actix_web::error::ErrorInternalServerError("Authentication process failed (blocking error)")
})?
.map_err(anyhow_to_actix_error)?;
match auth_result {
Some(user_data) => {
// Clone Arc again for token generation, using the AppState db field
let db_conn_token_arc = app_state.db.clone();
let user_id = user_data.id.clone();
// Generate and store a new token within web::block
let token = web::block(move || {
// Use the cloned Arc here
let conn = db_conn_token_arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?;
crate::db::generate_and_set_token_for_user(&conn, &user_id)
})
.await
.map_err(|e| {
log::error!("web::block error during token generation: {:?}", e);
actix_web::error::ErrorInternalServerError(
"Failed to complete login (token generation blocking error)",
)
})?
.map_err(anyhow_to_actix_error)?;
log::info!("Login successful for user_id: {}", user_data.id);
Ok(HttpResponse::Ok().json(LoginResponse { token }))
}
None => {
log::warn!("Login failed for username: {}", creds.username);
// Return 401 Unauthorized for failed login attempts
Err(actix_web::error::ErrorUnauthorized(
"Invalid username or password",
))
}
}
}
// POST /logout
pub async fn logout(
app_state: web::Data<AppState>, // Expect AppState
auth: Auth, // Requires authentication (extracts user_id from token)
) -> ActixResult<impl Responder> {
log::info!("User {} requesting logout", auth.user_id);
let db_conn_arc = app_state.db.clone(); // Get db from AppState
let user_id = auth.user_id.clone();
// Invalidate the token in the database within web::block
web::block(move || {
let conn = db_conn_arc // Use the cloned Arc
.lock()
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?;
crate::db::invalidate_token(&conn, &user_id)
})
.await
.map_err(|e| {
// Use the original auth.user_id here as user_id moved into the block
log::error!(
"web::block error during logout for user {}: {:?}",
auth.user_id,
e
);
actix_web::error::ErrorInternalServerError("Logout failed (blocking error)")
})?
.map_err(anyhow_to_actix_error)?;
log::info!("User {} logged out successfully", auth.user_id);
Ok(HttpResponse::Ok().json(json!({ "message": "Logged out successfully" })))
}
// POST /forms/{form_id}/submissions
pub async fn submit_form(
app_state: web::Data<AppState>,
path: web::Path<String>, // Extracts form_id from path
submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload
) -> ActixResult<impl Responder> {
let form_id = path.into_inner();
let conn = app_state.db.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
// Get form definition
let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?;
// Validate submission against form definition
if let Err(validation_errors) =
validate_submission_against_definition(&submission_payload, &form.fields)
{
return Ok(HttpResponse::BadRequest().json(validation_errors));
}
// Create submission record
let submission = Submission {
id: Uuid::new_v4().to_string(),
form_id: form_id.clone(),
data: submission_payload.into_inner(),
created_at: chrono::Utc::now(),
};
// Save submission to database
submission.save(&conn).map_err(|e| {
log::error!("Failed to save submission: {}", e);
actix_web::error::ErrorInternalServerError("Failed to save submission")
})?;
// Send notifications if configured
if let Some(notify_email) = form.notify_email {
let email_subject = format!("New submission for form: {}", form.name);
let email_body = format!(
"A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}",
form.name,
submission.id,
submission.created_at,
serde_json::to_string_pretty(&submission.data).unwrap_or_default()
);
if let Err(e) = app_state
.notification_service
.send_email(&notify_email, &email_subject, &email_body)
.await
{
log::warn!("Failed to send email notification: {}", e);
}
// Also send ntfy notification if configured (sends to the global topic)
if let Some(topic_flag) = &form.notify_ntfy_topic {
// Use field presence as a flag
if !topic_flag.is_empty() {
// Check if the flag string is non-empty
let ntfy_title = format!("New submission for: {}", form.name);
let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id);
if let Err(e) = app_state.notification_service.send_ntfy(
&ntfy_title,
&ntfy_message,
Some(3), // Medium priority
) {
log::warn!("Failed to send ntfy notification (global topic): {}", e);
}
}
}
}
Ok(HttpResponse::Created().json(json!({
"message": "Submission received",
"submission_id": submission.id
})))
}
// POST /forms
pub async fn create_form(
app_state: web::Data<AppState>,
_auth: Auth, // Authentication check via Auth extractor
payload: web::Json<serde_json::Value>,
) -> ActixResult<impl Responder> {
let payload = payload.into_inner();
// Extract form data from payload
let name = payload["name"]
.as_str()
.ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))?
.to_string();
let fields = payload["fields"].clone();
if !fields.is_array() {
return Err(actix_web::error::ErrorBadRequest(
"'fields' must be a JSON array",
));
}
let notify_email = payload["notify_email"].as_str().map(|s| s.to_string());
let notify_ntfy_topic = payload["notify_ntfy_topic"].as_str().map(|s| s.to_string());
// Create new form
let form = Form {
id: None, // Will be generated during save
name,
fields,
notify_email,
notify_ntfy_topic,
created_at: chrono::Utc::now(),
};
// Save the form
let conn = app_state.db.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
form.save(&conn).map_err(|e| {
log::error!("Failed to save form: {}", e);
actix_web::error::ErrorInternalServerError("Failed to save form")
})?;
Ok(HttpResponse::Created().json(form))
}
// GET /forms
pub async fn get_forms(
app_state: web::Data<AppState>,
auth: Auth, // Requires authentication
) -> ActixResult<impl Responder> {
log::info!("User {} requesting list of forms", auth.user_id);
let conn = app_state.db.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
let mut stmt = conn
.prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms")
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
let forms_iter = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let fields_str: String = row.get(2)?;
let notify_email: Option<String> = row.get(3)?;
let notify_ntfy_topic: Option<String> = row.get(4)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(5)?;
// Parse the 'fields' JSON string
let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| {
log::error!(
"DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.",
id,
e
);
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
Ok(Form {
id: Some(id),
name,
fields,
notify_email,
notify_ntfy_topic,
created_at,
})
})
.map_err(|e| {
log::error!("Failed to execute query: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
// Collect results, filtering out rows that failed parsing
let forms: Vec<Form> = forms_iter
.filter_map(|result| match result {
Ok(form) => Some(form),
Err(e) => {
log::warn!("Skipping a form row due to a processing error: {}", e);
None
}
})
.collect();
log::debug!("Returning {} forms for user {}", forms.len(), auth.user_id);
Ok(HttpResponse::Ok().json(forms))
}
// GET /forms/{form_id}/submissions
pub async fn get_submissions(
app_state: web::Data<AppState>,
auth: Auth, // Requires authentication
path: web::Path<String>, // Extracts form_id from the path
) -> ActixResult<impl Responder> {
let form_id = path.into_inner();
log::info!(
"User {} requesting submissions for form_id: {}",
auth.user_id,
form_id
);
let conn = app_state.db.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
// Check if the form exists
let _form = Form::get_by_id(&conn, &form_id).map_err(|e| {
if e.to_string().contains("not found") {
actix_web::error::ErrorNotFound("Form not found")
} else {
actix_web::error::ErrorInternalServerError("Database error")
}
})?;
// Get submissions
let mut stmt = conn
.prepare(
"SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC",
)
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
let submissions_iter = stmt
.query_map(params![form_id], |row| {
let id: String = row.get(0)?;
let form_id: String = row.get(1)?;
let data_str: String = row.get(2)?;
let created_at: chrono::DateTime<chrono::Utc> = row.get(3)?;
let data: serde_json::Value = serde_json::from_str(&data_str).map_err(|e| {
log::error!(
"DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.",
id,
e
);
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
Ok(Submission {
id,
form_id,
data,
created_at,
})
})
.map_err(|e| {
log::error!("Failed to execute query: {}", e);
actix_web::error::ErrorInternalServerError("Database error")
})?;
let submissions: Vec<Submission> = submissions_iter
.filter_map(|result| match result {
Ok(submission) => Some(submission),
Err(e) => {
log::warn!("Skipping a submission row due to processing error: {}", e);
None
}
})
.collect();
log::debug!(
"Returning {} submissions for form {} requested by user {}",
submissions.len(),
form_id,
auth.user_id
);
Ok(HttpResponse::Ok().json(submissions))
}
// --- Notification Settings Handlers ---
// GET /forms/{form_id}/notifications
pub async fn get_notification_settings(
app_state: web::Data<AppState>,
auth: Auth, // Requires authentication
path: web::Path<String>,
) -> ActixResult<impl Responder> {
let form_id = path.into_inner();
log::info!(
"User {} requesting notification settings for form_id: {}",
auth.user_id,
form_id
);
let conn = app_state.db.lock().map_err(|e| {
log::error!(
"Failed to acquire database lock for get_notification_settings: {}",
e
);
actix_web::error::ErrorInternalServerError("Database error")
})?;
// Get the form to ensure it exists and retrieve current settings
let form = Form::get_by_id(&conn, &form_id).map_err(|e| {
log::warn!(
"Attempt to get settings for non-existent form {}: {}",
form_id,
e
);
if e.to_string().contains("not found") {
actix_web::error::ErrorNotFound("Form not found")
} else {
actix_web::error::ErrorInternalServerError("Database error retrieving form")
}
})?;
let settings = crate::models::NotificationSettingsPayload {
notify_email: form.notify_email,
notify_ntfy_topic: form.notify_ntfy_topic,
};
Ok(HttpResponse::Ok().json(settings))
}
// PUT /forms/{form_id}/notifications
pub async fn update_notification_settings(
app_state: web::Data<AppState>,
auth: Auth, // Requires authentication
path: web::Path<String>,
payload: web::Json<crate::models::NotificationSettingsPayload>,
) -> ActixResult<impl Responder> {
let form_id = path.into_inner();
let new_settings = payload.into_inner();
log::info!(
"User {} updating notification settings for form_id: {}. Settings: {:?}",
auth.user_id,
form_id,
new_settings
);
let conn = app_state.db.lock().map_err(|e| {
log::error!(
"Failed to acquire database lock for update_notification_settings: {}",
e
);
actix_web::error::ErrorInternalServerError("Database error")
})?;
// Fetch the existing form to update it
let mut form = Form::get_by_id(&conn, &form_id).map_err(|e| {
log::warn!(
"Attempt to update settings for non-existent form {}: {}",
form_id,
e
);
if e.to_string().contains("not found") {
actix_web::error::ErrorNotFound("Form not found")
} else {
actix_web::error::ErrorInternalServerError("Database error retrieving form")
}
})?;
// Update the form fields
form.notify_email = new_settings.notify_email;
form.notify_ntfy_topic = new_settings.notify_ntfy_topic;
// Save the updated form
form.save(&conn).map_err(|e| {
log::error!(
"Failed to save updated notification settings for form {}: {}",
form_id,
e
);
actix_web::error::ErrorInternalServerError("Failed to save notification settings")
})?;
log::info!(
"Successfully updated notification settings for form {}",
form_id
);
Ok(HttpResponse::Ok().json(json!({ "message": "Notification settings updated successfully" })))
}
pub async fn health_check() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}

View File

@ -1,241 +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;
use tracing::{error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// Import modules
mod auth;
mod db;
mod handlers;
mod models;
mod notifications;
use notifications::{NotificationConfig, NotificationService};
// Application state that will be shared across all routes
pub struct AppState {
db: Arc<Mutex<rusqlite::Connection>>,
notification_service: Arc<NotificationService>,
}
#[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));
// Create AppState with both database and notification service
let app_state = web::Data::new(AppState {
db: Arc::new(Mutex::new(db_connection)),
notification_service: notification_service.clone(),
});
info!("Starting server at http://{}", bind_address);
HttpServer::new(move || {
let app_state = app_state.clone();
let allowed_origins = allowed_origins_list.clone();
let rate_limiter = RateLimiter::new(limiter_data.clone());
// Configure CORS
let cors = if !allowed_origins.is_empty() {
info!("Configuring CORS for origins: {:?}", allowed_origins);
let mut cors = Cors::default();
for origin in allowed_origins {
cors = cors.allowed_origin(&origin); // Add each origin
}
cors.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
.allowed_headers(vec![
header::AUTHORIZATION,
header::ACCEPT,
header::CONTENT_TYPE,
header::ORIGIN,
header::ACCESS_CONTROL_REQUEST_METHOD,
header::ACCESS_CONTROL_REQUEST_HEADERS,
])
.supports_credentials()
.max_age(3600)
} else {
warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set.");
Cors::default() // Keep restrictive default if no origins are provided
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
.allowed_headers(vec![
header::AUTHORIZATION,
header::ACCEPT,
header::CONTENT_TYPE,
header::ORIGIN,
header::ACCESS_CONTROL_REQUEST_METHOD,
header::ACCESS_CONTROL_REQUEST_HEADERS,
])
.supports_credentials()
.max_age(3600)
};
App::new()
.wrap(cors)
.wrap(Logger::default())
.wrap(tracing_actix_web::TracingLogger::default())
.wrap(rate_limiter)
.app_data(app_state)
.service(
web::scope("/api")
// Health check endpoint
.route("/health", web::get().to(handlers::health_check))
// Public routes
.route("/login", web::post().to(handlers::login))
.route(
"/forms/{form_id}/submissions",
web::post().to(handlers::submit_form),
)
// Protected routes
.route("/logout", web::post().to(handlers::logout))
.route("/forms", web::post().to(handlers::create_form))
.route("/forms", web::get().to(handlers::get_forms))
.route(
"/forms/{form_id}/submissions",
web::get().to(handlers::get_submissions),
)
.route(
"/forms/{form_id}/notifications",
web::get().to(handlers::get_notification_settings),
)
.route(
"/forms/{form_id}/notifications",
web::put().to(handlers::update_notification_settings),
),
)
.service(
fs::Files::new("/", "./frontend/")
.index_file("index.html")
.use_last_modified(true)
.default_handler(fs::NamedFile::open("./frontend/index.html").unwrap_or_else(
|_| {
error!("Fallback file not found: ../frontend/index.html");
process::exit(1);
},
)),
)
})
.bind(&bind_address)?
.run()
.await
}

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,76 +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 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>,
}

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

@ -0,0 +1,434 @@
const bcrypt = require("bcryptjs");
const crypto = require("crypto");
// const { v4: uuidv4 } = require("uuid"); // UUIDs will be generated by PostgreSQL
const pool = require("../config/database"); // db is now the pg Pool
const logger = require("../../config/logger"); // Corrected logger path
class User {
// No need for _run, _get, _all as pool.query returns a promise with a consistent result object.
// Create a new user
static async create(userData) {
const {
email,
password,
first_name,
last_name,
role = "user",
is_verified = false, // PostgreSQL uses true/false for BOOLEAN
} = userData;
const saltRounds = 12;
const password_hash = await bcrypt.hash(password, saltRounds);
const verification_token = crypto.randomBytes(32).toString("hex");
// UUID is generated by DB default (gen_random_uuid())
const query = `
INSERT INTO users (email, password_hash, first_name, last_name, role, is_verified, verification_token, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id, uuid, email, first_name, last_name, role, is_verified, verification_token;
`;
const values = [
email,
password_hash,
first_name,
last_name,
role,
is_verified,
verification_token,
];
try {
const result = await pool.query(query, values);
return result.rows[0]; // Returns the newly created user data including id and uuid
} catch (error) {
// PostgreSQL error codes: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (error.code === "23505") {
// unique_violation
if (error.constraint === "users_email_key") {
// Or whatever your unique constraint name for email is
throw new Error("Email already exists");
}
// Potentially other unique constraints like users_uuid_key if not handled by default generation
}
logger.error("Error creating user:", error);
throw error;
}
}
// Find user by email
static async findByEmail(email) {
const query = "SELECT * FROM users WHERE email = $1 AND is_active = TRUE";
const { rows } = await pool.query(query, [email]);
return rows[0];
}
// Find user by ID
static async findById(id) {
const query = "SELECT * FROM users WHERE id = $1 AND is_active = TRUE";
const { rows } = await pool.query(query, [id]);
return rows[0];
}
// Find user by UUID
static async findByUuid(uuid) {
const query = "SELECT * FROM users WHERE uuid = $1 AND is_active = TRUE";
const { rows } = await pool.query(query, [uuid]);
return rows[0];
}
// Find user by verification token
static async findByVerificationToken(token) {
const query = "SELECT * FROM users WHERE verification_token = $1";
const { rows } = await pool.query(query, [token]);
return rows[0];
}
// Find user by password reset token
static async findByPasswordResetToken(token) {
const query = `
SELECT * FROM users
WHERE password_reset_token = $1
AND password_reset_expires > NOW()
AND is_active = TRUE
`;
const { rows } = await pool.query(query, [token]);
return rows[0];
}
// Verify email
static async verifyEmail(token) {
const query = `
UPDATE users
SET is_verified = TRUE, verification_token = NULL -- updated_at is handled by trigger
WHERE verification_token = $1
RETURNING id;
`;
const result = await pool.query(query, [token]);
return result.rowCount > 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 = $1, password_reset_token = NULL, password_reset_expires = NULL -- updated_at handled by trigger
WHERE id = $2
`;
const result = await pool.query(query, [password_hash, id]);
return result.rowCount > 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 = $1,
must_change_password = FALSE,
password_reset_token = NULL,
password_reset_expires = NULL -- updated_at handled by trigger
WHERE id = $2
`;
const result = await pool.query(query, [password_hash, id]);
return result.rowCount > 0;
}
// Set password reset token
static async setPasswordResetToken(email) {
const token = crypto.randomBytes(32).toString("hex");
// PostgreSQL TIMESTAMPTZ handles timezone conversion, interval syntax is cleaner
const expires = new Date(Date.now() + 3600000); // Still use JS Date for interval calculation
const query = `
UPDATE users
SET password_reset_token = $1, password_reset_expires = $2 -- updated_at handled by trigger
WHERE email = $3 AND is_active = TRUE
RETURNING id;
`;
const result = await pool.query(query, [token, expires, email]);
if (result.rowCount > 0) {
return { token, expires };
}
return null;
}
// Increment failed login attempts
static async incrementFailedLoginAttempts(id) {
const query = `
UPDATE users
SET failed_login_attempts = failed_login_attempts + 1,
account_locked_until = CASE
WHEN failed_login_attempts >= 4 THEN NOW() + interval '30 minutes'
ELSE account_locked_until
END -- updated_at handled by trigger
WHERE id = $1
`;
await pool.query(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 handled by trigger
WHERE id = $1
`;
await pool.query(query, [id]);
}
// Update last login
static async updateLastLogin(id) {
const query = "UPDATE users SET last_login = NOW() WHERE id = $1"; // updated_at handled by trigger
await pool.query(query, [id]);
}
// Deactivate user account
static async deactivateUser(id) {
const query = "UPDATE users SET is_active = FALSE WHERE id = $1"; // updated_at handled by trigger
const result = await pool.query(query, [id]);
return result.rowCount > 0;
}
// Activate user account
static async activateUser(id) {
const query = "UPDATE users SET is_active = TRUE WHERE id = $1"; // updated_at handled by trigger
const result = await pool.query(query, [id]);
return result.rowCount > 0;
}
// Update user profile
static async updateProfile(id, updates) {
const allowedFields = ["first_name", "last_name", "email"];
const fieldsToUpdate = [];
const values = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key) && value !== undefined) {
// Use double quotes for field names if they might be reserved words, though not strictly necessary here
fieldsToUpdate.push(`\"${key}\" = $${paramIndex++}`);
values.push(value);
}
}
if (fieldsToUpdate.length === 0) {
return false; // No valid fields to update
}
values.push(id); // Add id as the last parameter for the WHERE clause
const query = `
UPDATE users
SET ${fieldsToUpdate.join(", ")}
WHERE id = $${paramIndex}
RETURNING *;
`;
// updated_at is handled by the trigger
try {
const result = await pool.query(query, values);
return result.rows[0]; // Return the updated user object
} catch (error) {
if (error.code === "23505" && error.constraint === "users_email_key") {
throw new Error("Email already exists");
}
logger.error("Error updating user profile:", error);
throw error;
}
}
// Get all users (with pagination and optional filters)
static async findAll(page = 1, limit = 20, filters = {}) {
let query =
"SELECT id, uuid, email, first_name, last_name, role, is_verified, is_active, last_login, created_at, updated_at FROM users";
const countQuery = "SELECT COUNT(*) FROM users";
const queryParams = [];
const filterClauses = [];
let paramIndex = 1;
if (filters.role) {
filterClauses.push(`role = $${paramIndex++}`);
queryParams.push(filters.role);
}
if (filters.is_active !== undefined) {
filterClauses.push(`is_active = $${paramIndex++}`);
queryParams.push(filters.is_active);
}
// Add more filters as needed
if (filterClauses.length > 0) {
query += " WHERE " + filterClauses.join(" AND ");
// Note: countQuery would also need the WHERE clause. This can get complex.
// For simplicity, the count query here doesn't include filters. Consider a more robust way if filters are common.
}
query += ` ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
const offset = (page - 1) * limit;
queryParams.push(limit, offset);
const { rows } = await pool.query(query, queryParams);
// For total count, you might need a separate query without limit/offset but with filters
// const totalResult = await pool.query(countQuery); // Potentially with filter conditions
// const total = parseInt(totalResult.rows[0].count, 10);
// For now, returning rows without total count for simplicity to match old behavior more closely
return rows;
}
// --- User Session Management (Example methods, adjust as needed) ---
static async saveSession(
userId,
tokenJti,
expiresAt,
userAgent = null,
ipAddress = null
) {
// expiresAt should be a Date object or a string PostgreSQL can parse
const query = `
INSERT INTO user_sessions (user_id, token_jti, expires_at, user_agent, ip_address, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
RETURNING id;
`;
const values = [userId, tokenJti, expiresAt, userAgent, ipAddress];
const result = await pool.query(query, values);
return result.rows[0].id;
}
static async isTokenBlacklisted(tokenJti) {
const query =
"SELECT 1 FROM user_sessions WHERE token_jti = $1 AND expires_at > NOW()";
const { rows } = await pool.query(query, [tokenJti]);
return rows.length > 0;
}
static async revokeSession(tokenJti) {
// Or, update expires_at to NOW() if you prefer not to delete
const query = "DELETE FROM user_sessions WHERE token_jti = $1";
const result = await pool.query(query, [tokenJti]);
return result.rowCount > 0;
}
static async revokeAllUserSessions(userId) {
const query = "DELETE FROM user_sessions WHERE user_id = $1";
const result = await pool.query(query, [userId]);
return result.rowCount > 0;
}
static async revokeAllUserSessionsExcept(userId, exceptJti) {
const query =
"DELETE FROM user_sessions WHERE user_id = $1 AND token_jti != $2";
const result = await pool.query(query, [userId, exceptJti]);
return result.rowCount > 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 = $1 AND expires_at > NOW() ORDER BY created_at DESC";
const { rows } = await pool.query(query, [userId]);
return rows;
}
static async getSessionByJti(jti) {
const query = "SELECT * FROM user_sessions WHERE token_jti = $1";
const { rows } = await pool.query(query, [jti]);
return rows[0];
}
static async cleanupExpiredSessions() {
const query = "DELETE FROM user_sessions WHERE expires_at <= NOW()";
const result = await pool.query(query);
logger.info(`Cleaned up ${result.rowCount} expired user sessions.`);
return result.rowCount;
}
// --- API Key Management (Example methods, needs hashing for api_key_secret) ---
static async createApiKey(userId, keyName, daysUntilExpiry = null) {
const apiKeyIdentifier = crypto.randomBytes(16).toString("hex"); // Public part
const apiKeySecret = crypto.randomBytes(32).toString("hex"); // Secret part, show ONCE to user
// IMPORTANT: You MUST hash the apiKeySecret before storing it.
// Use a strong, one-way hashing algorithm like bcrypt or scrypt.
// This example will store it directly for simplicity, but DO NOT do this in production.
const saltRounds = 12; // Or appropriate for your chosen hashing algorithm
const hashedApiKeySecret = await bcrypt.hash(apiKeySecret, saltRounds);
let expiresAt = null;
if (daysUntilExpiry) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + daysUntilExpiry);
}
const query = `
INSERT INTO api_keys (user_id, key_name, api_key_identifier, hashed_api_key_secret, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
RETURNING id, uuid, api_key_identifier, created_at, expires_at;
`;
const values = [
userId,
keyName,
apiKeyIdentifier,
hashedApiKeySecret,
expiresAt,
];
try {
const result = await pool.query(query, values);
return { ...result.rows[0], apiKeySecret }; // Return the raw secret ONCE for the user to copy
} catch (error) {
if (error.code === "23505") {
// unique_violation
// Handle if api_key_identifier somehow collides, though highly unlikely
logger.error("API Key identifier collision:", error);
}
logger.error("Error creating API key:", error);
throw error;
}
}
static async findApiKeyByIdentifier(identifier) {
const query = "SELECT * FROM api_keys WHERE api_key_identifier = $1";
const { rows } = await pool.query(query, [identifier]);
return rows[0]; // This will include the hashed_api_key_secret
}
// Call this after a key is used successfully
static async updateApiKeyLastUsed(apiKeyId) {
const query = "UPDATE api_keys SET last_used_at = NOW() WHERE id = $1";
await pool.query(query, [apiKeyId]);
}
static async getUserApiKeys(userId) {
// Do NOT return hashed_api_key_secret to the user, only metadata
const query =
"SELECT id, uuid, user_id, key_name, api_key_identifier, created_at, last_used_at, expires_at FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC";
const { rows } = await pool.query(query, [userId]);
return rows;
}
static async revokeApiKey(apiKeyId, userId) {
// Ensure the user owns this API key before revoking
const query = "DELETE FROM api_keys WHERE id = $1 AND user_id = $2";
const result = await pool.query(query, [apiKeyId, userId]);
return result.rowCount > 0;
}
// Placeholder for user stats - adjust query as needed for form/submission counts
static async getUserStats(userId) {
// This is a simplified example. You'd need to join with forms and submissions tables.
const query = `
SELECT
(SELECT COUNT(*) FROM forms WHERE user_id = $1) as form_count,
(SELECT COUNT(*) FROM submissions WHERE user_id = $1) as submission_count
-- Add more stats as needed
`;
// This query assumes user_id is directly on submissions. Adjust if form_uuid is the link.
const { rows } = await pool.query(query, [userId]);
return rows[0] || { form_count: 0, submission_count: 0 };
}
}
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());
}
}

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

@ -0,0 +1,98 @@
const express = require("express");
const pool = require("../config/database");
const apiAuthMiddleware = require("../middleware/apiAuthMiddleware");
const logger = require("../../config/logger");
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 { rows: 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 = $1
ORDER BY f.created_at DESC`,
[req.user.id] // req.user.id is attached by apiAuthMiddleware
);
res.json({ success: true, forms });
} catch (error) {
logger.error("API Error fetching forms for user:", {
userId: 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 {
const { rows: formDetailsRows } = await pool.query(
"SELECT user_id, name FROM forms WHERE uuid = $1",
[formUuid]
);
if (formDetailsRows.length === 0) {
return res.status(404).json({ success: false, error: "Form not found." });
}
const formDetails = formDetailsRows[0];
if (formDetails.user_id !== req.user.id) {
logger.warn(
`API Access Denied: User ${req.user.id} attempted to access form ${formUuid} owned by ${formDetails.user_id}`
);
return res.status(403).json({
success: false,
error: "Access denied. You do not own this form.",
});
}
const { rows: countResultRows } = await pool.query(
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = $1",
[formUuid]
);
const totalSubmissions = parseInt(countResultRows[0].total, 10);
const totalPages = Math.ceil(totalSubmissions / limit);
const { rows: submissions } = await pool.query(
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = $1 ORDER BY submitted_at DESC LIMIT $2 OFFSET $3",
[formUuid, limit, offset]
);
res.json({
success: true,
formName: formDetails.name,
formUuid,
pagination: {
currentPage: page,
totalPages: totalPages,
totalSubmissions: totalSubmissions,
limit: limit,
perPage: limit, // Alias for limit
count: submissions.length,
},
submissions,
});
} catch (error) {
logger.error("API Error fetching submissions for form:", {
formUuid,
userId: 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;

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

@ -0,0 +1,671 @@
const express = require("express");
const pool = require("../config/database"); // pg Pool
const { requireAuth } = require("../middleware/authMiddleware");
const { v4: uuidv4 } = require("uuid"); // Retained for now, though new form UUIDs could be DB generated
const { sendNtfyNotification } = require("../services/notification");
const User = require("../models/User"); // For API Key management
const logger = require("../../config/logger"); // Corrected logger path
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 { rows: 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 = $1
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",
pageTitle: "My Forms",
});
} catch (error) {
logger.error("Error fetching user forms:", error);
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",
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(); // UUID will be generated by DB if schema is set up for it
try {
// Assuming forms.uuid has DEFAULT gen_random_uuid()
const {
rows: [newForm],
} = await pool.query(
"INSERT INTO forms (name, user_id) VALUES ($1, $2) RETURNING uuid, name",
[formName, req.user.id]
);
logger.info(
`Form created: ${newForm.name} with UUID: ${newForm.uuid} for user: ${req.user.id}`
);
if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
try {
await sendNtfyNotification(
"New Form Created (User)",
`Form \"${newForm.name}\" (UUID: ${newForm.uuid}) was created by user ${req.user.email}.`,
"high"
);
} catch (ntfyError) {
logger.error(
"Failed to send ntfy notification for new form creation:",
ntfyError
);
}
}
res.redirect("/dashboard");
} catch (error) {
logger.error("Error creating form for user:", error);
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,
});
}
});
// 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;
const offset = (page - 1) * limit;
try {
const { rows: formDetailsRows } = await pool.query(
"SELECT name, user_id FROM forms WHERE uuid = $1",
[formUuid]
);
if (formDetailsRows.length === 0) {
return res.render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Form Not Found",
error: "The form you are looking for does not exist.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
const formDetails = formDetailsRows[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 view submissions for this form.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
const formName = formDetails.name;
const { rows: countResultRows } = await pool.query(
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = $1",
[formUuid]
);
const totalSubmissions = parseInt(countResultRows[0].total, 10);
const totalPages = Math.ceil(totalSubmissions / limit);
const { rows: submissions } = await pool.query(
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = $1 ORDER BY submitted_at DESC LIMIT $2 OFFSET $3",
[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) {
logger.error(
"Error fetching submissions for form:",
formUuid,
"user:",
req.user.id,
error
);
res.render("dashboard", {
user: req.user,
view: "form_submissions",
pageTitle: "Error Loading Submissions",
error:
"Could not load submissions for this form. Please try again later.",
formUuid: formUuid,
formName: "Error",
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 {
const { rows: formDetailsRows } = await pool.query(
"SELECT name, user_id FROM forms WHERE uuid = $1",
[formUuid]
);
if (formDetailsRows.length === 0) {
return res.status(404).send("Form not found.");
}
const formDetails = formDetailsRows[0];
if (formDetails.user_id !== req.user.id) {
return res.status(403).send("Access denied. You do not own this form.");
}
const formName = formDetails.name;
const { rows: submissions } = await pool.query(
"SELECT data, ip_address, submitted_at FROM submissions WHERE form_uuid = $1 ORDER BY submitted_at DESC",
[formUuid]
);
// Create CSV content
let headers = ["Submitted At", "IP Address"]; // Initialize with default headers
const dataRows = submissions.map((submission) => {
// Ensure submission.data is parsed if it's a JSON string, or used directly if already an object
let data = {};
if (typeof submission.data === "string") {
try {
data = JSON.parse(submission.data);
} catch (e) {
logger.warn(
`Failed to parse submission data for form ${formUuid}, submission ID ${submission.id}: ${submission.data}`
);
// Potentially include raw data or an error placeholder
data = { error_parsing_data: submission.data };
}
} else if (
typeof submission.data === "object" &&
submission.data !== null
) {
data = submission.data;
} else {
logger.warn(
`Unexpected submission data format for form ${formUuid}, submission ID ${submission.id}:`,
submission.data
);
data = { unexpected_data_format: String(submission.data) };
}
// Dynamically add keys from parsed data to headers, ensuring no duplicates
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,
};
});
let csvContent = headers.join(",") + "\n";
dataRows.forEach((row) => {
const values = headers.map((header) => {
const value =
row[header] === null || row[header] === undefined ? "" : row[header];
return `"${String(value).replace(/"/g, '""')}"`;
});
csvContent += values.join(",") + "\n";
});
res.header("Content-Type", "text/csv");
res.attachment(
`submissions-${formName.replace(/\s+/g, "_")}-${formUuid}.csv`
);
res.send(csvContent);
} catch (error) {
logger.error("Error exporting submissions:", 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 { rows: formRows } = await pool.query(
"SELECT * FROM forms WHERE uuid = $1 AND user_id = $2",
[formUuid, req.user.id]
);
if (formRows.length === 0) {
return res.status(404).render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Not Found",
error: "Form not found or you do not have permission to access it.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
res.render("dashboard", {
user: req.user,
form: formRows[0],
appUrl: `${req.protocol}://${req.get("host")}`,
view: "form_settings",
pageTitle: `Settings for ${formRows[0].name}`,
});
} catch (error) {
logger.error("Error fetching form settings:", error);
res.status(500).render("dashboard", {
user: req.user,
view: "my_forms",
pageTitle: "Error",
error: "Error fetching form settings.",
appUrl: `${req.protocol}://${req.get("host")}`,
forms: [],
});
}
});
// POST /dashboard/forms/:formUuid/settings - Update form settings
router.post("/forms/:formUuid/settings", async (req, res) => {
const { formUuid } = req.params;
const {
formName,
thankYouUrl,
thankYouMessage,
ntfyEnabled,
allowedDomains,
emailNotificationsEnabled,
notificationEmailAddress,
recaptchaEnabled,
} = req.body;
try {
// Verify user owns the form first
const { rows: formCheckRows } = await pool.query(
"SELECT user_id FROM forms WHERE uuid = $1",
[formUuid]
);
if (
formCheckRows.length === 0 ||
formCheckRows[0].user_id !== req.user.id
) {
return res.status(403).send("Access denied or form not found.");
}
// Construct the update query dynamically to only update provided fields
const updates = [];
const values = [];
let paramIndex = 1;
if (formName !== undefined) {
updates.push(`name = $${paramIndex++}`);
values.push(formName);
}
if (thankYouUrl !== undefined) {
updates.push(`thank_you_url = $${paramIndex++}`);
values.push(thankYouUrl || null);
}
if (thankYouMessage !== undefined) {
updates.push(`thank_you_message = $${paramIndex++}`);
values.push(thankYouMessage || null);
}
if (ntfyEnabled !== undefined) {
updates.push(`ntfy_enabled = $${paramIndex++}`);
values.push(ntfyEnabled === "on" || ntfyEnabled === true);
}
if (allowedDomains !== undefined) {
updates.push(`allowed_domains = $${paramIndex++}`);
values.push(allowedDomains || null);
}
if (emailNotificationsEnabled !== undefined) {
updates.push(`email_notifications_enabled = $${paramIndex++}`);
values.push(
emailNotificationsEnabled === "on" || emailNotificationsEnabled === true
);
}
if (notificationEmailAddress !== undefined) {
updates.push(`notification_email_address = $${paramIndex++}`);
values.push(notificationEmailAddress || null);
}
if (recaptchaEnabled !== undefined) {
updates.push(`recaptcha_enabled = $${paramIndex++}`);
values.push(recaptchaEnabled === "on" || recaptchaEnabled === true);
}
if (updates.length === 0) {
return res.redirect(
`/dashboard/forms/${formUuid}/settings?success=No changes detected`
);
}
values.push(formUuid);
values.push(req.user.id);
const query = `UPDATE forms SET ${updates.join(", ")}, updated_at = NOW() WHERE uuid = $${paramIndex++} AND user_id = $${paramIndex++}`;
const { rowCount } = await pool.query(query, values);
if (rowCount > 0) {
res.redirect(
`/dashboard/forms/${formUuid}/settings?success=Form settings updated successfully.`
);
} else {
// This case should ideally not happen if the form ownership check passed
res.redirect(
`/dashboard/forms/${formUuid}/settings?error=Failed to update settings or no changes made.`
);
}
} catch (error) {
logger.error("Error updating form settings:", error);
res.redirect(
`/dashboard/forms/${formUuid}/settings?error=Error updating form settings.`
);
}
});
// POST /dashboard/forms/:formUuid/archive - Archive a form
router.post("/forms/:formUuid/archive", async (req, res) => {
const { formUuid } = req.params;
try {
const { rowCount } = await pool.query(
"UPDATE forms SET is_archived = TRUE, updated_at = NOW() WHERE uuid = $1 AND user_id = $2",
[formUuid, req.user.id]
);
if (rowCount > 0) {
res.redirect("/dashboard?archived=true");
} else {
res.status(404).send("Form not found or not owned by user.");
}
} catch (error) {
logger.error("Error archiving form:", error);
res.status(500).send("Error archiving form.");
}
});
// POST /dashboard/forms/:formUuid/unarchive - Unarchive a form
router.post("/forms/:formUuid/unarchive", async (req, res) => {
const { formUuid } = req.params;
try {
const { rowCount } = await pool.query(
"UPDATE forms SET is_archived = FALSE, updated_at = NOW() WHERE uuid = $1 AND user_id = $2",
[formUuid, req.user.id]
);
if (rowCount > 0) {
res.redirect("/dashboard?unarchived=true");
} else {
res.status(404).send("Form not found or not owned by user.");
}
} catch (error) {
logger.error("Error unarchiving form:", error);
res.status(500).send("Error unarchiving form.");
}
});
// POST /dashboard/forms/:formUuid/delete - Delete a form
router.post("/forms/:formUuid/delete", async (req, res) => {
const { formUuid } = req.params;
try {
// Add additional checks or soft delete if needed
const { rowCount } = await pool.query(
"DELETE FROM forms WHERE uuid = $1 AND user_id = $2",
[formUuid, req.user.id]
);
if (rowCount > 0) {
res.redirect("/dashboard?deleted=true");
} else {
res.status(404).send("Form not found or not owned by user.");
}
} catch (error) {
logger.error("Error deleting form:", error);
res.status(500).send("Error deleting form.");
}
});
// GET /dashboard/profile - Display user profile page
router.get("/profile", async (req, res) => {
try {
// Fetch the full user object for the profile page, could use User model
const userProfile = await User.findById(req.user.id);
if (!userProfile) {
logger.warn(
`User not found in DB for ID: ${req.user.id} during profile view`
);
return res.status(404).send("User profile not found.");
}
res.render("dashboard", {
user: userProfile, // Pass the full userProfile object
appUrl: `${req.protocol}://${req.get("host")}`,
view: "profile_settings",
pageTitle: "My Profile",
});
} catch (error) {
logger.error("Error fetching user profile:", error);
res.status(500).render("dashboard", {
user: req.user, // Fallback to req.user if profile fetch fails
view: "profile_settings",
pageTitle: "My Profile",
error: "Could not load your profile information.",
appUrl: `${req.protocol}://${req.get("host")}`,
});
}
});
// POST /dashboard/profile - Update user profile
router.post("/profile", async (req, res) => {
const { firstName, lastName, email } = req.body;
try {
const updatedUser = await User.updateProfile(req.user.id, {
first_name: firstName,
last_name: lastName,
email: email,
});
if (updatedUser) {
// Update the session user object if email changes, etc.
// This is important because req.user is populated from the session at login.
// If email is part of the identifier or used for display, it needs to be fresh.
req.login(updatedUser, (err) => {
// req.login is from Passport to update session user
if (err) {
logger.error("Error updating session after profile update:", err);
return res.redirect("/dashboard/profile?error=Session update failed");
}
return res.redirect(
"/dashboard/profile?success=Profile updated successfully"
);
});
} else {
res.redirect(
"/dashboard/profile?error=Failed to update profile or no changes made"
);
}
} catch (error) {
logger.error("Error updating profile:", error);
let errorMessage = "Error updating profile.";
if (error.message === "Email already exists") {
errorMessage =
"That email address is already in use. Please choose another.";
}
res.redirect(
`/dashboard/profile?error=${encodeURIComponent(errorMessage)}`
);
}
});
// POST /dashboard/profile/change-password - Change user password
router.post("/profile/change-password", async (req, res) => {
const { currentPassword, newPassword, confirmPassword } = req.body;
if (newPassword !== confirmPassword) {
return res.redirect(
"/dashboard/profile?passError=New passwords do not match."
);
}
if (!newPassword || newPassword.length < 8) {
// Basic validation
return res.redirect(
"/dashboard/profile?passError=New password must be at least 8 characters long."
);
}
try {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(400).send("User not found.");
}
const isMatch = await bcrypt.compare(currentPassword, user.password_hash);
if (!isMatch) {
return res.redirect(
"/dashboard/profile?passError=Incorrect current password."
);
}
const success = await User.updatePassword(req.user.id, newPassword);
if (success) {
// Optionally, log out other sessions for security
// await User.revokeAllUserSessionsExcept(req.user.id, req.session.jwtJti); // Assuming jwtJti is stored in session
res.redirect(
"/dashboard/profile?passSuccess=Password changed successfully."
);
} else {
res.redirect("/dashboard/profile?passError=Failed to change password.");
}
} catch (error) {
logger.error("Error changing password:", error);
res.redirect("/dashboard/profile?passError=Error changing password.");
}
});
// API Keys Section
// GET /dashboard/api-keys - Display API keys page
router.get("/api-keys", async (req, res) => {
try {
const apiKeys = await User.getUserApiKeys(req.user.id);
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "api_keys",
pageTitle: "API Keys",
apiKeys: apiKeys,
newApiKey: req.query.newApiKey, // For showing the new key once
newApiKeyName: req.query.newApiKeyName,
});
} catch (error) {
logger.error("Error fetching API keys:", error);
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "api_keys",
pageTitle: "API Keys",
error: "Could not load your API keys.",
apiKeys: [],
});
}
});
// POST /dashboard/api-keys/create - Create a new API key
router.post("/api-keys/create", async (req, res) => {
const { keyName } = req.body;
if (!keyName || keyName.trim() === "") {
return res.redirect(
"/dashboard/api-keys?keyError=API Key name cannot be empty."
);
}
try {
// User.createApiKey should handle hashing and return the raw secret ONCE
const { apiKeySecret, ...newKeyDetails } = await User.createApiKey(
req.user.id,
keyName.trim()
);
// Pass the raw secret key to the view via query param for the user to copy ONCE.
// This is a common pattern but ensure it's clear this is the only time it's shown.
res.redirect(
`/dashboard/api-keys?success=API Key created successfully.&newApiKey=${encodeURIComponent(apiKeySecret)}&newApiKeyName=${encodeURIComponent(newKeyDetails.key_name)}`
);
} catch (error) {
logger.error("Error creating API key:", error);
res.redirect("/dashboard/api-keys?keyError=Error creating API key.");
}
});
// POST /dashboard/api-keys/:apiKeyId/delete - Delete an API key
router.post("/api-keys/:apiKeyId/delete", async (req, res) => {
const { apiKeyId } = req.params;
try {
const success = await User.revokeApiKey(apiKeyId, req.user.id);
if (success) {
res.redirect("/dashboard/api-keys?deleted=API Key deleted successfully.");
} else {
res.redirect(
"/dashboard/api-keys?keyError=Failed to delete API Key or key not found."
);
}
} catch (error) {
logger.error("Error deleting API key:", error);
res.redirect("/dashboard/api-keys?keyError=Error deleting API key.");
}
});
// GET /dashboard/settings - Main settings page (could link to profile, api keys, etc.)
router.get("/settings", (req, res) => {
res.render("dashboard", {
user: req.user,
appUrl: `${req.protocol}://${req.get("host")}`,
view: "general_settings", // A new EJS view or section for general settings
pageTitle: "Settings",
});
});
module.exports = router;

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

@ -0,0 +1,259 @@
const express = require("express");
const pool = require("../config/database"); // pg Pool
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 logger = require("../../config/logger"); // Corrected logger path
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" }));
// Render login page
router.get("/login", (req, res) => {
res.render("login", {
error: req.query.error,
success: req.query.success,
email: req.query.email,
});
});
// Render registration page
router.get("/register", (req, res) => {
res.render("register", {
error: req.query.error,
success: req.query.success,
email: req.query.email,
first_name: req.query.first_name,
last_name: req.query.last_name,
});
});
router.post(
"/submit/:formUuid",
strictRateLimit,
submissionRateLimit,
formSpecificRateLimit,
domainChecker,
async (req, res) => {
const { formUuid } = req.params;
const submissionData = { ...req.body };
const ipAddress = req.ip;
const recaptchaToken = submissionData["g-recaptcha-response"];
delete submissionData["g-recaptcha-response"];
if (submissionData.honeypot_field && submissionData.honeypot_field !== "") {
logger.info(
`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>"
);
}
let formSettings;
try {
const { rows: 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 = $1",
[formUuid]
);
if (forms.length === 0) {
logger.warn(
`Submission attempt to non-existent form UUID: ${formUuid} from IP: ${ipAddress}`
);
return res.status(404).send("Form endpoint not found.");
}
formSettings = forms[0];
if (formSettings.is_archived) {
logger.warn(
`Submission attempt to archived form UUID: ${formUuid} from IP: ${ipAddress}`
);
return res
.status(410)
.send(
"This form has been archived and is no longer accepting submissions."
);
}
} catch (dbError) {
logger.error("Error fetching form settings during submission:", {
formUuid,
error: dbError,
});
return res
.status(500)
.send("Error processing submission due to a configuration issue."); // More generic error to user
}
if (formSettings.recaptcha_enabled) {
if (!recaptchaToken) {
logger.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) {
logger.warn(
`reCAPTCHA verification failed for form ${formUuid} from IP ${ipAddress}.`
);
return res
.status(403)
.send("reCAPTCHA verification failed. Please try again.");
}
}
let formNameForNotification = formSettings.name || `Form ${formUuid}`;
try {
const ntfyEnabled = formSettings.ntfy_enabled;
const formOwnerUserId = formSettings.user_id; // This should be NOT NULL based on forms schema
const formForEmail = {
name: formSettings.name,
email_notifications_enabled: formSettings.email_notifications_enabled,
notification_email_address: formSettings.notification_email_address,
};
let ownerEmail = null;
if (formOwnerUserId) {
// Should always be true if form exists
const { rows: users } = await pool.query(
"SELECT email FROM users WHERE id = $1",
[formOwnerUserId]
);
if (users.length > 0) {
ownerEmail = users[0].email;
} else {
logger.warn(
`Owner user with ID ${formOwnerUserId} not found for form ${formUuid}, though form record exists.`
);
}
}
// The user_id in submissions table is NOT NULL in PostgreSQL schema, ensure formOwnerUserId is valid.
if (!formOwnerUserId) {
logger.error(
`Critical: formOwnerUserId is null for form ${formUuid} during submission. This should not happen if form exists.`
);
// Potentially send an alert to admin here
return res
.status(500)
.send("Error processing submission due to inconsistent data.");
}
await pool.query(
"INSERT INTO submissions (form_uuid, user_id, data, ip_address) VALUES ($1, $2, $3, $4)",
[formUuid, formOwnerUserId, JSON.stringify(submissionData), ipAddress]
);
logger.info(
`Submission received for ${formUuid} (user: ${formOwnerUserId}): ${JSON.stringify(submissionData)}`
);
const submissionSummary = Object.entries(submissionData)
.filter(([key]) => key !== "_thankyou") // Ensure _thankyou is not in summary
.map(([key, value]) => `${key}: ${value}`)
.join(", ");
if (ntfyEnabled) {
sendNtfyNotification(
`New Submission: ${formNameForNotification}`,
`Data: ${
submissionSummary || "No data fields"
}\nFrom IP: ${ipAddress}`,
"high",
"incoming_form"
).catch((err) =>
logger.error("Failed to send NTFY notification:", err)
); // Log & continue
}
if (ownerEmail) {
sendSubmissionNotification(
formForEmail,
submissionData,
ownerEmail
).catch((err) =>
logger.error("Failed to send submission email:", {
formUuid,
recipient: ownerEmail,
error: err,
})
);
} else if (
formForEmail.email_notifications_enabled &&
!formForEmail.notification_email_address
) {
logger.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) {
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) {
logger.error("Error processing submission (main block):", {
formUuid,
error: error.message,
stack: error.stack,
});
// Avoid sending detailed error to client, but log it.
sendNtfyNotification(
`Submission Error: ${formNameForNotification}`,
`Failed to process submission for ${formUuid} from IP ${ipAddress}. Error: ${error.message}`,
"max"
).catch((err) =>
logger.error("Failed to send error NTFY notification:", err)
);
res
.status(500)
.send(
"An error occurred while processing your submission. Please try again later."
);
}
}
);
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>

239
views/login.ejs Normal file
View File

@ -0,0 +1,239 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - Formies</title>
<style>
body {
font-family: sans-serif;
margin: 0;
background-color: #f4f7f6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background-color: #333;
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar a {
color: white;
text-decoration: none;
}
.navbar .logo {
font-size: 1.5rem;
font-weight: bold;
}
.container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.login-card {
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
margin: 0;
color: #333;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.25rem;
font-size: 1rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.btn {
background-color: #007bff;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.25rem;
font-size: 1rem;
cursor: pointer;
width: 100%;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #0056b3;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
text-align: center;
}
.success-message {
background-color: #d4edda;
color: #155724;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
text-align: center;
}
.links {
text-align: center;
margin-top: 1rem;
}
.links a {
color: #007bff;
text-decoration: none;
font-size: 0.9rem;
}
.links a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="logo"><a href="/">Formies</a></div>
<div>
<a href="/register">Register</a>
</div>
</nav>
<div class="container">
<div class="login-card">
<div class="login-header">
<h1>Welcome Back</h1>
<p>Please sign in to continue</p>
</div>
<% if (typeof error !== 'undefined' && error) { %>
<div class="error-message"><%= error %></div>
<% } %>
<% if (typeof success !== 'undefined' && success) { %>
<div class="success-message"><%= success %></div>
<% } %>
<form action="/api/auth/login" method="POST">
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
required
placeholder="Enter your email"
value="<%= typeof email !== 'undefined' ? email : '' %>"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
placeholder="Enter your password"
/>
</div>
<button type="submit" class="btn">Sign In</button>
<div class="links">
<a href="/forgot-password">Forgot your password?</a>
<br>
<a href="/register">Don't have an account? Register</a>
</div>
</form>
</div>
</div>
<script>
document.querySelector('form').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
// Store tokens
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
// Redirect to dashboard
window.location.href = '/dashboard';
} else {
// Show error message
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = data.message || 'Login failed';
const existingError = document.querySelector('.error-message');
if (existingError) {
existingError.remove();
}
document.querySelector('.login-card').insertBefore(
errorDiv,
document.querySelector('form')
);
}
} catch (error) {
console.error('Login error:', error);
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = 'An error occurred. Please try again.';
const existingError = document.querySelector('.error-message');
if (existingError) {
existingError.remove();
}
document.querySelector('.login-card').insertBefore(
errorDiv,
document.querySelector('form')
);
}
});
</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>
<% } %> <% } %>

313
views/register.ejs Normal file
View File

@ -0,0 +1,313 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Register - Formies</title>
<style>
body {
font-family: sans-serif;
margin: 0;
background-color: #f4f7f6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background-color: #333;
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar a {
color: white;
text-decoration: none;
}
.navbar .logo {
font-size: 1.5rem;
font-weight: bold;
}
.container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.register-card {
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.register-header {
text-align: center;
margin-bottom: 2rem;
}
.register-header h1 {
margin: 0;
color: #333;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.25rem;
font-size: 1rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.btn {
background-color: #007bff;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.25rem;
font-size: 1rem;
cursor: pointer;
width: 100%;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #0056b3;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
text-align: center;
}
.success-message {
background-color: #d4edda;
color: #155724;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
text-align: center;
}
.links {
text-align: center;
margin-top: 1rem;
}
.links a {
color: #007bff;
text-decoration: none;
font-size: 0.9rem;
}
.links a:hover {
text-decoration: underline;
}
.password-requirements {
font-size: 0.8rem;
color: #666;
margin-top: 0.5rem;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="logo"><a href="/">Formies</a></div>
<div>
<a href="/login">Login</a>
</div>
</nav>
<div class="container">
<div class="register-card">
<div class="register-header">
<h1>Create Account</h1>
<p>Join Formies to start creating forms</p>
</div>
<% if (typeof error !== 'undefined' && error) { %>
<div class="error-message"><%= error %></div>
<% } %>
<% if (typeof success !== 'undefined' && success) { %>
<div class="success-message"><%= success %></div>
<% } %>
<form id="registerForm">
<div class="form-group">
<label for="firstName">First Name</label>
<input
type="text"
id="firstName"
name="first_name"
required
placeholder="Enter your first name"
value="<%= typeof first_name !== 'undefined' ? first_name : '' %>"
/>
</div>
<div class="form-group">
<label for="lastName">Last Name</label>
<input
type="text"
id="lastName"
name="last_name"
required
placeholder="Enter your last name"
value="<%= typeof last_name !== 'undefined' ? last_name : '' %>"
/>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
required
placeholder="Enter your email"
value="<%= typeof email !== 'undefined' ? email : '' %>"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
placeholder="Create a password"
/>
<div class="password-requirements">
Password must be at least 8 characters long and include:
<ul>
<li>At least one uppercase letter</li>
<li>At least one lowercase letter</li>
<li>At least one number</li>
<li>At least one special character</li>
</ul>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
placeholder="Confirm your password"
/>
</div>
<button type="submit" class="btn">Create Account</button>
<div class="links">
<a href="/login">Already have an account? Sign in</a>
</div>
</form>
</div>
</div>
<script>
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const firstName = document.getElementById('firstName').value;
const lastName = document.getElementById('lastName').value;
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// Client-side validation
if (password !== confirmPassword) {
showError('Passwords do not match');
return;
}
// Password strength validation
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
if (!passwordRegex.test(password)) {
showError('Password does not meet the requirements');
return;
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
email,
password
})
});
const data = await response.json();
if (response.ok) {
// Show success message and redirect to login
showSuccess('Registration successful! Please check your email to verify your account.');
setTimeout(() => {
window.location.href = '/login?success=Registration successful! Please check your email to verify your account.';
}, 3000);
} else {
showError(data.message || 'Registration failed');
}
} catch (error) {
console.error('Registration error:', error);
showError('An error occurred. Please try again.');
}
});
function showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
const existingError = document.querySelector('.error-message');
if (existingError) {
existingError.remove();
}
document.querySelector('.register-card').insertBefore(
errorDiv,
document.querySelector('form')
);
}
function showSuccess(message) {
const successDiv = document.createElement('div');
successDiv.className = 'success-message';
successDiv.textContent = message;
const existingSuccess = document.querySelector('.success-message');
if (existingSuccess) {
existingSuccess.remove();
}
document.querySelector('.register-card').insertBefore(
successDiv,
document.querySelector('form')
);
}
</script>
</body>
</html>