Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
193be18726 | ||
![]() |
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 node:24-alpine AS builder
|
||||||
FROM rust:1.70-slim as builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# Install build dependencies
|
# Install build dependencies for sqlite3
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apk add --no-cache python3 make g++ sqlite-dev
|
||||||
pkg-config \
|
|
||||||
libsqlite3-dev \
|
COPY package*.json ./
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application
|
FROM node:24-alpine
|
||||||
RUN cargo build --release
|
|
||||||
|
|
||||||
# Runtime stage
|
WORKDIR /usr/src/app
|
||||||
FROM debian:bullseye-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
# Install runtime dependencies for sqlite3
|
||||||
|
RUN apk add --no-cache sqlite-libs python3 make g++ sqlite-dev
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Create a non-root user
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
libsqlite3-0 \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create necessary directories
|
COPY --from=builder /usr/src/app/package*.json ./
|
||||||
RUN mkdir -p /app/data /app/logs
|
COPY --from=builder /usr/src/app/ ./
|
||||||
|
|
||||||
# Copy the binary from builder
|
# Rebuild sqlite3 for the target architecture
|
||||||
COPY --from=builder /app/target/release/formies-be /app/
|
RUN npm rebuild sqlite3
|
||||||
|
|
||||||
# Copy configuration
|
# Set ownership to non-root user
|
||||||
COPY config/default.toml /app/config/default.toml
|
RUN chown -R appuser:appgroup /usr/src/app
|
||||||
|
|
||||||
# Set environment variables
|
USER appuser
|
||||||
ENV RUST_LOG=info
|
|
||||||
ENV DATABASE_URL=/app/data/form_data.db
|
|
||||||
ENV BIND_ADDRESS=0.0.0.0:8080
|
|
||||||
|
|
||||||
# Expose port
|
EXPOSE 3000
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
# Set proper permissions
|
CMD ["node", "server.js"]
|
||||||
RUN chown -R nobody:nogroup /app
|
|
||||||
USER nobody
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
CMD ["./formies-be"]
|
|
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 @@
|
|||||||
|
|
979
views/global.css
Normal file
979
views/global.css
Normal file
@ -0,0 +1,979 @@
|
|||||||
|
:root {
|
||||||
|
/* Scandinavian Industrial Palette */
|
||||||
|
--color-bg: #f5f7fa;
|
||||||
|
/* Very light cool gray - Scandi base */
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
/* White - Scandi cleanliness */
|
||||||
|
--color-primary: #34495e;
|
||||||
|
/* Dark slate blue/gray - Industrial strength */
|
||||||
|
--color-primary-rgb: 52, 73, 94;
|
||||||
|
--color-secondary: #7f8c8d;
|
||||||
|
/* Grayish cyan/slate - Industrial accent */
|
||||||
|
--color-accent: #c09574;
|
||||||
|
/* Muted tan/light wood - Scandi warmth */
|
||||||
|
--color-accent-rgb: 192, 149, 116;
|
||||||
|
--color-text: #2c3e50;
|
||||||
|
/* Dark, similar to primary for harmony */
|
||||||
|
--color-text-light: #566573;
|
||||||
|
/* Lighter gray for secondary text */
|
||||||
|
--color-border: #e1e5ea;
|
||||||
|
/* Light, cool gray for subtle borders */
|
||||||
|
|
||||||
|
--color-success: #27ae60;
|
||||||
|
/* Clear Green */
|
||||||
|
--color-success-bg: rgba(39, 174, 96, 0.1);
|
||||||
|
--color-pending: #f39c12;
|
||||||
|
/* Clear Amber/Orange */
|
||||||
|
--color-pending-bg: rgba(243, 156, 18, 0.1);
|
||||||
|
--color-archived: #95a5a6;
|
||||||
|
/* Muted Silver/Gray */
|
||||||
|
--color-archived-bg: rgba(149, 165, 166, 0.15);
|
||||||
|
--color-danger: #c0392b;
|
||||||
|
/* Clear, strong Red */
|
||||||
|
--color-danger-bg: rgba(192, 57, 43, 0.1);
|
||||||
|
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
/* Softer, more diffuse */
|
||||||
|
--shadow-md: 0 5px 15px rgba(0, 0, 0, 0.08);
|
||||||
|
/* Softer, more diffuse */
|
||||||
|
--border-radius: 4px;
|
||||||
|
/* Slightly sharper edges */
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: "Inter", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
/* Modern sans-serif */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
/* Removed background pattern for cleaner look */
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: auto;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: -9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus,
|
||||||
|
.skip-link:active {
|
||||||
|
display: block;
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin: 10px auto;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
z-index: 100000;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Titles */
|
||||||
|
.page-title {
|
||||||
|
font-size: 2.1rem;
|
||||||
|
/* Slightly larger */
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
|
||||||
|
/* Very subtle shadow */
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
/* Slightly larger */
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title svg {
|
||||||
|
color: var(--color-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.button {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-surface);
|
||||||
|
/* Changed to var for consistency, typically white */
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
/* Ensure 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;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
/* Darker primary */
|
||||||
|
border-color: #2c3e50;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
background-color: #212f3c;
|
||||||
|
/* Even darker primary */
|
||||||
|
border-color: #212f3c;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-color: #c8ced3;
|
||||||
|
/* Darker border */
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:active {
|
||||||
|
background-color: #e0e5ea;
|
||||||
|
/* Darker bg */
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.button.button-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.button-sm:hover {
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.button-sm:active {
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.button.button-danger {
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.button-danger:hover {
|
||||||
|
background-color: #a93226;
|
||||||
|
/* Darker danger */
|
||||||
|
border-color: #a93226;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.button-danger:active {
|
||||||
|
background-color: #922b21;
|
||||||
|
/* Even darker danger */
|
||||||
|
border-color: #922b21;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.button-danger:focus-visible {
|
||||||
|
outline: 2px solid var(--color-danger);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Generic Card */
|
||||||
|
.simple-card {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
/* Standardized lift */
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-card .card-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Card (for listing forms) */
|
||||||
|
.form-card {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
/* transform: translateY(-2px); */
|
||||||
|
/* Standardized lift */
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card-header {
|
||||||
|
padding: 16px;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
/* Clean header */
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-menu {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-menu:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-menu:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.form-card-content {
|
||||||
|
padding: 16px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submission-count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-url-info {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
/* Use main BG for slight recess */
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-url-info input[type="text"] {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: text;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
/* Ensure text color is readable */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-url-info input[type="text"]:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.form-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-badge.archived {
|
||||||
|
background-color: var(--color-archived-bg);
|
||||||
|
color: var(--color-archived);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-badge.active {
|
||||||
|
background-color: var(--color-success-bg);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Dropdown Menu */
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
list-style: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu.show {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
/* Inset outline */
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.text-danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.text-danger:hover {
|
||||||
|
background-color: var(--color-danger-bg);
|
||||||
|
color: var(--color-danger);
|
||||||
|
/* Keep text color as danger */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.text-danger:focus-visible {
|
||||||
|
outline: 2px solid var(--color-danger);
|
||||||
|
background-color: var(--color-danger-bg);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1050;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: rgba(var(--color-primary-rgb), 0.6);
|
||||||
|
/* Primary color backdrop */
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
/* Increased blur */
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
width: 95%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
position: relative;
|
||||||
|
margin: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show .modal-dialog {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: -0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Elements (inputs, labels) */
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.25);
|
||||||
|
/* Accent glow */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific style for the read-only URL input inside .form-url-info */
|
||||||
|
.form-url-info input[type="text"].form-url-display {
|
||||||
|
/* Inherits styles from .form-url-info input[type="text"] */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Form Manager Specific Layouts */
|
||||||
|
.create-form-section {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-section .section-title {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-section .form-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy URL Button Specifics */
|
||||||
|
.copy-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: none;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
/* Use main BG for hover */
|
||||||
|
border-color: #c8ced3;
|
||||||
|
/* Darker border */
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:active {
|
||||||
|
background-color: #e0e5ea;
|
||||||
|
/* Darker bg */
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button .copy-text {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 35px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button.copied .copy-text {
|
||||||
|
color: var(--color-success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button.copied svg {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Submissions Page specific */
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 2.1rem;
|
||||||
|
/* Matched page-title */
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
|
||||||
|
/* Matched page-title */
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info-custom {
|
||||||
|
padding: 16px;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 4px solid var(--color-accent);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submissions Table */
|
||||||
|
.submissions-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 700px;
|
||||||
|
caption-side: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table caption {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table th,
|
||||||
|
.submissions-table td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
/* Use main BG for header */
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table th:first-child {
|
||||||
|
/* No specific radius if table wrapper has it */
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table th:last-child {
|
||||||
|
/* No specific radius if table wrapper has it */
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table tbody tr:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-bg), #000000 3%);
|
||||||
|
/* Subtle darker BG hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.submissions-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease, transform 0.1s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action.delete:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action.delete:focus-visible {
|
||||||
|
outline: 2px solid var(--color-danger);
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.submission-data-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
word-break: break-word;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px dashed var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-data-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-data-item strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-right: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 32px 0 16px 0;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item .page-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 38px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item .page-link:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item .page-link:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.page-item.active .page-link {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-surface);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.active .page-link:hover {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
/* Darker primary */
|
||||||
|
border-color: #2c3e50;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.disabled .page-link {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.disabled .page-link:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.me-2 {
|
||||||
|
margin-right: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-3 {
|
||||||
|
margin-top: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.me-3 {
|
||||||
|
margin-right: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-flex {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-content-between {
|
||||||
|
justify-content: space-between !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-items-center {
|
||||||
|
align-items: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-items-start {
|
||||||
|
align-items: flex-start !important;
|
||||||
|
}
|
240
views/index.ejs
Normal file
240
views/index.ejs
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
<!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 rel="stylesheet" href="/global.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-title">formies</h1>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
<!-- Create New Form -->
|
||||||
|
<section class="create-form-section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true" focusable="false">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
Create New Form
|
||||||
|
</h2>
|
||||||
|
<form action="/admin/create-form" method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="formNameInput" class="form-label">Form Name</label>
|
||||||
|
<input type="text" id="formNameInput" name="formName" placeholder="e.g., Contact Us, Feedback"
|
||||||
|
required aria-describedby="formNameHelp" />
|
||||||
|
<small id="formNameHelp" class="form-text">A descriptive name for your new form.</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true" focusable="false">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
Create Form
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Forms List -->
|
||||||
|
<section>
|
||||||
|
<h2 class="section-title">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true" focusable="false">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||||
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||||
|
<polyline points="10 9 9 9 8 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
Your Forms
|
||||||
|
</h2>
|
||||||
|
<% if (forms.length===0) { %>
|
||||||
|
<p class="alert-info-custom">No forms created yet. Create your first form above!</p>
|
||||||
|
<% } else { %>
|
||||||
|
<% forms.forEach(form=> { %>
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="form-card-header">
|
||||||
|
<h3 class="form-title">
|
||||||
|
<a href="/admin/submissions/<%= form.uuid %>">
|
||||||
|
<%= form.name %>
|
||||||
|
</a>
|
||||||
|
<% if (form.is_archived) { %>
|
||||||
|
<span class="form-badge archived">Archived</span>
|
||||||
|
<% } %>
|
||||||
|
</h3>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button type="button" class="form-menu" data-action="toggle-dropdown"
|
||||||
|
aria-label="Actions for form <%= form.name %>" aria-expanded="false"
|
||||||
|
aria-controls="dropdownMenu<%= form.uuid %>">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
|
||||||
|
focusable="false">
|
||||||
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="19" cy="12" r="1"></circle>
|
||||||
|
<circle cx="5" cy="12" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" id="dropdownMenu<%= form.uuid %>" role="menu"
|
||||||
|
aria-labelledby="actionsButton<%= form.uuid %>">
|
||||||
|
<!-- Added aria-labelledby for context -->
|
||||||
|
<li role="none"><a role="menuitem" class="dropdown-item"
|
||||||
|
href="/admin/submissions/<%= form.uuid %>">View Submissions</a>
|
||||||
|
</li>
|
||||||
|
<li role="none"><a role="menuitem" class="dropdown-item"
|
||||||
|
href="/admin/submissions/<%= form.uuid %>/export">Export
|
||||||
|
Submissions</a>
|
||||||
|
</li>
|
||||||
|
<li role="separator" class="dropdown-divider">
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
</li>
|
||||||
|
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
|
||||||
|
data-action="show-modal"
|
||||||
|
data-modal="renameModal<%= form.uuid %>">Rename Form</button>
|
||||||
|
</li>
|
||||||
|
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
|
||||||
|
data-action="show-modal"
|
||||||
|
data-modal="domainsModal<%= form.uuid %>">Set Allowed
|
||||||
|
Domains</button>
|
||||||
|
</li>
|
||||||
|
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
|
||||||
|
data-action="test-notification" data-form-id="<%= form.uuid %>">Test
|
||||||
|
Notification</button></li>
|
||||||
|
<li role="separator" class="dropdown-divider">
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<form action="/admin/archive-form/<%= form.uuid %>" method="POST"
|
||||||
|
style="display: block;">
|
||||||
|
<input type="hidden" name="archive"
|
||||||
|
value="<%= form.is_archived ? 'false' : 'true' %>" />
|
||||||
|
<button type="submit" class="dropdown-item">
|
||||||
|
<%= form.is_archived ? 'Unarchive Form' : 'Archive Form' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<form action="/admin/delete-form/<%= form.uuid %>" method="POST"
|
||||||
|
style="display: block;">
|
||||||
|
<button type="submit" class="dropdown-item text-danger"
|
||||||
|
onclick="return confirm('Are you sure you want to delete this form? This action cannot be undone.')"
|
||||||
|
aria-describedby="deleteWarning<%= form.uuid %>">Delete
|
||||||
|
Form</button>
|
||||||
|
</form>
|
||||||
|
<span id="deleteWarning<%= form.uuid %>"
|
||||||
|
class="visually-hidden">Warning:
|
||||||
|
Deleting this form is permanent and cannot be undone.</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-card-content">
|
||||||
|
<p class="form-submission-count">
|
||||||
|
<%= form.submission_count %> submission<%= form.submission_count !==1 ? 's' : ''
|
||||||
|
%>
|
||||||
|
</p>
|
||||||
|
<div class="form-url-info">
|
||||||
|
<label for="formUrl<%= form.uuid %>" class="form-label visually-hidden">Form URL
|
||||||
|
for <%= form.name %></label>
|
||||||
|
<input type="text" id="formUrl<%= form.uuid %>" readonly
|
||||||
|
value="<%= appUrl %>/submit/<%= form.uuid %>" class="form-url-display"
|
||||||
|
aria-label="Form URL for <%= form.name %> (Read-only)">
|
||||||
|
<button type="button" class="button button-sm button-secondary copy-button"
|
||||||
|
data-copy-target="#formUrl<%= form.uuid %>" title="Copy URL to clipboard">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
|
||||||
|
focusable="false">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename Modal -->
|
||||||
|
<div class="modal" id="renameModal<%= form.uuid %>" role="dialog" aria-modal="true"
|
||||||
|
aria-hidden="true" aria-labelledby="renameModalTitle<%= form.uuid %>">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="renameModalTitle<%= form.uuid %>">Rename Form</h5>
|
||||||
|
<button type="button" class="btn-close" data-action="hide-modal"
|
||||||
|
data-modal="renameModal<%= form.uuid %>"
|
||||||
|
aria-label="Close rename form modal">×</button>
|
||||||
|
</div>
|
||||||
|
<form action="/admin/update-form-name/<%= form.uuid %>" method="POST">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newName<%= form.uuid %>" class="form-label">New Name</label>
|
||||||
|
<input type="text" id="newName<%= form.uuid %>" name="newName"
|
||||||
|
value="<%= form.name %>" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="button button-secondary"
|
||||||
|
data-action="hide-modal"
|
||||||
|
data-modal="renameModal<%= form.uuid %>">Cancel</button>
|
||||||
|
<button type="submit" class="button">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Allowed Domains Modal -->
|
||||||
|
<div class="modal" id="domainsModal<%= form.uuid %>" role="dialog" aria-modal="true"
|
||||||
|
aria-hidden="true" aria-labelledby="domainsModalTitle<%= form.uuid %>">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="domainsModalTitle<%= form.uuid %>">Set Allowed
|
||||||
|
Domains</h5>
|
||||||
|
<button type="button" class="btn-close" data-action="hide-modal"
|
||||||
|
data-modal="domainsModal<%= form.uuid %>"
|
||||||
|
aria-label="Close allowed domains modal">×</button>
|
||||||
|
</div>
|
||||||
|
<form action="/admin/update-allowed-domains/<%= form.uuid %>" method="POST">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="allowedDomains<%= form.uuid %>" class="form-label">Allowed
|
||||||
|
Domains (comma-separated)</label>
|
||||||
|
<input type="text" id="allowedDomains<%= form.uuid %>"
|
||||||
|
name="allowedDomains"
|
||||||
|
placeholder="example.com, subdomain.example.com"
|
||||||
|
value="<%= form.allowed_domains ? form.allowed_domains.join(', ') : '' %>"
|
||||||
|
aria-describedby="domainsHelp<%= form.uuid %>" />
|
||||||
|
<small class="form-text" id="domainsHelp<%= form.uuid %>">Leave empty to
|
||||||
|
allow submissions from any domain.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="button button-secondary"
|
||||||
|
data-action="hide-modal"
|
||||||
|
data-modal="domainsModal<%= form.uuid %>">Cancel</button>
|
||||||
|
<button type="submit" class="button">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
71
views/main.js
Normal file
71
views/main.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// Dropdown functionality
|
||||||
|
function toggleDropdown(button) {
|
||||||
|
// Find the closest .dropdown and then the .dropdown-menu inside it
|
||||||
|
const dropdown = button.closest('.dropdown');
|
||||||
|
if (!dropdown) return;
|
||||||
|
const dropdownMenu = dropdown.querySelector('.dropdown-menu');
|
||||||
|
if (dropdownMenu) {
|
||||||
|
dropdownMenu.classList.toggle('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal functionality
|
||||||
|
function showModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all event listeners
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Handle dropdown toggles
|
||||||
|
document.querySelectorAll('[data-action="toggle-dropdown"]').forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
toggleDropdown(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle modal show buttons
|
||||||
|
document.querySelectorAll('[data-action="show-modal"]').forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const modalId = this.dataset.modal;
|
||||||
|
showModal(modalId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle modal hide buttons
|
||||||
|
document.querySelectorAll('[data-action="hide-modal"]').forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const modalId = this.dataset.modal;
|
||||||
|
hideModal(modalId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle test notification buttons
|
||||||
|
document.querySelectorAll('[data-action="test-notification"]').forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const formId = this.dataset.formId;
|
||||||
|
// Implement test notification functionality here
|
||||||
|
console.log('Testing notification for form:', formId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
// Only close if click is outside any .dropdown
|
||||||
|
if (!event.target.closest('.dropdown')) {
|
||||||
|
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
|
||||||
|
menu.classList.remove('show');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (event.target.classList.contains('modal')) {
|
||||||
|
event.target.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
177
views/submissions.ejs
Normal file
177
views/submissions.ejs
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Submissions - <%= formName %>
|
||||||
|
</title>
|
||||||
|
<link rel="stylesheet" href="/global.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<main id="main-content">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h1 class="dashboard-title">Submissions for <%= formName %>
|
||||||
|
</h1>
|
||||||
|
<div>
|
||||||
|
<a href="/admin" class="button button-secondary me-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true" focusable="false">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||||
|
<polyline points="12 19 5 12 12 5"></polyline>
|
||||||
|
</svg>
|
||||||
|
Back to Forms
|
||||||
|
</a>
|
||||||
|
<a href="/admin/submissions/<%= formUuid %>/export" class="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true" focusable="false">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
|
<form action="/admin/clear-submissions/<%= formUuid %>" method="POST"
|
||||||
|
style="display: inline-block; margin-left: 0.5rem;">
|
||||||
|
<button type="submit" class="button button-danger"
|
||||||
|
onclick="return confirm('Are you sure you want to delete ALL submissions for this form? This action cannot be undone.')">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path
|
||||||
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
|
||||||
|
</path>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
Clear All Submissions
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (submissions.length===0) { %>
|
||||||
|
<div class="alert-info-custom">No submissions yet for this form.</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="submissions-table-wrapper">
|
||||||
|
<table class="submissions-table">
|
||||||
|
<caption class="visually-hidden">List of submissions for the form named <%= formName %>.
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Submitted At</th>
|
||||||
|
<th scope="col">IP Address</th>
|
||||||
|
<th scope="col">Data</th>
|
||||||
|
<th scope="col" style="text-align: right;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% submissions.forEach(submission=> { %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<%= new Date(submission.submitted_at).toLocaleString() %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= submission.ip_address %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% const data=JSON.parse(submission.data); %>
|
||||||
|
<% Object.entries(data).forEach(([key, value])=> { %>
|
||||||
|
<% if (key !=='honeypot_field' && key !=='_thankyou' ) { %>
|
||||||
|
<div class="submission-data-item">
|
||||||
|
<strong>
|
||||||
|
<%= key %>:
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
<%= typeof value==='object' ? JSON.stringify(value) :
|
||||||
|
value %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% }); %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions">
|
||||||
|
<form action="/admin/delete-submission/<%= submission.id %>"
|
||||||
|
method="POST" style="display: inline;">
|
||||||
|
<button type="submit" class="table-action delete"
|
||||||
|
title="Delete Submission"
|
||||||
|
onclick="return confirm('Are you sure you want to delete this submission?')"
|
||||||
|
aria-label="Delete submission from <%= submission.ip_address %> at <%= new Date(submission.submitted_at).toLocaleString() %>">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" aria-hidden="true"
|
||||||
|
focusable="false">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path
|
||||||
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
|
||||||
|
</path>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<% if (pagination.totalPages> 1) { %>
|
||||||
|
<nav aria-label="Submissions pagination">
|
||||||
|
<ul class="pagination">
|
||||||
|
<% if (pagination.currentPage> 1) { %>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="/admin/submissions/<%= formUuid %>?page=<%= pagination.currentPage - 1 %>&limit=<%= pagination.limit %>">Previous</a>
|
||||||
|
</li>
|
||||||
|
<% } else { %>
|
||||||
|
<li class="page-item disabled"><span class="page-link"
|
||||||
|
aria-disabled="true">Previous</span></li>
|
||||||
|
<% } %>
|
||||||
|
<% for(let i=1; i <=pagination.totalPages; i++) { %>
|
||||||
|
<li
|
||||||
|
class="page-item <%= i === pagination.currentPage ? 'active' : '' %>">
|
||||||
|
<a class="page-link"
|
||||||
|
href="/admin/submissions/<%= formUuid %>?page=<%= i %>&limit=<%= pagination.limit %>"
|
||||||
|
<% if (i===pagination.currentPage) { %>
|
||||||
|
aria-current="page" <% } %>>
|
||||||
|
<%= i %>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<% } %>
|
||||||
|
<% if (pagination.currentPage < pagination.totalPages) { %>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="/admin/submissions/<%= formUuid %>?page=<%= pagination.currentPage + 1 %>&limit=<%= pagination.limit %>">Next</a>
|
||||||
|
</li>
|
||||||
|
<% } else { %>
|
||||||
|
<li class="page-item disabled"><span class="page-link"
|
||||||
|
aria-disabled="true">Next</span></li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="pagination-info" role="status" aria-live="polite">
|
||||||
|
Showing <%= (pagination.currentPage - 1) * pagination.limit + 1 %> to <%=
|
||||||
|
Math.min(pagination.currentPage * pagination.limit, pagination.totalSubmissions) %>
|
||||||
|
of <%= pagination.totalSubmissions %> submissions
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user