demo001
This commit is contained in:
parent
07266f4dcb
commit
fe5184e18c
4
.env
4
.env
@ -1,2 +1,4 @@
|
|||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
INITIAL_ADMIN_PASSWORD=admin
|
INITIAL_ADMIN_PASSWORD=admin
|
||||||
|
ALLOWED_ORIGIN=http://127.0.0.1:5500,http://localhost:5500
|
||||||
|
DATABASE_URL=form_data.db
|
2176
Cargo.lock
generated
2176
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
@ -19,4 +19,21 @@ anyhow = "1.0"
|
|||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
url = "2"
|
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"
|
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM rust:1.70-slim as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
libsqlite3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM debian:bullseye-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libsqlite3-0 \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p /app/data /app/logs
|
||||||
|
|
||||||
|
# Copy the binary from builder
|
||||||
|
COPY --from=builder /app/target/release/formies-be /app/
|
||||||
|
|
||||||
|
# Copy configuration
|
||||||
|
COPY config/default.toml /app/config/default.toml
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
ENV DATABASE_URL=/app/data/form_data.db
|
||||||
|
ENV BIND_ADDRESS=0.0.0.0:8080
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Set proper permissions
|
||||||
|
RUN chown -R nobody:nogroup /app
|
||||||
|
USER nobody
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["./formies-be"]
|
149
README.md
Normal file
149
README.md
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# 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
|
30
config/default.toml
Normal file
30
config/default.toml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
[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
Normal file
1294
design.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
form_data.db
BIN
form_data.db
Binary file not shown.
220
frontend/index.html
Normal file
220
frontend/index.html
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<!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>
|
575
frontend/script.js
Normal file
575
frontend/script.js
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
411
frontend/style.css
Normal file
411
frontend/style.css
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
/* --- 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;
|
||||||
|
}
|
||||||
|
}
|
1555
repomix-output.xml
1555
repomix-output.xml
File diff suppressed because it is too large
Load Diff
18
src/auth.rs
18
src/auth.rs
@ -1,14 +1,14 @@
|
|||||||
// src/auth.rs
|
// src/auth.rs
|
||||||
|
use super::AppState;
|
||||||
use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; // Specific error types
|
use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; // Specific error types
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest,
|
dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest,
|
||||||
HttpRequest, Result as ActixResult,
|
HttpRequest,
|
||||||
};
|
};
|
||||||
use chrono::{Duration, Utc}; // Import chrono for time checks
|
|
||||||
use futures::future::{ready, Ready};
|
use futures::future::{ready, Ready};
|
||||||
use log; // Use the log crate
|
use log; // Use the log crate
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely)
|
||||||
|
|
||||||
// Represents an authenticated user via token
|
// Represents an authenticated user via token
|
||||||
pub struct Auth {
|
pub struct Auth {
|
||||||
@ -23,11 +23,13 @@ impl FromRequest for Auth {
|
|||||||
|
|
||||||
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
||||||
// Extract database connection pool from application data
|
// Extract database connection pool from application data
|
||||||
// Replace .expect() with proper error handling
|
// Extract the *whole* AppState first
|
||||||
let db_data_result = req.app_data::<web::Data<Arc<Mutex<Connection>>>>();
|
let app_state_result = req.app_data::<web::Data<AppState>>();
|
||||||
|
|
||||||
let db_data = match db_data_result {
|
// Get the Arc<Mutex<Connection>> from AppState
|
||||||
Some(data) => data,
|
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 => {
|
None => {
|
||||||
log::error!("Database connection missing in application data configuration.");
|
log::error!("Database connection missing in application data configuration.");
|
||||||
return ready(Err(ErrorInternalServerError(
|
return ready(Err(ErrorInternalServerError(
|
||||||
@ -49,7 +51,7 @@ impl FromRequest for Auth {
|
|||||||
|
|
||||||
// Lock the mutex to get access to the connection
|
// Lock the mutex to get access to the connection
|
||||||
// Handle potential mutex poisoning explicitly
|
// Handle potential mutex poisoning explicitly
|
||||||
let conn_guard = match db_data.lock() {
|
let conn_guard = match db_arc_mutex.lock() {
|
||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
Err(poisoned) => {
|
Err(poisoned) => {
|
||||||
log::error!("Database mutex poisoned: {}", poisoned);
|
log::error!("Database mutex poisoned: {}", poisoned);
|
||||||
|
128
src/db.rs
128
src/db.rs
@ -3,9 +3,7 @@ use anyhow::{anyhow, Context, Result as AnyhowResult};
|
|||||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
use chrono::{Duration as ChronoDuration, Utc}; // Use Utc for timestamps
|
use chrono::{Duration as ChronoDuration, Utc}; // Use Utc for timestamps
|
||||||
use log; // Use the log crate
|
use log; // Use the log crate
|
||||||
use rusqlite::{
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
params, types::Value as RusqliteValue, Connection, OptionalExtension, Result as RusqliteResult,
|
|
||||||
};
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -34,22 +32,42 @@ pub fn init_db(database_url: &str) -> AnyhowResult<Connection> {
|
|||||||
.context("Failed to create 'users' table")?;
|
.context("Failed to create 'users' table")?;
|
||||||
|
|
||||||
log::debug!("Creating 'forms' table if not exists...");
|
log::debug!("Creating 'forms' table if not exists...");
|
||||||
// Storing complex form definitions as JSON blobs in TEXT columns is pragmatic
|
|
||||||
// but sacrifices DB-level type safety and query capabilities. Ensure robust
|
|
||||||
// application-level validation and consider backup strategies carefully.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS forms (
|
"CREATE TABLE IF NOT EXISTS forms (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
fields TEXT NOT NULL, -- Stores JSON definition of form fields
|
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
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.context("Failed to create 'forms' table")?;
|
.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...");
|
log::debug!("Creating 'submissions' table if not exists...");
|
||||||
// Storing submission data as JSON blobs has similar tradeoffs as form fields.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS submissions (
|
"CREATE TABLE IF NOT EXISTS submissions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -250,53 +268,89 @@ pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> Anyh
|
|||||||
// Fetch a specific form definition by its ID
|
// Fetch a specific form definition by its ID
|
||||||
pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Option<models::Form>> {
|
pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult<Option<models::Form>> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id, name, fields FROM forms WHERE id = ?1")
|
.prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1")
|
||||||
.context("Failed to prepare statement for getting form definition")?;
|
.context("Failed to prepare query for fetching form")?;
|
||||||
|
|
||||||
let form_option = stmt
|
let result = stmt
|
||||||
.query_row(params![form_id], |row| {
|
.query_row(params![form_id], |row| {
|
||||||
let id: String = row.get(0)?;
|
let id: String = row.get(0)?;
|
||||||
let name: String = row.get(1)?;
|
let name: String = row.get(1)?;
|
||||||
let fields_str: String = row.get(2)?;
|
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)?;
|
||||||
|
|
||||||
// Ensure fields can be parsed as valid JSON Value
|
// Parse the fields JSON string
|
||||||
let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| {
|
let fields = serde_json::from_str(&fields_str).map_err(|e| {
|
||||||
// Log clearly that this is a data integrity issue
|
|
||||||
log::error!(
|
|
||||||
"Database integrity error: Failed to parse 'fields' JSON for form_id {}: {}. Content: '{}'",
|
|
||||||
id, e, fields_str // Log content if not too large/sensitive
|
|
||||||
);
|
|
||||||
rusqlite::Error::FromSqlConversionFailure(
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
2,
|
2, // Index of 'fields' column
|
||||||
rusqlite::types::Type::Text,
|
rusqlite::types::Type::Text,
|
||||||
Box::new(e),
|
Box::new(e),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// **Basic check**: Ensure fields is an array (common pattern for form definitions)
|
|
||||||
if !fields.is_array() {
|
|
||||||
log::error!(
|
|
||||||
"Database integrity error: 'fields' column for form_id {} is not a JSON array.",
|
|
||||||
id
|
|
||||||
);
|
|
||||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
|
||||||
2,
|
|
||||||
rusqlite::types::Type::Text,
|
|
||||||
"Form fields definition is not a valid JSON array".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(models::Form {
|
Ok(models::Form {
|
||||||
id: Some(id),
|
id: Some(id),
|
||||||
name,
|
name,
|
||||||
fields,
|
fields,
|
||||||
|
notify_email,
|
||||||
|
notify_ntfy_topic, // Include the new field
|
||||||
|
created_at,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.optional() // Handle case where form_id doesn't exist
|
.optional()
|
||||||
.context(format!(
|
.context(format!("Failed to fetch form with ID: {}", form_id))?;
|
||||||
"Failed to execute query for form definition with id {}",
|
|
||||||
form_id
|
|
||||||
))?;
|
|
||||||
|
|
||||||
Ok(form_option)
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
683
src/handlers.rs
683
src/handlers.rs
@ -1,29 +1,16 @@
|
|||||||
// src/handlers.rs
|
|
||||||
use crate::auth::Auth;
|
use crate::auth::Auth;
|
||||||
use crate::models::{Form, LoginCredentials, LoginResponse, Submission};
|
use crate::models::{Form, LoginCredentials, LoginResponse, Submission};
|
||||||
|
use crate::AppState;
|
||||||
use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
|
use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult};
|
||||||
use anyhow::Context; // Import anyhow::Context for error chaining
|
use chrono; // Only import the module since we use it qualified
|
||||||
use log;
|
use log;
|
||||||
use regex::Regex; // For pattern validation
|
use regex::Regex; // For pattern validation
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity
|
use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::error::Error as StdError;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
// --- Helper Function for Database Access ---
|
|
||||||
|
|
||||||
// Gets a database connection from the request data, handling lock errors consistently.
|
|
||||||
fn get_db_conn(
|
|
||||||
db: &web::Data<Arc<Mutex<Connection>>>,
|
|
||||||
) -> Result<std::sync::MutexGuard<'_, Connection>, ActixWebError> {
|
|
||||||
db.lock().map_err(|poisoned| {
|
|
||||||
log::error!("Database mutex poisoned: {}", poisoned);
|
|
||||||
actix_web::error::ErrorInternalServerError("Internal database error (mutex lock)")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helper Function for Validation ---
|
// --- Helper Function for Validation ---
|
||||||
|
|
||||||
/// Validates submission data against the form field definitions with enhanced checks.
|
/// Validates submission data against the form field definitions with enhanced checks.
|
||||||
@ -274,16 +261,18 @@ fn anyhow_to_actix_error(e: anyhow::Error) -> ActixWebError {
|
|||||||
|
|
||||||
// POST /login
|
// POST /login
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
db: web::Data<Arc<Mutex<Connection>>>,
|
app_state: web::Data<AppState>, // Expect AppState like other handlers
|
||||||
creds: web::Json<LoginCredentials>,
|
creds: web::Json<LoginCredentials>,
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
let db_conn = db.clone(); // Clone Arc for use in web::block
|
// Clone the Arc<Mutex<Connection>> from AppState
|
||||||
|
let db_conn_arc = app_state.db.clone();
|
||||||
let username = creds.username.clone();
|
let username = creds.username.clone();
|
||||||
let password = creds.password.clone();
|
let password = creds.password.clone();
|
||||||
|
|
||||||
// Wrap the blocking database operations in web::block
|
// Wrap the blocking database operations in web::block
|
||||||
let auth_result = web::block(move || {
|
let auth_result = web::block(move || {
|
||||||
let conn = db_conn
|
// Use the cloned Arc here
|
||||||
|
let conn = db_conn_arc
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?;
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?;
|
||||||
crate::db::authenticate_user(&conn, &username, &password)
|
crate::db::authenticate_user(&conn, &username, &password)
|
||||||
@ -297,12 +286,14 @@ pub async fn login(
|
|||||||
|
|
||||||
match auth_result {
|
match auth_result {
|
||||||
Some(user_data) => {
|
Some(user_data) => {
|
||||||
let db_conn_token = db.clone(); // Clone Arc again for token generation
|
// 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();
|
let user_id = user_data.id.clone();
|
||||||
|
|
||||||
// Generate and store a new token within web::block
|
// Generate and store a new token within web::block
|
||||||
let token = web::block(move || {
|
let token = web::block(move || {
|
||||||
let conn = db_conn_token
|
// Use the cloned Arc here
|
||||||
|
let conn = db_conn_token_arc
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?;
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?;
|
||||||
crate::db::generate_and_set_token_for_user(&conn, &user_id)
|
crate::db::generate_and_set_token_for_user(&conn, &user_id)
|
||||||
@ -331,26 +322,26 @@ pub async fn login(
|
|||||||
|
|
||||||
// POST /logout
|
// POST /logout
|
||||||
pub async fn logout(
|
pub async fn logout(
|
||||||
db: web::Data<Arc<Mutex<Connection>>>,
|
app_state: web::Data<AppState>, // Expect AppState
|
||||||
auth: Auth, // Requires authentication (extracts user_id from token)
|
auth: Auth, // Requires authentication (extracts user_id from token)
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
log::info!("User {} requesting logout", auth.user_id);
|
log::info!("User {} requesting logout", auth.user_id);
|
||||||
let db_conn = db.clone();
|
let db_conn_arc = app_state.db.clone(); // Get db from AppState
|
||||||
let user_id = auth.user_id.clone();
|
let user_id = auth.user_id.clone();
|
||||||
|
|
||||||
// Invalidate the token in the database within web::block
|
// Invalidate the token in the database within web::block
|
||||||
web::block(move || {
|
web::block(move || {
|
||||||
let conn = db_conn
|
let conn = db_conn_arc // Use the cloned Arc
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?;
|
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?;
|
||||||
crate::db::invalidate_token(&conn, &user_id)
|
crate::db::invalidate_token(&conn, &user_id)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
let user_id = auth.user_id.clone(); // Clone user_id again after the move
|
// Use the original auth.user_id here as user_id moved into the block
|
||||||
log::error!(
|
log::error!(
|
||||||
"web::block error during logout for user {}: {:?}",
|
"web::block error during logout for user {}: {:?}",
|
||||||
user_id,
|
auth.user_id,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
actix_web::error::ErrorInternalServerError("Logout failed (blocking error)")
|
actix_web::error::ErrorInternalServerError("Logout failed (blocking error)")
|
||||||
@ -363,274 +354,205 @@ pub async fn logout(
|
|||||||
|
|
||||||
// POST /forms/{form_id}/submissions
|
// POST /forms/{form_id}/submissions
|
||||||
pub async fn submit_form(
|
pub async fn submit_form(
|
||||||
db: web::Data<Arc<Mutex<Connection>>>,
|
app_state: web::Data<AppState>,
|
||||||
path: web::Path<String>, // Extracts form_id from path
|
path: web::Path<String>, // Extracts form_id from path
|
||||||
submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload
|
submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
let form_id = path.into_inner();
|
let form_id = path.into_inner();
|
||||||
let submission_data = submission_payload.into_inner(); // Get the JSON data
|
let conn = app_state.db.lock().map_err(|e| {
|
||||||
|
log::error!("Failed to acquire database lock: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
|
})?;
|
||||||
|
|
||||||
// --- Stage 1: Fetch form definition (Read-only, can use shared lock) ---
|
// Get form definition
|
||||||
let form_definition = {
|
let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?;
|
||||||
// Acquire lock temporarily for the read operation
|
|
||||||
let conn = get_db_conn(&db)?;
|
|
||||||
match crate::db::get_form_definition(&conn, &form_id) {
|
|
||||||
Ok(Some(form)) => form,
|
|
||||||
Ok(None) => {
|
|
||||||
log::warn!("Submission attempt for non-existent form_id: {}", form_id);
|
|
||||||
return Err(actix_web::error::ErrorNotFound("Form not found"));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to fetch form definition for {}: {:?}", form_id, e);
|
|
||||||
return Err(actix_web::error::ErrorInternalServerError(
|
|
||||||
"Could not retrieve form information",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Lock is released here when 'conn' goes out of scope
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Stage 2: Validate submission against definition (CPU-bound, no DB lock needed) ---
|
// Validate submission against form definition
|
||||||
if let Err(validation_errors) =
|
if let Err(validation_errors) =
|
||||||
validate_submission_against_definition(&submission_data, &form_definition.fields)
|
validate_submission_against_definition(&submission_payload, &form.fields)
|
||||||
{
|
{
|
||||||
log::warn!(
|
|
||||||
"Submission validation failed for form_id {}. Errors: {:?}", // Log actual errors if needed (might be verbose)
|
|
||||||
form_id,
|
|
||||||
validation_errors
|
|
||||||
);
|
|
||||||
// Return 400 Bad Request with validation error details
|
|
||||||
return Ok(HttpResponse::BadRequest().json(validation_errors));
|
return Ok(HttpResponse::BadRequest().json(validation_errors));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Stage 3: Serialize validated data and Insert submission (Write operation, use web::block) ---
|
// Create submission record
|
||||||
let submission_json = match serde_json::to_string(&submission_data) {
|
let submission = Submission {
|
||||||
Ok(json_string) => json_string,
|
id: Uuid::new_v4().to_string(),
|
||||||
Err(e) => {
|
form_id: form_id.clone(),
|
||||||
log::error!(
|
data: submission_payload.into_inner(),
|
||||||
"Failed to serialize validated submission data for form {}: {}",
|
created_at: chrono::Utc::now(),
|
||||||
form_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Err(actix_web::error::ErrorInternalServerError(
|
|
||||||
"Failed to process submission data internally",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let db_conn_write = db.clone(); // Clone Arc for the blocking operation
|
// Save submission to database
|
||||||
let form_id_clone = form_id.clone(); // Clone for closure
|
submission.save(&conn).map_err(|e| {
|
||||||
let submission_id = Uuid::new_v4().to_string(); // Generate unique ID for the submission
|
log::error!("Failed to save submission: {}", e);
|
||||||
let submission_id_clone = submission_id.clone(); // Clone for closure
|
actix_web::error::ErrorInternalServerError("Failed to save submission")
|
||||||
|
})?;
|
||||||
|
|
||||||
web::block(move || {
|
// Send notifications if configured
|
||||||
let conn = db_conn_write.lock().map_err(|_| {
|
if let Some(notify_email) = form.notify_email {
|
||||||
anyhow::anyhow!("Database mutex poisoned during submission insert lock")
|
let email_subject = format!("New submission for form: {}", form.name);
|
||||||
})?;
|
let email_body = format!(
|
||||||
conn.execute(
|
"A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}",
|
||||||
"INSERT INTO submissions (id, form_id, data) VALUES (?1, ?2, ?3)",
|
form.name,
|
||||||
params![submission_id_clone, form_id_clone, submission_json],
|
submission.id,
|
||||||
)
|
submission.created_at,
|
||||||
.context(format!(
|
serde_json::to_string_pretty(&submission.data).unwrap_or_default()
|
||||||
"Failed to insert submission for form {}",
|
|
||||||
form_id_clone
|
|
||||||
))
|
|
||||||
.map_err(anyhow::Error::from)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
log::error!(
|
|
||||||
"web::block error during submission insertion for form {}: {:?}",
|
|
||||||
form_id,
|
|
||||||
e
|
|
||||||
);
|
);
|
||||||
actix_web::error::ErrorInternalServerError("Failed to save submission (blocking error)")
|
|
||||||
})?
|
|
||||||
.map_err(anyhow_to_actix_error)?;
|
|
||||||
|
|
||||||
log::info!(
|
if let Err(e) = app_state
|
||||||
"Successfully inserted submission {} for form_id {}",
|
.notification_service
|
||||||
submission_id,
|
.send_email(¬ify_email, &email_subject, &email_body)
|
||||||
form_id
|
.await
|
||||||
);
|
{
|
||||||
// Return 200 OK with the new submission ID
|
log::warn!("Failed to send email notification: {}", e);
|
||||||
Ok(HttpResponse::Ok().json(json!({ "submission_id": submission_id })))
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Protected Handlers (Require Auth) ---
|
|
||||||
|
|
||||||
// POST /forms
|
// POST /forms
|
||||||
pub async fn create_form(
|
pub async fn create_form(
|
||||||
db: web::Data<Arc<Mutex<Connection>>>,
|
app_state: web::Data<AppState>,
|
||||||
auth: Auth, // Authentication check via Auth extractor
|
_auth: Auth, // Authentication check via Auth extractor
|
||||||
form_payload: web::Json<Form>,
|
payload: web::Json<serde_json::Value>,
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
log::info!(
|
let payload = payload.into_inner();
|
||||||
"User {} attempting to create form: {}",
|
|
||||||
auth.user_id,
|
|
||||||
form_payload.name
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut form = form_payload.into_inner();
|
// Extract form data from payload
|
||||||
// Generate a new UUID for the form if not provided (or overwrite if provided)
|
let name = payload["name"]
|
||||||
let form_id = form.id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
.as_str()
|
||||||
form.id = Some(form_id.clone()); // Ensure the form object has the ID
|
.ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Basic structural validation: Ensure 'fields' is a JSON array before serialization/saving
|
let fields = payload["fields"].clone();
|
||||||
if !form.fields.is_array() {
|
if !fields.is_array() {
|
||||||
log::error!(
|
|
||||||
"User {} attempted to create form '{}' ('{}') where 'fields' is not a JSON array.",
|
|
||||||
auth.user_id,
|
|
||||||
form.name,
|
|
||||||
form_id
|
|
||||||
);
|
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
return Err(actix_web::error::ErrorBadRequest(
|
||||||
"Form 'fields' must be a valid JSON array.",
|
"'fields' must be a JSON array",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// TODO: Add deeper validation of the 'fields' structure itself if needed
|
|
||||||
// e.g., check if each element in 'fields' is an object with 'name' and 'type'.
|
|
||||||
|
|
||||||
// Serialize the fields part to JSON string for DB storage
|
let notify_email = payload["notify_email"].as_str().map(|s| s.to_string());
|
||||||
let fields_json = match serde_json::to_string(&form.fields) {
|
let notify_ntfy_topic = payload["notify_ntfy_topic"].as_str().map(|s| s.to_string());
|
||||||
Ok(json_str) => json_str,
|
|
||||||
Err(e) => {
|
// Create new form
|
||||||
log::error!(
|
let form = Form {
|
||||||
"Failed to serialize form fields for form '{}' ('{}') by user {}: {}",
|
id: None, // Will be generated during save
|
||||||
form.name,
|
name,
|
||||||
form_id,
|
fields,
|
||||||
auth.user_id,
|
notify_email,
|
||||||
e
|
notify_ntfy_topic,
|
||||||
);
|
created_at: chrono::Utc::now(),
|
||||||
return Err(actix_web::error::ErrorInternalServerError(
|
|
||||||
"Failed to process form fields internally",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clone data needed for the blocking database operation
|
// Save the form
|
||||||
let db_conn = db.clone();
|
let conn = app_state.db.lock().map_err(|e| {
|
||||||
// let form_id = form_id; // Already have it from above
|
log::error!("Failed to acquire database lock: {}", e);
|
||||||
let form_name = form.name.clone();
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
let user_id = auth.user_id.clone(); // For logging inside block if needed
|
})?;
|
||||||
|
|
||||||
// Insert the form using web::block for the blocking DB write
|
form.save(&conn).map_err(|e| {
|
||||||
web::block(move || {
|
log::error!("Failed to save form: {}", e);
|
||||||
let conn = db_conn
|
actix_web::error::ErrorInternalServerError("Failed to save form")
|
||||||
.lock()
|
})?;
|
||||||
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during form creation lock"))?;
|
|
||||||
conn.execute(
|
|
||||||
// Consider adding user_id to the forms table if forms are user-specific
|
|
||||||
"INSERT INTO forms (id, name, fields) VALUES (?1, ?2, ?3)",
|
|
||||||
params![form_id, form_name, fields_json],
|
|
||||||
)
|
|
||||||
.context("Failed to insert new form into database")
|
|
||||||
.map_err(anyhow::Error::from)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
log::error!(
|
|
||||||
"web::block error during form creation by user {}: {:?}",
|
|
||||||
auth.user_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
actix_web::error::ErrorInternalServerError("Failed to create form (blocking error)")
|
|
||||||
})?
|
|
||||||
.map_err(anyhow_to_actix_error)?;
|
|
||||||
|
|
||||||
log::info!(
|
Ok(HttpResponse::Created().json(form))
|
||||||
"Successfully created form '{}' with id {} by user {}",
|
|
||||||
form.name,
|
|
||||||
form.id.as_ref().unwrap(), // Safe unwrap as we set it
|
|
||||||
auth.user_id
|
|
||||||
);
|
|
||||||
// Return 200 OK with the newly created form object (including its ID)
|
|
||||||
Ok(HttpResponse::Ok().json(form))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /forms
|
// GET /forms
|
||||||
pub async fn get_forms(
|
pub async fn get_forms(
|
||||||
db: web::Data<Arc<Mutex<Connection>>>,
|
app_state: web::Data<AppState>,
|
||||||
auth: Auth, // Requires authentication
|
auth: Auth, // Requires authentication
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
log::info!("User {} requesting list of forms", auth.user_id);
|
log::info!("User {} requesting list of forms", auth.user_id);
|
||||||
let db_conn = db.clone();
|
|
||||||
let user_id = auth.user_id.clone(); // Clone for logging context if needed inside block
|
|
||||||
|
|
||||||
// Wrap DB query in web::block as it might be slow with many forms or complex parsing
|
let conn = app_state.db.lock().map_err(|e| {
|
||||||
let forms_result = web::block(move || {
|
log::error!("Failed to acquire database lock: {}", e);
|
||||||
let conn = db_conn
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
.lock()
|
})?;
|
||||||
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_forms lock"))?;
|
|
||||||
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id, name, fields FROM forms")
|
.prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms")
|
||||||
.context("Failed to prepare statement for getting forms")?;
|
.map_err(|e| {
|
||||||
|
log::error!("Failed to prepare statement: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
|
})?;
|
||||||
|
|
||||||
let forms_iter = stmt
|
let forms_iter = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let id: String = row.get(0)?;
|
let id: String = row.get(0)?;
|
||||||
let name: String = row.get(1)?;
|
let name: String = row.get(1)?;
|
||||||
let fields_str: String = row.get(2)?;
|
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. If it fails, log the error and skip the row.
|
// Parse the 'fields' JSON string
|
||||||
let fields: serde_json::Value = match serde_json::from_str(&fields_str) {
|
let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| {
|
||||||
Ok(json_value) => json_value,
|
log::error!(
|
||||||
Err(e) => {
|
"DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.",
|
||||||
// Log the data integrity issue clearly
|
id,
|
||||||
log::error!(
|
e
|
||||||
"DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.",
|
);
|
||||||
id, e
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
);
|
2,
|
||||||
// Return a special error that `filter_map` below can catch,
|
rusqlite::types::Type::Text,
|
||||||
// without failing the entire query_map.
|
Box::new(e),
|
||||||
// Using a specific rusqlite error type here is okay.
|
)
|
||||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
})?;
|
||||||
2, // Column index
|
|
||||||
rusqlite::types::Type::Text,
|
|
||||||
Box::new(e) // Box the original error
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Form { id: Some(id), name, fields })
|
Ok(Form {
|
||||||
|
id: Some(id),
|
||||||
|
name,
|
||||||
|
fields,
|
||||||
|
notify_email,
|
||||||
|
notify_ntfy_topic,
|
||||||
|
created_at,
|
||||||
})
|
})
|
||||||
.context("Failed to execute query map for getting forms")?;
|
})
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("Failed to execute query: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
|
})?;
|
||||||
|
|
||||||
// Collect results, filtering out rows that failed parsing WITHIN the block
|
// Collect results, filtering out rows that failed parsing
|
||||||
let forms: Vec<Form> = forms_iter
|
let forms: Vec<Form> = forms_iter
|
||||||
.filter_map(|result| match result {
|
.filter_map(|result| match result {
|
||||||
Ok(form) => Some(form),
|
Ok(form) => Some(form),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Error was already logged inside the query_map closure.
|
log::warn!("Skipping a form row due to a processing error: {}", e);
|
||||||
// We just filter out the failed row here.
|
None
|
||||||
log::warn!("Skipping a form row due to a processing error: {}", e);
|
}
|
||||||
None // Skip this row
|
})
|
||||||
}
|
.collect();
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>(forms) // Ensure block returns Result compatible with flattening
|
log::debug!("Returning {} forms for user {}", forms.len(), auth.user_id);
|
||||||
})
|
Ok(HttpResponse::Ok().json(forms))
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
// Handle web::block error
|
|
||||||
log::error!("web::block error during get_forms for user {}: {:?}", user_id, e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Failed to retrieve forms (blocking error)")
|
|
||||||
})?
|
|
||||||
.map_err(anyhow_to_actix_error)?; // Flatten Result<Result<Vec<Form>, anyhow::Error>, BlockingError>
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Returning {} forms for user {}",
|
|
||||||
forms_result.len(),
|
|
||||||
auth.user_id
|
|
||||||
);
|
|
||||||
Ok(HttpResponse::Ok().json(forms_result))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /forms/{form_id}/submissions
|
// GET /forms/{form_id}/submissions
|
||||||
pub async fn get_submissions(
|
pub async fn get_submissions(
|
||||||
db: web::Data<Arc<Mutex<Connection>>>,
|
app_state: web::Data<AppState>,
|
||||||
auth: Auth, // Requires authentication
|
auth: Auth, // Requires authentication
|
||||||
path: web::Path<String>, // Extracts form_id from the path
|
path: web::Path<String>, // Extracts form_id from the path
|
||||||
) -> ActixResult<impl Responder> {
|
) -> ActixResult<impl Responder> {
|
||||||
@ -641,106 +563,189 @@ pub async fn get_submissions(
|
|||||||
form_id
|
form_id
|
||||||
);
|
);
|
||||||
|
|
||||||
let db_conn = db.clone();
|
let conn = app_state.db.lock().map_err(|e| {
|
||||||
let form_id_clone = form_id.clone();
|
log::error!("Failed to acquire database lock: {}", e);
|
||||||
let user_id = auth.user_id.clone(); // Clone for logging context
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
|
})?;
|
||||||
|
|
||||||
// Wrap DB queries (existence check + fetching submissions) in web::block
|
// Check if the form exists
|
||||||
let submissions_result = web::block(move || {
|
let _form = Form::get_by_id(&conn, &form_id).map_err(|e| {
|
||||||
let conn = db_conn
|
if e.to_string().contains("not found") {
|
||||||
.lock()
|
actix_web::error::ErrorNotFound("Form not found")
|
||||||
.map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_submissions lock"))?;
|
} else {
|
||||||
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
// 1. Check if the form exists first
|
|
||||||
let form_exists: bool = match conn.query_row(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM forms WHERE id = ?1 LIMIT 1)", // Added LIMIT 1 for potential optimization
|
|
||||||
params![form_id_clone],
|
|
||||||
|row| row.get::<_, i32>(0), // sqlite returns 0 or 1 for EXISTS
|
|
||||||
) {
|
|
||||||
Ok(count) => count == 1,
|
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => false, // Should not happen with EXISTS, but handle defensively
|
|
||||||
Err(e) => return Err(anyhow::Error::from(e) // Propagate other DB errors
|
|
||||||
.context(format!("Failed check existence of form {}", form_id_clone))),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !form_exists {
|
|
||||||
// Use Ok(None) to signal "form not found" to the calling async context
|
|
||||||
return Ok(None);
|
|
||||||
}
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
// 2. If form exists, fetch its submissions
|
// Get submissions
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn
|
||||||
"SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", // Include created_at if needed
|
.prepare(
|
||||||
)
|
"SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC",
|
||||||
.context(format!("Failed to prepare statement for getting submissions for form {}", form_id_clone))?;
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("Failed to prepare statement: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
|
})?;
|
||||||
|
|
||||||
let submissions_iter = stmt
|
let submissions_iter = stmt
|
||||||
.query_map(params![form_id_clone], |row| {
|
.query_map(params![form_id], |row| {
|
||||||
let id: String = row.get(0)?;
|
let id: String = row.get(0)?;
|
||||||
let form_id_db: String = row.get(1)?;
|
let form_id: String = row.get(1)?;
|
||||||
let data_str: String = row.get(2)?;
|
let data_str: String = row.get(2)?;
|
||||||
// let created_at: String = row.get(3)?; // Example: If you fetch created_at
|
let created_at: chrono::DateTime<chrono::Utc> = row.get(3)?;
|
||||||
|
|
||||||
// Parse the 'data' JSON string, handling potential errors
|
let data: serde_json::Value = serde_json::from_str(&data_str).map_err(|e| {
|
||||||
let data: serde_json::Value = match serde_json::from_str(&data_str) {
|
log::error!(
|
||||||
Ok(json_value) => json_value,
|
"DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.",
|
||||||
Err(e) => {
|
id,
|
||||||
log::error!(
|
e
|
||||||
"DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.",
|
);
|
||||||
id, e
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
);
|
2,
|
||||||
// Return specific error for filter_map
|
rusqlite::types::Type::Text,
|
||||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
Box::new(e),
|
||||||
2, rusqlite::types::Type::Text, Box::new(e)
|
)
|
||||||
));
|
})?;
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Submission { id, form_id: form_id_db, data }) // Add created_at if fetched
|
Ok(Submission {
|
||||||
})
|
id,
|
||||||
.context(format!("Failed to execute query map for getting submissions for form {}", form_id_clone))?;
|
|
||||||
|
|
||||||
// Collect valid submissions, filtering out rows that failed parsing
|
|
||||||
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 // Skip this row
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Some(submissions)) // Indicate success with the (potentially empty) list of submissions
|
|
||||||
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| { // Handle web::block error (cancellation, panic)
|
|
||||||
log::error!("web::block error during get_submissions for form {} by user {}: {:?}", form_id, user_id, e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Failed to retrieve submissions (blocking error)")
|
|
||||||
})?
|
|
||||||
.map_err(anyhow_to_actix_error)?; // Flatten Result<Result<Option<Vec<Submission>>, anyhow::Error>, BlockingError>
|
|
||||||
|
|
||||||
// Process the result obtained from the web::block
|
|
||||||
match submissions_result {
|
|
||||||
Some(submissions) => {
|
|
||||||
// Form exists, return the found submissions (might be an empty list)
|
|
||||||
log::debug!(
|
|
||||||
"Returning {} submissions for form {} requested by user {}",
|
|
||||||
submissions.len(),
|
|
||||||
form_id,
|
form_id,
|
||||||
auth.user_id
|
data,
|
||||||
);
|
created_at,
|
||||||
Ok(HttpResponse::Ok().json(submissions))
|
})
|
||||||
}
|
})
|
||||||
None => {
|
.map_err(|e| {
|
||||||
// Form was not found (signaled by Ok(None) from the block)
|
log::error!("Failed to execute query: {}", e);
|
||||||
log::warn!(
|
actix_web::error::ErrorInternalServerError("Database error")
|
||||||
"Attempt by user {} to get submissions for non-existent form_id: {}",
|
})?;
|
||||||
auth.user_id,
|
|
||||||
form_id
|
let submissions: Vec<Submission> = submissions_iter
|
||||||
);
|
.filter_map(|result| match result {
|
||||||
Err(actix_web::error::ErrorNotFound("Form not found"))
|
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()
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
264
src/main.rs
264
src/main.rs
@ -1,164 +1,238 @@
|
|||||||
// src/main.rs
|
// src/main.rs
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_files as fs;
|
use actix_files as fs;
|
||||||
use actix_web::{http::header, middleware::Logger, web, App, HttpServer}; // Added Logger explicitly
|
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 dotenv::dotenv;
|
||||||
use log;
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::io::Result as IoResult; // Alias for clarity
|
use std::io::Result as IoResult;
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
// Import modules
|
// Import modules
|
||||||
mod auth;
|
mod auth;
|
||||||
mod db;
|
mod db;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod models;
|
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]
|
#[actix_web::main]
|
||||||
async fn main() -> IoResult<()> {
|
async fn main() -> IoResult<()> {
|
||||||
dotenv().ok(); // Load .env file
|
// Load environment variables from .env file
|
||||||
|
dotenv().ok();
|
||||||
|
|
||||||
// Initialize logger (using RUST_LOG env var)
|
// Initialize Sentry for error tracking
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
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) ---
|
// --- Configuration (Environment Variables) ---
|
||||||
// CRITICAL: Database URL is required
|
let database_url = settings.get_string("DATABASE_URL").unwrap_or_else(|_| {
|
||||||
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| {
|
warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'.");
|
||||||
log::warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'.");
|
|
||||||
"form_data.db".to_string()
|
"form_data.db".to_string()
|
||||||
});
|
});
|
||||||
// CRITICAL: Bind address is required
|
|
||||||
let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| {
|
let bind_address = settings.get_string("BIND_ADDRESS").unwrap_or_else(|_| {
|
||||||
log::warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'.");
|
warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'.");
|
||||||
"127.0.0.1:8080".to_string()
|
"127.0.0.1:8080".to_string()
|
||||||
});
|
});
|
||||||
// CRITICAL: Initial admin credentials (checked in db::init_db)
|
|
||||||
// let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME").expect("Missing INITIAL_ADMIN_USERNAME");
|
|
||||||
// let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD").expect("Missing INITIAL_ADMIN_PASSWORD");
|
|
||||||
// OPTIONAL: Allowed origin for CORS
|
|
||||||
let allowed_origin = env::var("ALLOWED_ORIGIN").ok(); // Use ok() to make it optional
|
|
||||||
|
|
||||||
log::info!(" --- Formies Backend Configuration ---");
|
// Read allowed origins as a comma-separated string, defaulting to empty
|
||||||
log::info!("Required Environment Variables:");
|
let allowed_origins_str = env::var("ALLOWED_ORIGIN").unwrap_or_else(|_| {
|
||||||
log::info!(" - DATABASE_URL (Current: {})", database_url);
|
warn!("ALLOWED_ORIGIN environment variable not set. CORS will be restrictive.");
|
||||||
log::info!(" - BIND_ADDRESS (Current: {})", bind_address);
|
String::new() // Default to empty string if not set
|
||||||
log::info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)");
|
});
|
||||||
log::info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)");
|
|
||||||
log::info!("Optional Environment Variables:");
|
// Split the string into a vector of origins
|
||||||
if let Some(ref origin) = allowed_origin {
|
let allowed_origins_list: Vec<String> = if allowed_origins_str.is_empty() {
|
||||||
log::info!(" - ALLOWED_ORIGIN (Set: {})", origin);
|
Vec::new() // Return an empty vector if the string is empty
|
||||||
} else {
|
} else {
|
||||||
log::warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive, potentially blocking browser access. Set to your frontend URL (e.g., http://localhost:5173 or https://yourdomain.com).");
|
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");
|
||||||
}
|
}
|
||||||
log::info!(" - RUST_LOG (e.g., 'info,formies_be=debug')");
|
info!(" - RUST_LOG (e.g., 'info,formies_be=debug')");
|
||||||
log::info!(" --- End Configuration ---");
|
info!(" --- End Configuration ---");
|
||||||
|
|
||||||
// Initialize database connection
|
// Initialize database connection
|
||||||
let db_connection = match db::init_db(&database_url) {
|
let db_connection = match db::init_db(&database_url) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Specific check for missing admin credentials error
|
|
||||||
if e.to_string().contains("INITIAL_ADMIN_USERNAME")
|
if e.to_string().contains("INITIAL_ADMIN_USERNAME")
|
||||||
|| e.to_string().contains("INITIAL_ADMIN_PASSWORD")
|
|| e.to_string().contains("INITIAL_ADMIN_PASSWORD")
|
||||||
{
|
{
|
||||||
log::error!("FATAL: {}", e);
|
error!("FATAL: {}", e);
|
||||||
log::error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables.");
|
error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables.");
|
||||||
} else {
|
} else {
|
||||||
log::error!(
|
error!(
|
||||||
"FATAL: Failed to initialize database at {}: {:?}",
|
"FATAL: Failed to initialize database at {}: {:?}",
|
||||||
database_url,
|
database_url, e
|
||||||
e
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
process::exit(1); // Exit if DB initialization fails
|
process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrap connection in Arc<Mutex<>> for thread-safe sharing
|
// Initialize rate limiter using the correct fields
|
||||||
let db_data = web::Data::new(Arc::new(Mutex::new(db_connection)));
|
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));
|
||||||
|
|
||||||
log::info!("Starting server at http://{}", bind_address);
|
// 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 || {
|
HttpServer::new(move || {
|
||||||
// Clone shared state for the closure
|
let app_state = app_state.clone();
|
||||||
let db_data_clone = db_data.clone();
|
let allowed_origins = allowed_origins_list.clone();
|
||||||
let allowed_origin_clone = allowed_origin.clone();
|
let rate_limiter = RateLimiter::new(limiter_data.clone());
|
||||||
|
|
||||||
// Configure CORS
|
// Configure CORS
|
||||||
let cors = match allowed_origin_clone {
|
let cors = if !allowed_origins.is_empty() {
|
||||||
Some(origin) => {
|
info!("Configuring CORS for origins: {:?}", allowed_origins);
|
||||||
log::info!("Configuring CORS for specific origin: {}", origin);
|
let mut cors = Cors::default();
|
||||||
Cors::default()
|
for origin in allowed_origins {
|
||||||
.allowed_origin(&origin) // Allow only the specified origin
|
cors = cors.allowed_origin(&origin); // Add each origin
|
||||||
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
|
||||||
.allowed_headers(vec![
|
|
||||||
header::AUTHORIZATION,
|
|
||||||
header::ACCEPT,
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
header::ORIGIN, // Add Origin header if needed
|
|
||||||
header::ACCESS_CONTROL_REQUEST_METHOD,
|
|
||||||
header::ACCESS_CONTROL_REQUEST_HEADERS,
|
|
||||||
])
|
|
||||||
.supports_credentials()
|
|
||||||
.max_age(3600)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Default restrictive CORS: No origin allowed explicitly.
|
|
||||||
// This will likely block browser requests unless the browser and server are on the same origin.
|
|
||||||
log::warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set.");
|
|
||||||
Cors::default() // No allowed_origin set
|
|
||||||
.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)
|
|
||||||
// DO NOT use allow_any_origin() unless you fully understand the security implications.
|
|
||||||
}
|
}
|
||||||
|
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()
|
App::new()
|
||||||
.wrap(cors) // Apply CORS middleware
|
.wrap(cors)
|
||||||
.wrap(Logger::default()) // Add request logging (default format)
|
.wrap(Logger::default())
|
||||||
.app_data(db_data_clone) // Share database connection pool
|
.wrap(tracing_actix_web::TracingLogger::default())
|
||||||
// --- API Routes ---
|
.wrap(rate_limiter)
|
||||||
|
.app_data(app_state)
|
||||||
.service(
|
.service(
|
||||||
web::scope("/api") // Group API routes under /api
|
web::scope("/api")
|
||||||
// --- Public Routes ---
|
// Health check endpoint
|
||||||
|
.route("/health", web::get().to(handlers::health_check))
|
||||||
|
// Public routes
|
||||||
.route("/login", web::post().to(handlers::login))
|
.route("/login", web::post().to(handlers::login))
|
||||||
.route(
|
.route(
|
||||||
"/forms/{form_id}/submissions",
|
"/forms/{form_id}/submissions",
|
||||||
web::post().to(handlers::submit_form),
|
web::post().to(handlers::submit_form),
|
||||||
)
|
)
|
||||||
// --- Protected Routes (using Auth extractor) ---
|
// Protected routes
|
||||||
.route("/logout", web::post().to(handlers::logout)) // Added logout
|
.route("/logout", web::post().to(handlers::logout))
|
||||||
.route("/forms", web::post().to(handlers::create_form))
|
.route("/forms", web::post().to(handlers::create_form))
|
||||||
.route("/forms", web::get().to(handlers::get_forms))
|
.route("/forms", web::get().to(handlers::get_forms))
|
||||||
.route(
|
.route(
|
||||||
"/forms/{form_id}/submissions",
|
"/forms/{form_id}/submissions",
|
||||||
web::get().to(handlers::get_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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// --- Static Files (Serve Frontend - Optional) ---
|
|
||||||
// Assumes frontend build output is in ../frontend/dist
|
|
||||||
// Register this LAST to avoid conflicts with API routes
|
|
||||||
.service(
|
.service(
|
||||||
fs::Files::new("/", "../frontend/dist/")
|
fs::Files::new("/", "./frontend/")
|
||||||
.index_file("index.html")
|
.index_file("index.html")
|
||||||
.use_last_modified(true)
|
.use_last_modified(true)
|
||||||
// Optional: Add a fallback to index.html for SPA routing
|
.default_handler(fs::NamedFile::open("./frontend/index.html").unwrap_or_else(
|
||||||
.default_handler(
|
|_| {
|
||||||
fs::NamedFile::open("../frontend/dist/index.html").unwrap_or_else(|_| {
|
error!("Fallback file not found: ../frontend/index.html");
|
||||||
log::error!("Fallback file not found: ../frontend/dist/index.html");
|
process::exit(1);
|
||||||
process::exit(1); // Exit if fallback file is missing
|
},
|
||||||
}), // Handle error explicitly
|
)),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.bind(&bind_address)?
|
.bind(&bind_address)?
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
// src/models.rs
|
// src/models.rs
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
// Consider adding chrono for DateTime types if needed in responses
|
// Consider adding chrono for DateTime types if needed in responses
|
||||||
// use chrono::{DateTime, Utc};
|
// use chrono::{DateTime, Utc};
|
||||||
@ -28,8 +29,9 @@ pub struct Form {
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
pub fields: serde_json::Value,
|
pub fields: serde_json::Value,
|
||||||
// Optional: Add created_at if needed in API responses
|
pub notify_email: Option<String>,
|
||||||
// pub created_at: Option<DateTime<Utc>>,
|
pub notify_ntfy_topic: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Represents a single submission for a specific form
|
// Represents a single submission for a specific form
|
||||||
@ -41,8 +43,7 @@ pub struct Submission {
|
|||||||
/// Expected to be a JSON object where keys are field names (`name`) from the form definition's `fields` array.
|
/// 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 }`
|
/// Example: `{ "email": "user@example.com", "age": 30 }`
|
||||||
pub data: serde_json::Value,
|
pub data: serde_json::Value,
|
||||||
// Optional: Add created_at if needed in API responses
|
pub created_at: DateTime<Utc>,
|
||||||
// pub created_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used for the /login endpoint request body
|
// Used for the /login endpoint request body
|
||||||
@ -67,73 +68,9 @@ pub struct UserAuthData {
|
|||||||
// Note: Token and expiry are handled separately and not needed in this specific struct
|
// Note: Token and expiry are handled separately and not needed in this specific struct
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Custom Application Error (Optional but Recommended for Consistency) ---
|
// Used for the GET/PUT /forms/{form_id}/notifications endpoints
|
||||||
// Although not fully integrated in this pass to minimize changes,
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
// this shows the structure for future improvement.
|
pub struct NotificationSettingsPayload {
|
||||||
|
pub notify_email: Option<String>,
|
||||||
// use actix_web::{ResponseError, http::StatusCode};
|
pub notify_ntfy_topic: Option<String>,
|
||||||
// use std::fmt;
|
}
|
||||||
|
|
||||||
// #[derive(Debug)]
|
|
||||||
// pub enum AppError {
|
|
||||||
// DatabaseError(anyhow::Error),
|
|
||||||
// ConfigError(String),
|
|
||||||
// ValidationError(serde_json::Value), // Store the validation errors JSON
|
|
||||||
// NotFound(String),
|
|
||||||
// Unauthorized(String),
|
|
||||||
// InternalError(String),
|
|
||||||
// BlockingError(String),
|
|
||||||
// }
|
|
||||||
|
|
||||||
// impl fmt::Display for AppError {
|
|
||||||
// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
// match self {
|
|
||||||
// AppError::DatabaseError(e) => write!(f, "Database error: {}", e),
|
|
||||||
// AppError::ConfigError(s) => write!(f, "Configuration error: {}", s),
|
|
||||||
// AppError::ValidationError(_) => write!(f, "Validation failed"),
|
|
||||||
// AppError::NotFound(s) => write!(f, "Not found: {}", s),
|
|
||||||
// AppError::Unauthorized(s) => write!(f, "Unauthorized: {}", s),
|
|
||||||
// AppError::InternalError(s) => write!(f, "Internal server error: {}", s),
|
|
||||||
// AppError::BlockingError(s) => write!(f, "Blocking operation error: {}", s),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// impl ResponseError for AppError {
|
|
||||||
// fn status_code(&self) -> StatusCode {
|
|
||||||
// match self {
|
|
||||||
// AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
// AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
// AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
|
||||||
// AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
|
||||||
// AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
|
||||||
// AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
// AppError::BlockingError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// fn error_response(&self) -> HttpResponse {
|
|
||||||
// let status = self.status_code();
|
|
||||||
// let error_json = match self {
|
|
||||||
// AppError::ValidationError(errors) => errors.clone(),
|
|
||||||
// // Provide a generic error structure for others
|
|
||||||
// _ => json!({ "error": status.canonical_reason().unwrap_or("Unknown Error"), "message": self.to_string() }),
|
|
||||||
// };
|
|
||||||
|
|
||||||
// HttpResponse::build(status).json(error_json)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Implement From traits to convert other errors into AppError easily
|
|
||||||
// impl From<anyhow::Error> for AppError {
|
|
||||||
// fn from(err: anyhow::Error) -> Self {
|
|
||||||
// // Basic conversion, could add more context analysis here
|
|
||||||
// AppError::DatabaseError(err)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// impl From<actix_web::error::BlockingError> for AppError {
|
|
||||||
// fn from(err: actix_web::error::BlockingError) -> Self {
|
|
||||||
// AppError::BlockingError(err.to_string())
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
// // Add From<rusqlite::Error>, From<serde_json::Error>, etc. as needed
|
|
||||||
|
148
src/notifications.rs
Normal file
148
src/notifications.rs
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
1
tests/handlers_test.rs
Normal file
1
tests/handlers_test.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
Loading…
Reference in New Issue
Block a user