Refactor project structure to transition from Rust to Node.js, removing Rust-specific files and adding Node.js dependencies. Introduced Docker support with a new Dockerfile and docker-compose.yml. Added server.js for Express application setup, along with necessary middleware and routes. Updated .gitignore to exclude environment files and SQLite database. Removed legacy files including Cargo.toml, Cargo.lock, and design.html.
This commit is contained in:
parent
fe5184e18c
commit
2ac4fda944
5
.gitignore
vendored
5
.gitignore
vendored
@ -1 +1,4 @@
|
||||
/target
|
||||
*.env
|
||||
package-lock.json
|
||||
node_modules
|
||||
database.sqlite
|
4102
Cargo.lock
generated
4102
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
39
Cargo.toml
@ -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"
|
59
Dockerfile
59
Dockerfile
@ -1,51 +1,36 @@
|
||||
# Build stage
|
||||
FROM rust:1.70-slim as builder
|
||||
FROM node:24-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/*
|
||||
# Install build dependencies for sqlite3
|
||||
RUN apk add --no-cache python3 make g++ sqlite-dev
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN cargo build --release
|
||||
FROM node:24-alpine
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bullseye-slim
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
WORKDIR /app
|
||||
# Install runtime dependencies for sqlite3
|
||||
RUN apk add --no-cache sqlite-libs python3 make g++ sqlite-dev
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsqlite3-0 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Create a non-root user
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
COPY --from=builder /usr/src/app/package*.json ./
|
||||
COPY --from=builder /usr/src/app/ ./
|
||||
|
||||
# Copy the binary from builder
|
||||
COPY --from=builder /app/target/release/formies-be /app/
|
||||
# Rebuild sqlite3 for the target architecture
|
||||
RUN npm rebuild sqlite3
|
||||
|
||||
# Copy configuration
|
||||
COPY config/default.toml /app/config/default.toml
|
||||
# Set ownership to non-root user
|
||||
RUN chown -R appuser:appgroup /usr/src/app
|
||||
|
||||
# Set environment variables
|
||||
ENV RUST_LOG=info
|
||||
ENV DATABASE_URL=/app/data/form_data.db
|
||||
ENV BIND_ADDRESS=0.0.0.0:8080
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
EXPOSE 3000
|
||||
|
||||
# Set proper permissions
|
||||
RUN chown -R nobody:nogroup /app
|
||||
USER nobody
|
||||
|
||||
# Run the application
|
||||
CMD ["./formies-be"]
|
||||
CMD ["node", "server.js"]
|
149
README.md
149
README.md
@ -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
|
@ -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
|
1294
design.html
1294
design.html
File diff suppressed because it is too large
Load Diff
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@ -0,0 +1,13 @@
|
||||
services:
|
||||
app:
|
||||
image: whtvrboo/formies:1.02
|
||||
container_name: formies
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NTFY_TOPIC_URL=https://ntfy.vinylnostalgia.com/form-alerts
|
||||
- NTFY_ENABLED=true
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- ./database.sqlite:/usr/src/app/database.sqlite
|
BIN
form_data.db
BIN
form_data.db
Binary file not shown.
@ -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">×</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>
|
@ -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
|
||||
}
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "formies",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"basic-auth": "^2.0.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"helmet": "^8.1.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
}
|
53
server.js
Normal file
53
server.js
Normal file
@ -0,0 +1,53 @@
|
||||
import dotenv from "dotenv";
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import cors from "cors";
|
||||
import adminRoutes from "./src/routes/admin.js";
|
||||
import publicRoutes from "./src/routes/public.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// CORS configuration
|
||||
app.use(cors({
|
||||
origin: ['https://mohamad.dev', 'https://www.mohamad.dev'],
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Middleware
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" }
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.set("view engine", "ejs");
|
||||
app.use(express.static('views', {
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('.js')) {
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Routes
|
||||
app.use("/admin", adminRoutes);
|
||||
app.use("/", publicRoutes);
|
||||
|
||||
// Start server
|
||||
app.listen(80, () => {
|
||||
console.log(`Server running on http://0.0.0.0:${PORT}`);
|
||||
if (!process.env.ADMIN_USER || !process.env.ADMIN_PASSWORD) {
|
||||
console.warn("WARNING: Admin routes are UNPROTECTED. Set ADMIN_USER and ADMIN_PASSWORD in .env");
|
||||
}
|
||||
if (process.env.ADMIN_USER && process.env.ADMIN_PASSWORD) {
|
||||
console.log(`Admin access: User: ${process.env.ADMIN_USER}, Pass: (hidden)`);
|
||||
}
|
||||
if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
|
||||
console.log(`Ntfy notifications enabled for topic: ${process.env.NTFY_TOPIC_URL}`);
|
||||
} else {
|
||||
console.log("Ntfy notifications disabled or topic not configured.");
|
||||
}
|
||||
});
|
101
src/auth.rs
101
src/auth.rs
@ -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")))
|
||||
}
|
||||
}
|
||||
}
|
34
src/config/database.js
Normal file
34
src/config/database.js
Normal file
@ -0,0 +1,34 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
|
||||
// Create a database connection
|
||||
const db = await open({
|
||||
filename: './database.sqlite',
|
||||
driver: sqlite3.Database
|
||||
});
|
||||
|
||||
// Initialize tables if they don't exist
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS forms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT DEFAULT 'My Form',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
thank_you_url TEXT,
|
||||
thank_you_message TEXT,
|
||||
ntfy_enabled INTEGER DEFAULT 1,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
allowed_domains TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS submissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
form_uuid TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (form_uuid) REFERENCES forms(uuid) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
export default db;
|
356
src/db.rs
356
src/db.rs
@ -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(())
|
||||
}
|
||||
}
|
751
src/handlers.rs
751
src/handlers.rs
@ -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(¬ify_email, &email_subject, &email_body)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to send email notification: {}", e);
|
||||
}
|
||||
|
||||
// Also send ntfy notification if configured (sends to the global topic)
|
||||
if let Some(topic_flag) = &form.notify_ntfy_topic {
|
||||
// Use field presence as a flag
|
||||
if !topic_flag.is_empty() {
|
||||
// Check if the flag string is non-empty
|
||||
let ntfy_title = format!("New submission for: {}", form.name);
|
||||
let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id);
|
||||
if let Err(e) = app_state.notification_service.send_ntfy(
|
||||
&ntfy_title,
|
||||
&ntfy_message,
|
||||
Some(3), // Medium priority
|
||||
) {
|
||||
log::warn!("Failed to send ntfy notification (global topic): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Created().json(json!({
|
||||
"message": "Submission received",
|
||||
"submission_id": submission.id
|
||||
})))
|
||||
}
|
||||
|
||||
// POST /forms
|
||||
pub async fn create_form(
|
||||
app_state: web::Data<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()
|
||||
}))
|
||||
}
|
241
src/main.rs
241
src/main.rs
@ -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
|
||||
}
|
49
src/middleware/domainChecker.js
Normal file
49
src/middleware/domainChecker.js
Normal file
@ -0,0 +1,49 @@
|
||||
import dbPromise from "../config/database.js";
|
||||
|
||||
const domainChecker = async (req, res, next) => {
|
||||
const formUuid = req.params.formUuid;
|
||||
const referer = req.headers.referer || req.headers.origin;
|
||||
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const form = await db.get(
|
||||
"SELECT allowed_domains FROM forms WHERE uuid = ?",
|
||||
[formUuid]
|
||||
);
|
||||
|
||||
if (!form) {
|
||||
return res.status(404).json({ error: "Form not found" });
|
||||
}
|
||||
|
||||
// If no domains are specified, allow all
|
||||
if (!form.allowed_domains) {
|
||||
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" });
|
||||
}
|
||||
};
|
||||
|
||||
export default domainChecker;
|
44
src/middleware/rateLimiter.js
Normal file
44
src/middleware/rateLimiter.js
Normal file
@ -0,0 +1,44 @@
|
||||
const ipRateLimitStore = new Map();
|
||||
|
||||
// Clean up old entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of ipRateLimitStore.entries()) {
|
||||
if (now - value.timestamp > 60000) {
|
||||
// 1 minute
|
||||
ipRateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
const rateLimiter = (req, res, next) => {
|
||||
const formUuid = req.params.formUuid;
|
||||
const ip = req.ip;
|
||||
const key = `${formUuid}_${ip}`;
|
||||
const now = Date.now();
|
||||
const windowMs = 60000; // 1 minute
|
||||
const maxRequests = 5; // 5 requests per minute
|
||||
|
||||
const current = ipRateLimitStore.get(key) || { count: 0, timestamp: now };
|
||||
|
||||
// Reset if window has passed
|
||||
if (now - current.timestamp > windowMs) {
|
||||
current.count = 0;
|
||||
current.timestamp = now;
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (current.count >= maxRequests) {
|
||||
return res.status(429).json({
|
||||
error: "Too many requests. Please try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
current.count++;
|
||||
ipRateLimitStore.set(key, current);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export default rateLimiter;
|
@ -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>,
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
405
src/routes/admin.js
Normal file
405
src/routes/admin.js
Normal file
@ -0,0 +1,405 @@
|
||||
import express from "express";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import dbPromise from "../config/database.js";
|
||||
import { sendNtfyNotification } from "../services/notification.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const forms = await db.all(`
|
||||
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 ORDER BY created_at DESC
|
||||
`);
|
||||
res.render("index", {
|
||||
forms,
|
||||
appUrl: `${req.protocol}://${req.get("host")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching forms:", error);
|
||||
res.status(500).send("Error fetching forms");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/create-form", async (req, res) => {
|
||||
const formName = req.body.formName || "Untitled Form";
|
||||
const newUuid = uuidv4();
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
await db.run("INSERT INTO forms (uuid, name) VALUES (?, ?)", [newUuid, formName]);
|
||||
console.log(`Form created: ${formName} with UUID: ${newUuid}`);
|
||||
await sendNtfyNotification(
|
||||
"New Form Created",
|
||||
`Form "${formName}" (UUID: ${newUuid}) was created.`,
|
||||
"high"
|
||||
);
|
||||
res.redirect("/admin");
|
||||
} catch (error) {
|
||||
console.error("Error creating form:", error);
|
||||
res.status(500).send("Error creating 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 db = await dbPromise;
|
||||
const formDetails = await db.get("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
||||
if (!formDetails) {
|
||||
return res.status(404).send("Form not found.");
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
const countResult = await db.get(
|
||||
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = ?",
|
||||
[formUuid]
|
||||
);
|
||||
const totalSubmissions = countResult.total;
|
||||
const totalPages = Math.ceil(totalSubmissions / limit);
|
||||
|
||||
const submissions = await db.all(
|
||||
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
[formUuid, limit, offset]
|
||||
);
|
||||
|
||||
res.render("submissions", {
|
||||
submissions,
|
||||
formUuid,
|
||||
formName: formDetails.name,
|
||||
appUrl: `${req.protocol}://${req.get("host")}`,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages,
|
||||
totalSubmissions,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching submissions:", error);
|
||||
res.status(500).send("Error fetching submissions");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/submissions/:formUuid/export", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const formDetails = await db.get("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
||||
if (!formDetails) {
|
||||
return res.status(404).send("Form not found.");
|
||||
}
|
||||
|
||||
const submissions = await db.all(
|
||||
"SELECT data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC",
|
||||
[formUuid]
|
||||
);
|
||||
|
||||
// Create CSV content
|
||||
const headers = ["Submitted At", "IP Address"];
|
||||
const rows = submissions.map((submission) => {
|
||||
const data = JSON.parse(submission.data);
|
||||
// Add all form fields as headers
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (!headers.includes(key)) {
|
||||
headers.push(key);
|
||||
}
|
||||
});
|
||||
return {
|
||||
submitted_at: new Date(submission.submitted_at).toISOString(),
|
||||
ip_address: submission.ip_address,
|
||||
...data,
|
||||
};
|
||||
});
|
||||
|
||||
// Generate CSV content
|
||||
let csvContent = headers.join(",") + "\n";
|
||||
rows.forEach((row) => {
|
||||
const values = headers.map((header) => {
|
||||
const value = row[header] || "";
|
||||
// Escape commas and quotes in values
|
||||
return `"${String(value).replace(/"/g, '""')}"`;
|
||||
});
|
||||
csvContent += values.join(",") + "\n";
|
||||
});
|
||||
|
||||
// Set response headers for CSV download
|
||||
res.setHeader("Content-Type", "text/csv");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${formDetails.name}-submissions.csv"`
|
||||
);
|
||||
res.send(csvContent);
|
||||
} catch (error) {
|
||||
console.error("Error exporting submissions:", error);
|
||||
res.status(500).send("Error exporting submissions");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/delete-form/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
||||
const formName =
|
||||
formResult.length > 0 ? formResult[0].name : `Form ${formUuid}`;
|
||||
|
||||
await db.run("DELETE FROM forms WHERE uuid = ?", [formUuid]);
|
||||
console.log(`Form ${formUuid} deleted.`);
|
||||
await sendNtfyNotification(
|
||||
"Form Deleted",
|
||||
`Form "${formName}" (UUID: ${formUuid}) was deleted.`,
|
||||
"default"
|
||||
);
|
||||
res.redirect("/admin");
|
||||
} catch (error) {
|
||||
console.error("Error deleting form:", error);
|
||||
res.status(500).send("Error deleting form.");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/delete-submission/:submissionId", async (req, res) => {
|
||||
const { submissionId } = req.params;
|
||||
let formUuidForRedirect = "unknown-form";
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const submissionResult = await db.all("SELECT form_uuid FROM submissions WHERE id = ?", [submissionId]);
|
||||
if (submissionResult.length > 0) {
|
||||
formUuidForRedirect = submissionResult[0].form_uuid;
|
||||
}
|
||||
|
||||
await db.run("DELETE FROM submissions WHERE id = ?", [submissionId]);
|
||||
console.log(`Submission ${submissionId} deleted.`);
|
||||
await sendNtfyNotification(
|
||||
"Submission Deleted",
|
||||
`Submission ID ${submissionId} (for form ${formUuidForRedirect}) was deleted.`,
|
||||
"low"
|
||||
);
|
||||
if (formUuidForRedirect !== "unknown-form") {
|
||||
res.redirect(`/admin/submissions/${formUuidForRedirect}`);
|
||||
} else {
|
||||
res.redirect("/admin");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting submission:", error);
|
||||
res.status(500).send("Error deleting submission.");
|
||||
if (formUuidForRedirect !== "unknown-form") {
|
||||
res.redirect(`/admin/submissions/${formUuidForRedirect}`);
|
||||
} else {
|
||||
res.redirect("/admin");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/update-form-name/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
const { newName } = req.body;
|
||||
|
||||
if (!newName || newName.trim() === "") {
|
||||
return res.status(400).send("New form name is required.");
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
||||
|
||||
if (formResult.length === 0) {
|
||||
return res.status(404).send("Form not found.");
|
||||
}
|
||||
|
||||
const oldName = formResult[0].name;
|
||||
|
||||
await db.run("UPDATE forms SET name = ? WHERE uuid = ?", [
|
||||
newName,
|
||||
formUuid,
|
||||
]);
|
||||
|
||||
console.log(
|
||||
`Form name updated from '${oldName}' to '${newName}' for UUID: ${formUuid}`
|
||||
);
|
||||
|
||||
await sendNtfyNotification(
|
||||
"Form Name Updated",
|
||||
`Form name changed from '${oldName}' to '${newName}' for UUID: ${formUuid}.`,
|
||||
"default"
|
||||
);
|
||||
|
||||
res.redirect("/admin");
|
||||
} catch (error) {
|
||||
console.error("Error updating form name:", error);
|
||||
res.status(500).send("Error updating form name.");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/update-thank-you-url/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
const { thankYouUrl } = req.body;
|
||||
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
await db.run("UPDATE forms SET thank_you_url = ? WHERE uuid = ?", [thankYouUrl, formUuid]);
|
||||
console.log(`Thank You URL updated for form UUID: ${formUuid}`);
|
||||
res.redirect("/admin");
|
||||
} catch (error) {
|
||||
console.error("Error updating Thank You URL:", error);
|
||||
res.status(500).send("Error updating Thank You URL.");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/update-ntfy-enabled/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
const ntfyEnabled = req.body.ntfyEnabled === "true";
|
||||
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
await db.run("UPDATE forms SET ntfy_enabled = ? WHERE uuid = ?", [
|
||||
ntfyEnabled,
|
||||
formUuid,
|
||||
]);
|
||||
console.log(`Ntfy notifications updated for form UUID: ${formUuid}`);
|
||||
res.redirect("/admin");
|
||||
} catch (error) {
|
||||
console.error("Error updating Ntfy notifications setting:", error);
|
||||
res.status(500).send("Error updating Ntfy notifications setting.");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/clear-submissions/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
try {
|
||||
// First get form name for notification before clearing
|
||||
const db = await dbPromise;
|
||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
||||
const formName =
|
||||
formResult.length > 0 ? formResult[0].name : `Form ${formUuid}`;
|
||||
|
||||
// Delete all submissions for this form
|
||||
await db.run("DELETE FROM submissions WHERE form_uuid = ?", [formUuid]);
|
||||
console.log(`All submissions cleared for form ${formUuid}`);
|
||||
await sendNtfyNotification(
|
||||
"Submissions Cleared",
|
||||
`All submissions for form "${formName}" (UUID: ${formUuid}) were cleared.`,
|
||||
"default"
|
||||
);
|
||||
res.redirect(`/admin/submissions/${formUuid}`);
|
||||
} catch (error) {
|
||||
console.error("Error clearing submissions:", error);
|
||||
res.status(500).send("Error clearing submissions.");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/update-thank-you-message/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
const { thankYouMessage } = req.body;
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
await db.run("UPDATE forms SET thank_you_message = ? WHERE uuid = ?", [thankYouMessage, formUuid]);
|
||||
console.log(`Thank you message updated for form ${formUuid}`);
|
||||
res.redirect("/admin");
|
||||
} catch (error) {
|
||||
console.error("Error updating thank you message:", error);
|
||||
res.status(500).send("Error updating thank you message.");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/archive-form/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
const { archive } = req.body;
|
||||
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
||||
|
||||
if (formResult.length === 0) {
|
||||
return res.status(404).send("Form not found.");
|
||||
}
|
||||
|
||||
await db.run("UPDATE forms SET is_archived = ? WHERE uuid = ?", [
|
||||
archive === "true",
|
||||
formUuid,
|
||||
]);
|
||||
|
||||
const action = archive === "true" ? "archived" : "unarchived";
|
||||
await sendNtfyNotification(
|
||||
`Form ${action}`,
|
||||
`Form "${formResult[0].name}" (UUID: ${formUuid}) has been ${action}.`,
|
||||
"default"
|
||||
);
|
||||
|
||||
res.redirect("/admin");
|
||||
} catch (error) {
|
||||
console.error("Error updating form archive status:", error);
|
||||
res.status(500).send("Error updating form archive status");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/update-allowed-domains/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
const { allowedDomains } = req.body;
|
||||
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
||||
|
||||
if (formResult.length === 0) {
|
||||
return res.status(404).send("Form not found.");
|
||||
}
|
||||
|
||||
await db.run("UPDATE forms SET allowed_domains = ? WHERE uuid = ?", [
|
||||
allowedDomains,
|
||||
formUuid,
|
||||
]);
|
||||
|
||||
await sendNtfyNotification(
|
||||
"Form Allowed Domains Updated",
|
||||
`Form "${formResult[0].name}" (UUID: ${formUuid}) allowed domains have been updated.`,
|
||||
"default"
|
||||
);
|
||||
|
||||
res.redirect("/admin");
|
||||
} catch (error) {
|
||||
console.error("Error updating allowed domains:", error);
|
||||
res.status(500).send("Error updating allowed domains");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/test-notification/:formUuid", async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const formResult = await db.all("SELECT name, ntfy_enabled FROM forms WHERE uuid = ?", [formUuid]);
|
||||
|
||||
if (formResult.length === 0) {
|
||||
return res.status(404).send("Form not found.");
|
||||
}
|
||||
|
||||
if (!formResult[0].ntfy_enabled) {
|
||||
return res
|
||||
.status(400)
|
||||
.send("Ntfy notifications are not enabled for this form.");
|
||||
}
|
||||
|
||||
await sendNtfyNotification(
|
||||
"Test Notification",
|
||||
`This is a test notification for form "${formResult[0].name}" (UUID: ${formUuid}).`,
|
||||
"default",
|
||||
"test"
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Test notification sent successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error sending test notification:", error);
|
||||
res.status(500).send("Error sending test notification");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
104
src/routes/public.js
Normal file
104
src/routes/public.js
Normal file
@ -0,0 +1,104 @@
|
||||
import express from "express";
|
||||
import dbPromise from "../config/database.js";
|
||||
import { sendNtfyNotification } from "../services/notification.js";
|
||||
import rateLimiter from "../middleware/rateLimiter.js";
|
||||
import domainChecker from "../middleware/domainChecker.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => res.redirect("/admin"));
|
||||
router.get("/health", (req, res) => res.status(200).json({ status: "ok" }));
|
||||
|
||||
router.post(
|
||||
"/submit/:formUuid",
|
||||
rateLimiter,
|
||||
domainChecker,
|
||||
async (req, res) => {
|
||||
const { formUuid } = req.params;
|
||||
const submissionData = { ...req.body };
|
||||
const ipAddress = req.ip;
|
||||
|
||||
if (submissionData.honeypot_field && submissionData.honeypot_field !== "") {
|
||||
console.log(
|
||||
`Honeypot triggered for ${formUuid} by IP ${ipAddress}. Ignoring submission.`
|
||||
);
|
||||
if (submissionData._thankyou) {
|
||||
return res.redirect(submissionData._thankyou);
|
||||
}
|
||||
return res.send(
|
||||
"<h1>Thank You!</h1><p>Your submission has been received.</p>"
|
||||
);
|
||||
}
|
||||
delete submissionData.honeypot_field;
|
||||
|
||||
let formNameForNotification = `Form ${formUuid}`;
|
||||
try {
|
||||
const db = await dbPromise;
|
||||
const form = await db.get(
|
||||
"SELECT id, name, thank_you_url, thank_you_message, ntfy_enabled, is_archived FROM forms WHERE uuid = ?",
|
||||
[formUuid]
|
||||
);
|
||||
if (!form) {
|
||||
return res.status(404).send("Form endpoint not found.");
|
||||
}
|
||||
|
||||
if (form.is_archived) {
|
||||
return res
|
||||
.status(410)
|
||||
.send(
|
||||
"This form has been archived and is no longer accepting submissions."
|
||||
);
|
||||
}
|
||||
|
||||
formNameForNotification = form.name || `Form ${formUuid}`;
|
||||
const ntfyEnabled = form.ntfy_enabled;
|
||||
|
||||
await db.run(
|
||||
"INSERT INTO submissions (form_uuid, data, ip_address) VALUES (?, ?, ?)",
|
||||
[formUuid, JSON.stringify(submissionData), ipAddress]
|
||||
);
|
||||
console.log(`Submission received for ${formUuid}:`, submissionData);
|
||||
|
||||
const submissionSummary = Object.entries(submissionData)
|
||||
.filter(([key]) => key !== "_thankyou")
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(", ");
|
||||
|
||||
if (ntfyEnabled) {
|
||||
await sendNtfyNotification(
|
||||
`New Submission: ${formNameForNotification}`,
|
||||
`Data: ${submissionSummary || "No data fields"
|
||||
}\nFrom IP: ${ipAddress}`,
|
||||
"high",
|
||||
"incoming_form"
|
||||
);
|
||||
}
|
||||
|
||||
if (form.thank_you_url) {
|
||||
return res.redirect(form.thank_you_url);
|
||||
}
|
||||
|
||||
if (form.thank_you_message) {
|
||||
return res.send(`<h1>Thank You!</h1><p>${form.thank_you_message}</p>`);
|
||||
}
|
||||
|
||||
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 formies</a></p>'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error processing submission:", error);
|
||||
await sendNtfyNotification(
|
||||
`Submission Error: ${formNameForNotification}`,
|
||||
`Failed to process submission for ${formUuid} from IP ${ipAddress}. Error: ${error.message}`,
|
||||
"max"
|
||||
);
|
||||
res.status(500).send("Error processing submission.");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
33
src/services/notification.js
Normal file
33
src/services/notification.js
Normal file
@ -0,0 +1,33 @@
|
||||
import fetch from "node-fetch";
|
||||
|
||||
export 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);
|
||||
}
|
||||
}
|
||||
|
||||
export default { sendNtfyNotification };
|
@ -1 +0,0 @@
|
||||
|
Loading…
Reference in New Issue
Block a user