This commit is contained in:
Mohamad.Elsena 2025-05-05 17:01:20 +02:00
parent 07266f4dcb
commit fe5184e18c
19 changed files with 5637 additions and 2174 deletions

4
.env
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

220
frontend/index.html Normal file
View 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">&times;</span>
<h2>Notification Settings for <span id="modal-form-name"></span></h2>
<form id="notification-settings-form">
<input type="hidden" id="modal-form-id" />
<div id="modal-status" class="status"></div>
<label for="modal-notify-email">Notify Email Address:</label>
<input
type="email"
id="modal-notify-email"
name="notify_email"
placeholder="leave blank to disable email" />
<label for="modal-notify-ntfy-topic">Enable ntfy Notification:</label>
<input
type="text"
id="modal-notify-ntfy-topic"
name="notify_ntfy_topic"
placeholder="enter any text to enable (uses global topic)" />
<small
>Enter any non-empty text here (e.g., "yes" or the topic name
itself) to enable ntfy notifications for this form. The notification
will be sent to the globally configured ntfy topic specified in the
backend environment variables. Leave blank to disable ntfy for this
form.</small
>
<div class="modal-actions">
<button type="submit" id="save-notification-settings">
Save Settings
</button>
<button type="button" id="cancel-notification-settings">
Cancel
</button>
</div>
</form>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

575
frontend/script.js Normal file
View 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
View 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -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(())
}
} }

View File

@ -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(&notify_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()
}))
} }

View File

@ -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)?

View File

@ -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
View 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
View File

@ -0,0 +1 @@