-
+# 🦀 RustForms
-✨ Your new, shiny [Nx workspace](https://nx.dev) is almost ready ✨.
+**A lightning-fast, secure, and beautiful form builder built with Rust and Next.js**
-[Learn more about this workspace setup and its capabilities](https://nx.dev/nx-api/next?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed!
+[](https://www.gnu.org/licenses/agpl-3.0)
+[](https://www.rust-lang.org/)
+[](https://nextjs.org/)
+[](https://www.docker.com/)
-## Finish your CI setup
+*Born from passion, powered by innovation*
-[Click here to finish setting up your workspace!](https://cloud.nx.app/connect/RRhUOm2D1t)
+[🚀 Quick Start](#-quick-start) • [📖 Documentation](#-documentation) • [🐳 Docker Deployment](#-docker-deployment) • [🤝 Contributing](#-contributing)
+
-## Run tasks
+---
-To run the dev server for your app, use:
+## 🌟 About RustForms
-```sh
-npx nx dev web-ui
-```
+RustForms is a modern, open-source form builder that combines the performance of Rust with the elegance of React. What started as a fun side project by a passionate developer has evolved into a production-ready solution, thanks to RantAI's unique approach to innovation.
+
+### 🎯 **The RantAI Story**
+
+At [RantAI](https://rantai.dev), we believe in empowering our team members to pursue their creative passions. Our unique approach allows employees to develop side projects that, when they show promise, can become official RantAI products with shared ownership between the creator and the company. RustForms is a perfect example of this philosophy in action - what began as an individual's creative exploration has blossomed into a comprehensive solution that we're proud to share with the open-source community.
+
+### ✨ **Why RustForms?**
+
+- **🚀 Blazing Fast**: Rust-powered backend delivers sub-millisecond response times
+- **🔒 Security First**: Memory-safe Rust eliminates entire classes of vulnerabilities
+- **🎨 Beautiful UI**: Modern, responsive interface built with Next.js and Tailwind CSS
+- **🐳 Easy Deploy**: One-command Docker deployment for effortless self-hosting
+- **📱 Mobile Ready**: Responsive design that works perfectly on all devices
+- **🔧 Developer Friendly**: Comprehensive API, excellent documentation, and great DX
+
+## 🚀 Quick Start
+
+### Prerequisites
+
+- **Node.js** 18+ and **pnpm**
+- **Rust** 1.76+
+- **PostgreSQL** 14+
+- **Docker** (optional, for containerized deployment)
+
+### Development Setup
+
+```bash
+# Clone the repository
+git clone https://github.com/rantai/rustforms.git
+cd rustforms
+
+# Install dependencies
+pnpm install
-To create a production bundle:
+# Set up environment variables
+cp .env.example .env
+# Edit .env with your database credentials and configuration
-```sh
-npx nx build web-ui
+# Run database migrations
+cargo run --bin migrate
+
+# Start the development servers
+pnpm nx dev web-ui # Frontend (http://localhost:3000)
+pnpm nx run api # Backend (http://localhost:3001)
```
-To see all available targets to run for a project, run:
+### 🐳 Docker Deployment (Recommended)
+
+For the fastest setup experience:
+
+```bash
+# Clone and configure
+git clone https://github.com/rantai/rustforms.git
+cd rustforms
+cp .env.example .env
+# Edit .env with your configuration
-```sh
-npx nx show project web-ui
+# One-command deployment
+./deploy.sh
```
-These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files.
+**That's it!** 🎉 RustForms will be running at http://localhost:3000
+
+> 📖 **Detailed deployment guide**: [DOCKER_DEPLOYMENT.md](./DOCKER_DEPLOYMENT.md)
+
+## 🏗️ Architecture
+
+RustForms is built as a modern, full-stack application:
+
+### **Backend (Rust)**
+- **Framework**: [Axum](https://github.com/tokio-rs/axum) - Fast, ergonomic web framework
+- **Database**: PostgreSQL with [SQLx](https://github.com/launchbadge/sqlx) for compile-time SQL checking
+- **Authentication**: JWT-based with secure password hashing (Argon2)
+- **API Documentation**: OpenAPI/Swagger with [utoipa](https://github.com/juhaku/utoipa)
+- **Email**: SMTP integration for form notifications
+
+### **Frontend (Next.js)**
+- **Framework**: Next.js 15 with React 19
+- **Styling**: Tailwind CSS with [shadcn/ui](https://ui.shadcn.com/) components
+- **State Management**: React Query for server state
+- **Type Safety**: Full TypeScript coverage
+- **Authentication**: Secure JWT handling with context providers
+
+### **Infrastructure**
+- **Monorepo**: Nx workspace for unified development experience
+- **Containerization**: Optimized Docker setup for production deployment
+- **Database**: PostgreSQL with performance tuning
+- **Reverse Proxy**: Nginx configuration for production (optional)
+
+## 📖 Documentation
-[More about running tasks in the docs »](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
+| Document | Description |
+|----------|-------------|
+| [🐳 Docker Deployment Guide](./DOCKER_DEPLOYMENT.md) | Complete self-hosting instructions |
+| [🔧 API Analysis](./API_ANALYSIS.md) | Comprehensive API endpoint documentation |
+| [🎨 UI Feature Guide](./BLUR_OVERLAY_GUIDE.md) | Feature management and UI components |
+| [📋 Swagger Implementation](./SWAGGER_IMPLEMENTATION.md) | API documentation setup |
-## Add new projects
+## ⚡ Features
-While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature.
+### **Current Features**
+- ✅ **User Authentication** - Secure registration, login, and session management
+- ✅ **Form Creation** - Intuitive drag-and-drop form builder
+- ✅ **Form Management** - Edit, duplicate, delete, and organize forms
+- ✅ **Responsive Design** - Beautiful UI that works on all devices
+- ✅ **Docker Deployment** - One-command setup for self-hosting
+- ✅ **API Documentation** - Auto-generated Swagger/OpenAPI docs
-Use the plugin's generator to create new projects.
+### **Coming Soon** 🚧
+- 📊 **Form Analytics** - Detailed submission statistics and insights
+- 🔗 **Form Sharing** - Public/private sharing with access controls
+- 📧 **Email Notifications** - Automated responses and notifications
+- 🎨 **Theme Customization** - Custom branding and styling options
+- 📱 **Mobile App** - Native mobile applications
+- 🔌 **Webhooks** - Integration with external services
+- 🌍 **Multi-language** - Internationalization support
-To generate a new application, use:
+## 🛠️ Development
-```sh
-npx nx g @nx/next:app demo
+### **Project Structure**
+
+```
+rustforms/
+├── apps/
+│ ├── api/ # Rust backend (Axum + SQLx)
+│ └── web-ui/ # Next.js frontend
+├── docker/ # Docker configuration files
+├── docs/ # Documentation
+└── deploy.sh # One-command deployment script
+```
+
+### **Development Commands**
+
+```bash
+# Development
+pnpm nx dev web-ui # Start frontend dev server
+pnpm nx run api # Start backend dev server
+pnpm nx graph # Visualize project dependencies
+
+# Building
+pnpm nx build web-ui # Build frontend for production
+pnpm nx build api # Build backend for production
+
+# Testing
+pnpm nx test api # Run backend tests
+pnpm nx lint web-ui # Lint frontend code
+
+# Database
+cargo run --bin migrate # Run database migrations
+cargo run --bin seed # Seed database with sample data
```
-To generate a new library, use:
+### **API Development**
+
+The Rust backend provides a comprehensive REST API:
+
+```bash
+# Start the API server
+pnpm nx run api
-```sh
-npx nx g @nx/react:lib mylib
+# View API documentation
+open http://localhost:3001/swagger-ui
```
-You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list ` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE.
+**Key API Features:**
+- 🔐 JWT Authentication
+- 📝 CRUD operations for forms and submissions
+- 📊 Analytics endpoints
+- 📧 Email notification system
+- 🔍 Advanced filtering and search
+
+## 🤝 Contributing
+
+We welcome contributions from the community! Whether you're fixing bugs, adding features, or improving documentation, your help is appreciated.
+
+### **Getting Started**
+
+1. **Fork the repository**
+2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
+3. **Make your changes** and add tests
+4. **Run the test suite**: `pnpm nx test api`
+5. **Commit your changes**: `git commit -m 'Add amazing feature'`
+6. **Push to your branch**: `git push origin feature/amazing-feature`
+7. **Open a Pull Request**
+
+### **Development Guidelines**
+
+- Follow Rust best practices and run `cargo clippy`
+- Use TypeScript for all frontend code
+- Add tests for new features
+- Update documentation for API changes
+- Follow the existing code style and conventions
+
+### **Community**
+
+- 🐛 **Bug Reports**: [GitHub Issues](https://github.com/rantai/rustforms/issues)
+- 💡 **Feature Requests**: [GitHub Discussions](https://github.com/rantai/rustforms/discussions)
+- 💬 **General Chat**: [RantAI Discord](https://discord.gg/rantai)
+
+## 📄 License
+
+RustForms is open source software licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0)](./LICENSE).
-[Learn more about Nx plugins »](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry »](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
+This means:
+- ✅ **Freedom to use**: Use RustForms for any purpose
+- ✅ **Freedom to study**: Access and modify the source code
+- ✅ **Freedom to share**: Distribute copies to help others
+- ✅ **Freedom to improve**: Distribute modified versions
+- ⚠️ **Network use requirement**: If you run RustForms as a service, you must provide the source code to users
+> **Note**: The AGPL-3.0 license ensures that improvements to RustForms benefit the entire community, even when the software is used to provide network services.
-[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
+## 🙏 Acknowledgments
-## Install Nx Console
+- **Creator**: Built with passion by a RantAI team member
+- **RantAI**: For fostering innovation and supporting open source
+- **Community**: Thanks to all contributors and users
+- **Open Source**: Built on amazing open source technologies
-Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ.
+---
-[Install Nx Console »](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
+
-## Useful links
+**Made with ❤️ by [RantAI](https://rantai.dev)**
-Learn more:
+*Empowering developers to build amazing things*
-- [Learn more about this workspace setup](https://nx.dev/nx-api/next?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
-- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
-- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
-- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
+[🌟 Star us on GitHub](https://github.com/rantai/rustforms) • [🐦 Follow RantAI](https://twitter.com/rantai_dev) • [🌐 Visit RantAI](https://rantai.dev)
-And join the Nx community:
-- [Discord](https://go.nx.dev/community)
-- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl)
-- [Our Youtube channel](https://www.youtube.com/@nxdevtools)
-- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
+
diff --git a/apps/api/.env.example b/apps/api/.env.example
index ac0f330..12e41d2 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -1,10 +1,15 @@
-# A secret token to prevent random spam. This will be part of the URL.
-FORM_SECRET="your-super-secret-unguessable-token"
+# Database connection URL
+DATABASE_URL="postgres://user:password@localhost:5432/rustforms_db"
-# The email address you want to receive form submissions at.
-RECIPIENT_EMAIL="your-email@example.com"
+# JWT secret for authentication tokens
+JWT_SECRET="your-super-secret-jwt-key"
-# Your SMTP provider credentials
+# Your SMTP provider credentials for sending emails to form owners
SMTP_HOST="smtp.example.com"
SMTP_USERNAME="your-smtp-username"
-SMTP_PASSWORD="your-smtp-password"
\ No newline at end of file
+SMTP_PASSWORD="your-smtp-password"
+
+# DEPRECATED: These are no longer needed as of Phase 2
+# Each form now has its own unique secret and emails go to the form owner's email
+# FORM_SECRET="your-super-secret-unguessable-token"
+# RECIPIENT_EMAIL="your-email@example.com"
\ No newline at end of file
diff --git a/apps/api/.sqlx/query-4cd14ef0a86a8e666d0c7482f5b7448633610dc688c1f7d3ade9efb403201c41.json b/apps/api/.sqlx/query-4cd14ef0a86a8e666d0c7482f5b7448633610dc688c1f7d3ade9efb403201c41.json
new file mode 100644
index 0000000..b929583
--- /dev/null
+++ b/apps/api/.sqlx/query-4cd14ef0a86a8e666d0c7482f5b7448633610dc688c1f7d3ade9efb403201c41.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, password_hash FROM users WHERE email = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "password_hash",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "4cd14ef0a86a8e666d0c7482f5b7448633610dc688c1f7d3ade9efb403201c41"
+}
diff --git a/apps/api/.sqlx/query-704ed4ca767a4a490a1289c0c65c9c01f8b4802e23bf9caf4636dd744682aca3.json b/apps/api/.sqlx/query-704ed4ca767a4a490a1289c0c65c9c01f8b4802e23bf9caf4636dd744682aca3.json
new file mode 100644
index 0000000..ca9bf5e
--- /dev/null
+++ b/apps/api/.sqlx/query-704ed4ca767a4a490a1289c0c65c9c01f8b4802e23bf9caf4636dd744682aca3.json
@@ -0,0 +1,52 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT * FROM forms WHERE user_id = $1 ORDER BY created_at DESC",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 5,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "704ed4ca767a4a490a1289c0c65c9c01f8b4802e23bf9caf4636dd744682aca3"
+}
diff --git a/apps/api/.sqlx/query-e2bc28db0fd8a424fc909d5e3fa781a39c1a5dac106d77edeaf7a2c75610b345.json b/apps/api/.sqlx/query-e2bc28db0fd8a424fc909d5e3fa781a39c1a5dac106d77edeaf7a2c75610b345.json
new file mode 100644
index 0000000..b0e5b2e
--- /dev/null
+++ b/apps/api/.sqlx/query-e2bc28db0fd8a424fc909d5e3fa781a39c1a5dac106d77edeaf7a2c75610b345.json
@@ -0,0 +1,34 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT f.id, f.name, u.email as owner_email \n FROM forms f \n JOIN users u ON f.user_id = u.id \n WHERE f.secret = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "owner_email",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "e2bc28db0fd8a424fc909d5e3fa781a39c1a5dac106d77edeaf7a2c75610b345"
+}
diff --git a/apps/api/.sqlx/query-e2ee0027cb21bccf640eded386991e75a7daad5f9face5104bebda09acac83c2.json b/apps/api/.sqlx/query-e2ee0027cb21bccf640eded386991e75a7daad5f9face5104bebda09acac83c2.json
new file mode 100644
index 0000000..b24b384
--- /dev/null
+++ b/apps/api/.sqlx/query-e2ee0027cb21bccf640eded386991e75a7daad5f9face5104bebda09acac83c2.json
@@ -0,0 +1,53 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO forms (user_id, name) VALUES ($1, $2) RETURNING *",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 5,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Varchar"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "e2ee0027cb21bccf640eded386991e75a7daad5f9face5104bebda09acac83c2"
+}
diff --git a/apps/api/.sqlx/query-fd29e5539c03b1d874e9f0805b24bdee2062f7bb6e14d0344a45f507d02843e8.json b/apps/api/.sqlx/query-fd29e5539c03b1d874e9f0805b24bdee2062f7bb6e14d0344a45f507d02843e8.json
new file mode 100644
index 0000000..a2ea503
--- /dev/null
+++ b/apps/api/.sqlx/query-fd29e5539c03b1d874e9f0805b24bdee2062f7bb6e14d0344a45f507d02843e8.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO users (email, password_hash) VALUES ($1, $2)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Varchar"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "fd29e5539c03b1d874e9f0805b24bdee2062f7bb6e14d0344a45f507d02843e8"
+}
diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml
index 838cf09..43370c7 100644
--- a/apps/api/Cargo.toml
+++ b/apps/api/Cargo.toml
@@ -9,6 +9,7 @@ tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenvy = "0.15"
+jsonwebtoken = "9"
# For sending email
# New line - Correct features
@@ -21,4 +22,29 @@ lettre = { version = "0.11", default-features = false, features = [
"tokio1-rustls",
"rustls-platform-verifier",
"ring",
-] }
\ No newline at end of file
+ "file-transport",
+] }
+
+# For Database access with compile-time checks
+sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "macros", "uuid", "chrono", "ipnetwork"] } # <-- ADD "ipnetwork" HERE
+uuid = { version = "1", features = ["v4", "serde"] } # <-- ADD THIS ENTIRE LINE
+chrono = { version = "0.4", features = ["serde"] }
+
+# For securely hashing passwords
+argon2 = "0.5"
+
+# For OpenAPI/Swagger documentation
+utoipa = { version = "4.2.1", features = ["axum_extras", "chrono", "uuid"] }
+utoipa-swagger-ui = { version = "7.0.0", features = ["axum"] } # Check for latest compatible with utoipa and axum
+
+# For CORS support
+tower-http = { version = "0.5", features = ["cors"] }
+
+# For IP address handling
+ipnetwork = "0.20"
+
+# File: apps/api/Cargo.toml
+[dev-dependencies]
+axum-test = "14.0"
+# Add http so we can use status codes like StatusCode::CREATED
+http = "1.1"
\ No newline at end of file
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index 0d64923..bcbb8c1 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -1,29 +1,67 @@
-# ---- Stage 1: The Builder ----
-# Use the official Rust image as a build environment.
-FROM rust:1.79-slim as builder
+# Optimized Dockerfile for RustForms API (Cargo-based build)
+# This replaces the Nx-based build with direct Cargo for better performance
+
+# ---- Stage 1: Rust Build Environment ----
+FROM rust:1.76-slim as builder
+
+# Install required dependencies for compilation
+RUN apt-get update && apt-get install -y \
+ pkg-config \
+ libssl-dev \
+ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /usr/src/rustforms
-# Copy the entire monorepo context to leverage Nx caching
-COPY . .
+# Copy dependency files first for better layer caching
+COPY Cargo.toml Cargo.lock ./
+COPY apps/api/Cargo.toml ./apps/api/
+
+# Create a dummy main.rs to cache dependencies
+RUN mkdir -p apps/api/src && \
+ echo "fn main() {}" > apps/api/src/main.rs
+
+# Build dependencies (this layer will be cached unless Cargo.toml changes)
+RUN cargo build --manifest-path apps/api/Cargo.toml --release
+
+# Remove dummy source
+RUN rm -rf apps/api/src
+
+# Copy actual source code
+COPY apps/api/src ./apps/api/src
-# Build the API in release mode using Nx's runner
-# This creates an optimized binary.
-RUN npx nx run api:build --skip-nx-cache
+# Build the actual application
+RUN cargo build --manifest-path apps/api/Cargo.toml --release
-# ---- Stage 2: The Final Image ----
-# Use a minimal, secure base image for the final container.
+# ---- Stage 2: Runtime Environment ----
FROM debian:12-slim
-# Set the working directory
-WORKDIR /usr/src/rustforms
+# Install only essential runtime dependencies
+RUN apt-get update && apt-get install -y \
+ ca-certificates \
+ libssl3 \
+ curl \
+ && rm -rf /var/lib/apt/lists/* \
+ && apt-get clean
+
+# Create a non-root user for security
+RUN groupadd -r rustforms && useradd -r -g rustforms rustforms
+
+# Create app directory
+WORKDIR /app
+
+# Copy the compiled binary from builder stage
+COPY --from=builder /usr/src/rustforms/target/release/api ./api
+
+# Change ownership to non-root user
+RUN chown rustforms:rustforms /app/api
-# Copy the compiled binary from the 'builder' stage.
-COPY --from=builder /usr/src/rustforms/dist/apps/api/api .
+# Switch to non-root user
+USER rustforms
-# Expose the port the app runs on.
+# Expose the port
EXPOSE 3001
-# The command to run when the container starts.
+# Set the startup command
CMD ["./api"]
\ No newline at end of file
diff --git a/apps/api/Dockerfile.optimized b/apps/api/Dockerfile.optimized
new file mode 100644
index 0000000..d023f5f
--- /dev/null
+++ b/apps/api/Dockerfile.optimized
@@ -0,0 +1,70 @@
+# Optimized Multi-stage Dockerfile for RustForms API
+# This creates a lightweight, performant container suitable for production and community self-hosting
+
+# ---- Stage 1: Rust Build Environment ----
+FROM rust:1.76-slim as rust-builder
+
+# Install required dependencies for compilation
+RUN apt-get update && apt-get install -y \
+ pkg-config \
+ libssl-dev \
+ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create app directory
+WORKDIR /usr/src/rustforms
+
+# Copy dependency files first for better layer caching
+COPY Cargo.toml Cargo.lock ./
+COPY apps/api/Cargo.toml ./apps/api/
+
+# Create a dummy main.rs to cache dependencies
+RUN mkdir -p apps/api/src && \
+ echo "fn main() {}" > apps/api/src/main.rs
+
+# Build dependencies (this layer will be cached unless Cargo.toml changes)
+RUN cargo build --manifest-path apps/api/Cargo.toml --release
+
+# Remove dummy source
+RUN rm -rf apps/api/src
+
+# Copy actual source code
+COPY apps/api/src ./apps/api/src
+
+# Build the actual application
+RUN cargo build --manifest-path apps/api/Cargo.toml --release
+
+# ---- Stage 2: Runtime Environment ----
+FROM debian:12-slim as runtime
+
+# Install only essential runtime dependencies
+RUN apt-get update && apt-get install -y \
+ ca-certificates \
+ libssl3 \
+ && rm -rf /var/lib/apt/lists/* \
+ && apt-get clean
+
+# Create a non-root user for security
+RUN groupadd -r rustforms && useradd -r -g rustforms rustforms
+
+# Create app directory
+WORKDIR /app
+
+# Copy the compiled binary from builder stage
+COPY --from=rust-builder /usr/src/rustforms/target/release/api ./rustforms-api
+
+# Change ownership to non-root user
+RUN chown rustforms:rustforms /app/rustforms-api
+
+# Switch to non-root user
+USER rustforms
+
+# Expose the port
+EXPOSE 3001
+
+# Add health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:3001/health || exit 1
+
+# Set the startup command
+CMD ["./rustforms-api"]
diff --git a/apps/api/migrations/20250617045920_create_users_table.sql b/apps/api/migrations/20250617045920_create_users_table.sql
new file mode 100644
index 0000000..f4a01d9
--- /dev/null
+++ b/apps/api/migrations/20250617045920_create_users_table.sql
@@ -0,0 +1,7 @@
+-- migrations/YYYYMMDDHHMMSS_create_users_table.sql
+CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email VARCHAR(255) UNIQUE NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
\ No newline at end of file
diff --git a/apps/api/migrations/20250621121517_create_forms_table.sql b/apps/api/migrations/20250621121517_create_forms_table.sql
new file mode 100644
index 0000000..4103ebf
--- /dev/null
+++ b/apps/api/migrations/20250621121517_create_forms_table.sql
@@ -0,0 +1,15 @@
+-- Create forms table
+CREATE TABLE forms (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL,
+ secret VARCHAR(255) UNIQUE NOT NULL DEFAULT gen_random_uuid()::text,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Create an index on user_id for faster queries
+CREATE INDEX idx_forms_user_id ON forms(user_id);
+
+-- Create an index on secret for faster lookups
+CREATE INDEX idx_forms_secret ON forms(secret);
diff --git a/apps/api/migrations/20250622000001_create_submissions_table.sql b/apps/api/migrations/20250622000001_create_submissions_table.sql
new file mode 100644
index 0000000..ede897a
--- /dev/null
+++ b/apps/api/migrations/20250622000001_create_submissions_table.sql
@@ -0,0 +1,12 @@
+-- Create submissions table
+CREATE TABLE submissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ form_id UUID NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
+ data JSONB NOT NULL,
+ ip_address INET NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Create index for faster queries
+CREATE INDEX idx_submissions_form_id ON submissions(form_id);
+CREATE INDEX idx_submissions_created_at ON submissions(created_at);
diff --git a/apps/api/src/auth/handler.rs b/apps/api/src/auth/handler.rs
new file mode 100644
index 0000000..9084564
--- /dev/null
+++ b/apps/api/src/auth/handler.rs
@@ -0,0 +1,101 @@
+// File: apps/api/src/auth/handler.rs
+
+use crate::auth::password::{hash_password, verify_password};
+use crate::models::{Claims, LoginPayload, SignupPayload, LoginResponse};
+use crate::state::AppState;
+use axum::{
+ extract::{State, Json},
+ http::StatusCode,
+ response::IntoResponse,
+};
+use chrono::Utc;
+use jsonwebtoken::{encode, Header, EncodingKey};
+use utoipa;
+
+#[utoipa::path(
+ post,
+ path = "/api/auth/signup",
+ request_body = SignupPayload,
+ responses(
+ (status = 201, description = "User created successfully"),
+ (status = 409, description = "Email already exists"),
+ (status = 500, description = "Internal server error")
+ ),
+ tag = "Authentication"
+)]
+pub async fn handle_signup(
+ State(state): State,
+ Json(payload): Json,
+) -> impl IntoResponse {
+ let password_hash = match hash_password(&payload.password) {
+ Ok(hash) => hash,
+ Err(_) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "Failed to hash password",
+ )
+ .into_response()
+ }
+ };
+
+ let result = sqlx::query!(
+ "INSERT INTO users (email, password_hash) VALUES ($1, $2)",
+ payload.email,
+ password_hash
+ )
+ .execute(&state.db_pool)
+ .await;
+
+ match result {
+ Ok(_) => (StatusCode::CREATED, "User created successfully").into_response(),
+ Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
+ (StatusCode::CONFLICT, "Email already exists").into_response()
+ }
+ Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response(),
+ }
+}
+
+#[utoipa::path(
+ post,
+ path = "/api/auth/login",
+ request_body = LoginPayload,
+ responses(
+ (status = 200, description = "Login successful", body = LoginResponse),
+ (status = 401, description = "Invalid credentials"),
+ (status = 500, description = "Internal server error")
+ ),
+ tag = "Authentication"
+)]
+pub async fn handle_login(
+ State(state): State,
+ Json(payload): Json,
+) -> impl IntoResponse {
+ let user = match sqlx::query!(
+ "SELECT id, password_hash FROM users WHERE email = $1",
+ payload.email
+ )
+ .fetch_optional(&state.db_pool)
+ .await
+ {
+ Ok(Some(user)) => user,
+ _ => return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
+ };
+
+ if !verify_password(&payload.password, &user.password_hash).unwrap_or(false) {
+ return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
+ }
+
+ let claims = Claims {
+ sub: user.id.to_string(), // .to_string() works on UUIDs
+ exp: (Utc::now() + chrono::Duration::hours(24)).timestamp() as usize,
+ };
+ let token = encode(
+ &Header::default(),
+ &claims,
+ &EncodingKey::from_secret(state.jwt_secret.as_ref()),
+ )
+ .unwrap();
+
+ let response = LoginResponse { token };
+ (StatusCode::OK, axum::Json(response)).into_response()
+}
\ No newline at end of file
diff --git a/apps/api/src/auth/middleware.rs b/apps/api/src/auth/middleware.rs
new file mode 100644
index 0000000..4dd2c57
--- /dev/null
+++ b/apps/api/src/auth/middleware.rs
@@ -0,0 +1,67 @@
+// File: apps/api/src/auth/middleware.rs
+
+use crate::models::Claims;
+use crate::state::AppState;
+use axum::{
+ async_trait,
+ body::Body,
+ extract::{FromRequestParts, State},
+ http::{header, request::Parts, Request, StatusCode},
+ middleware::Next, // <-- Note: Next is imported here
+ response::Response,
+};
+use jsonwebtoken::{decode, DecodingKey, Validation};
+
+
+pub async fn auth_middleware(
+ State(state): State,
+ mut request: Request,
+ next: Next, // <-- THE FIX: It's just `Next`, not `Next`
+) -> Result {
+ let token = request
+ .headers()
+ .get(header::AUTHORIZATION)
+ .and_then(|auth_header| auth_header.to_str().ok())
+ .and_then(|auth_value| auth_value.strip_prefix("Bearer "));
+
+ let Some(token) = token else {
+ return Err(StatusCode::UNAUTHORIZED);
+ };
+
+ let claims = decode::(
+ token,
+ &DecodingKey::from_secret(state.jwt_secret.as_ref()),
+ &Validation::default(),
+ )
+ .map_err(|_| StatusCode::UNAUTHORIZED)?
+ .claims;
+
+ request.extensions_mut().insert(claims.sub);
+
+ // This line now works, because next.run() expects the Request
+ Ok(next.run(request).await)
+}
+
+
+// This part is unchanged and correct
+pub struct UserId(pub String);
+
+#[async_trait]
+impl FromRequestParts for UserId
+where
+ S: Send + Sync,
+{
+ type Rejection = StatusCode;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ _state: &S,
+ ) -> Result {
+ parts
+ .extensions
+ .get::()
+ .cloned()
+ .map(UserId)
+ .ok_or(StatusCode::INTERNAL_SERVER_ERROR)
+ }
+}
\ No newline at end of file
diff --git a/apps/api/src/auth/mod.rs b/apps/api/src/auth/mod.rs
new file mode 100644
index 0000000..0054470
--- /dev/null
+++ b/apps/api/src/auth/mod.rs
@@ -0,0 +1,4 @@
+// File: apps/api/src/auth/mod.rs
+pub mod handler;
+pub mod middleware;
+pub mod password;
\ No newline at end of file
diff --git a/apps/api/src/auth/password.rs b/apps/api/src/auth/password.rs
new file mode 100644
index 0000000..f66fbbd
--- /dev/null
+++ b/apps/api/src/auth/password.rs
@@ -0,0 +1,18 @@
+use argon2::password_hash::{PasswordHash, SaltString};
+use argon2::{Argon2, PasswordHasher, PasswordVerifier};
+
+pub fn hash_password(password: &str) -> Result {
+ let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
+ let argon2 = Argon2::default();
+ PasswordHasher::hash_password(&argon2, password.as_bytes(), &salt)
+ .map(|hash| hash.to_string())
+}
+
+pub fn verify_password(password: &str, hash: &str) -> Result {
+ PasswordHash::new(hash)
+ .and_then(|parsed_hash| {
+ Argon2::default().verify_password(password.as_bytes(), &parsed_hash)
+ })
+ .map(|_| true)
+ .or(Ok(false))
+}
\ No newline at end of file
diff --git a/apps/api/src/forms/handler.rs b/apps/api/src/forms/handler.rs
new file mode 100644
index 0000000..7c3d6bd
--- /dev/null
+++ b/apps/api/src/forms/handler.rs
@@ -0,0 +1,389 @@
+use crate::auth::middleware::UserId;
+use crate::models::{FormPayload, Form, CreateFormPayload, Submission};
+use crate::state::AppState;
+use axum::{extract::{Json, Path, State, ConnectInfo}, http::StatusCode, response::IntoResponse};
+use lettre::{Message, AsyncTransport};
+use std::sync::Arc;
+use std::net::SocketAddr;
+use uuid::Uuid;
+use utoipa;
+use ipnetwork::IpNetwork;
+
+#[utoipa::path(
+ post,
+ path = "/api/forms",
+ request_body = CreateFormPayload,
+ responses(
+ (status = 201, description = "Form created successfully", body = Form),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Forms"
+)]
+pub async fn create_form(
+ State(state): State,
+ user_id: UserId, // Extracted from the JWT by our middleware
+ Json(payload): Json,
+) -> impl IntoResponse {
+ let user_uuid = match Uuid::parse_str(&user_id.0) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid user ID format").into_response(),
+ };
+
+ match sqlx::query_as!(
+ Form,
+ "INSERT INTO forms (user_id, name) VALUES ($1, $2) RETURNING *",
+ user_uuid,
+ payload.name
+ )
+ .fetch_one(&state.db_pool)
+ .await
+ {
+ Ok(new_form) => (StatusCode::CREATED, axum::Json(new_form)).into_response(),
+ Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create form").into_response(),
+ }
+}
+
+#[utoipa::path(
+ get,
+ path = "/api/forms",
+ responses(
+ (status = 200, description = "List of user's forms", body = [Form]),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Forms"
+)]
+pub async fn get_user_forms(
+ State(state): State,
+ user_id: UserId, // Extracted from the JWT by our middleware
+) -> impl IntoResponse {
+ let user_uuid = match Uuid::parse_str(&user_id.0) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid user ID format").into_response(),
+ };
+
+ match sqlx::query_as!(
+ Form,
+ "SELECT * FROM forms WHERE user_id = $1 ORDER BY created_at DESC",
+ user_uuid
+ )
+ .fetch_all(&state.db_pool)
+ .await
+ {
+ Ok(forms) => (StatusCode::OK, axum::Json(forms)).into_response(),
+ Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch forms").into_response(),
+ }
+}
+
+#[utoipa::path(
+ post,
+ path = "/api/submit/{secret}",
+ request_body = FormPayload,
+ params(
+ ("secret" = String, Path, description = "Form secret for submission")
+ ),
+ responses(
+ (status = 200, description = "Form submission received successfully"),
+ (status = 404, description = "Form not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ tag = "Form Submissions"
+)]
+pub async fn handle_form_submission(
+ State(state): State,
+ ConnectInfo(addr): ConnectInfo,
+ Path(secret): Path,
+ Json(payload): Json,
+) -> impl IntoResponse {
+ // Look up the form by its secret and get the owner's email
+ let form_info = match sqlx::query!(
+ r#"
+ SELECT f.id, f.name, u.email as owner_email
+ FROM forms f
+ JOIN users u ON f.user_id = u.id
+ WHERE f.secret = $1
+ "#,
+ secret
+ )
+ .fetch_optional(&state.db_pool)
+ .await
+ {
+ Ok(Some(form)) => form,
+ Ok(None) => return (StatusCode::NOT_FOUND, "Form not found").into_response(),
+ Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
+ };
+
+ // Store the submission in the database
+ let submission_data = serde_json::to_value(&payload.0).unwrap_or_default();
+ let ip_address = addr.ip();
+ let ip_network = IpNetwork::from(ip_address);
+
+ let _submission = match sqlx::query_as!(
+ Submission,
+ r#"
+ INSERT INTO submissions (form_id, data, ip_address)
+ VALUES ($1, $2, $3)
+ RETURNING id, form_id, data, ip_address::text as "ip_address!", created_at
+ "#,
+ form_info.id,
+ submission_data,
+ ip_network
+ )
+ .fetch_one(&state.db_pool)
+ .await
+ {
+ Ok(submission) => submission,
+ Err(e) => {
+ eprintln!("Failed to store submission: {:?}", e);
+ return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to store submission").into_response();
+ }
+ };
+
+ // Build the email body from the form submission
+ let mut email_body = String::from("New form submission:\n\n");
+ email_body.push_str(&format!("Form: {}\n", form_info.name));
+ email_body.push_str(&format!("Form ID: {}\n", form_info.id));
+ email_body.push_str(&format!("IP Address: {}\n\n", ip_address));
+
+ for (key, value) in payload.0.iter() {
+ email_body.push_str(&format!("{}: {}\n", key, value));
+ }
+
+ // Create the email message, sending to the form owner
+ let email = Message::builder()
+ .from("RustForms ".parse().unwrap())
+ .to(form_info.owner_email.parse().unwrap())
+ .subject(&format!("New submission for form: {}", form_info.name))
+ .body(email_body)
+ .unwrap();
+
+ // Send the email asynchronously
+ let mailer = Arc::clone(&state.mailer);
+ tokio::spawn(async move {
+ match mailer.send(email).await {
+ Ok(_) => println!("Email sent successfully to form owner!"),
+ Err(e) => eprintln!("Failed to send email: {:?}", e),
+ }
+ });
+
+ (StatusCode::OK, "Form submission received").into_response()
+}
+
+#[utoipa::path(
+ get,
+ path = "/api/forms/{id}",
+ params(
+ ("id" = String, Path, description = "Form ID")
+ ),
+ responses(
+ (status = 200, description = "Form details", body = Form),
+ (status = 404, description = "Form not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Forms"
+)]
+pub async fn get_form_by_id(
+ State(state): State,
+ user_id: UserId,
+ Path(form_id): Path,
+) -> impl IntoResponse {
+ let user_uuid = match Uuid::parse_str(&user_id.0) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid user ID format").into_response(),
+ };
+
+ let form_uuid = match Uuid::parse_str(&form_id) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::BAD_REQUEST, "Invalid form ID format").into_response(),
+ };
+
+ match sqlx::query_as!(
+ Form,
+ "SELECT * FROM forms WHERE id = $1 AND user_id = $2",
+ form_uuid,
+ user_uuid
+ )
+ .fetch_optional(&state.db_pool)
+ .await
+ {
+ Ok(Some(form)) => (StatusCode::OK, axum::Json(form)).into_response(),
+ Ok(None) => (StatusCode::NOT_FOUND, "Form not found").into_response(),
+ Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch form").into_response(),
+ }
+}
+
+#[utoipa::path(
+ get,
+ path = "/api/forms/{id}/submissions",
+ params(
+ ("id" = String, Path, description = "Form ID")
+ ),
+ responses(
+ (status = 200, description = "List of form submissions", body = [Submission]),
+ (status = 404, description = "Form not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Form Submissions"
+)]
+pub async fn get_form_submissions(
+ State(state): State,
+ user_id: UserId,
+ Path(form_id): Path,
+) -> impl IntoResponse {
+ let user_uuid = match Uuid::parse_str(&user_id.0) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid user ID format").into_response(),
+ };
+
+ let form_uuid = match Uuid::parse_str(&form_id) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::BAD_REQUEST, "Invalid form ID format").into_response(),
+ };
+
+ // First verify the form belongs to the user
+ let form_exists = match sqlx::query!(
+ "SELECT id FROM forms WHERE id = $1 AND user_id = $2",
+ form_uuid,
+ user_uuid
+ )
+ .fetch_optional(&state.db_pool)
+ .await
+ {
+ Ok(Some(_)) => true,
+ Ok(None) => return (StatusCode::NOT_FOUND, "Form not found").into_response(),
+ Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
+ };
+
+ if !form_exists {
+ return (StatusCode::NOT_FOUND, "Form not found").into_response();
+ }
+
+ // Get submissions for the form
+ match sqlx::query_as!(
+ Submission,
+ r#"
+ SELECT id, form_id, data, ip_address::text as "ip_address!", created_at
+ FROM submissions
+ WHERE form_id = $1
+ ORDER BY created_at DESC
+ "#,
+ form_uuid
+ )
+ .fetch_all(&state.db_pool)
+ .await
+ {
+ Ok(submissions) => (StatusCode::OK, axum::Json(submissions)).into_response(),
+ Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch submissions").into_response(),
+ }
+}
+
+#[utoipa::path(
+ delete,
+ path = "/api/forms/{id}",
+ params(
+ ("id" = String, Path, description = "Form ID")
+ ),
+ responses(
+ (status = 200, description = "Form deleted successfully"),
+ (status = 404, description = "Form not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Forms"
+)]
+pub async fn delete_form(
+ State(state): State,
+ user_id: UserId,
+ Path(form_id): Path,
+) -> impl IntoResponse {
+ let user_uuid = match Uuid::parse_str(&user_id.0) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid user ID format").into_response(),
+ };
+
+ let form_uuid = match Uuid::parse_str(&form_id) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::BAD_REQUEST, "Invalid form ID format").into_response(),
+ };
+
+ match sqlx::query!(
+ "DELETE FROM forms WHERE id = $1 AND user_id = $2",
+ form_uuid,
+ user_uuid
+ )
+ .execute(&state.db_pool)
+ .await
+ {
+ Ok(result) if result.rows_affected() > 0 => {
+ (StatusCode::OK, "Form deleted successfully").into_response()
+ }
+ Ok(_) => (StatusCode::NOT_FOUND, "Form not found").into_response(),
+ Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete form").into_response(),
+ }
+}
+
+#[utoipa::path(
+ delete,
+ path = "/api/submissions/{id}",
+ params(
+ ("id" = String, Path, description = "Submission ID")
+ ),
+ responses(
+ (status = 200, description = "Submission deleted successfully"),
+ (status = 404, description = "Submission not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Form Submissions"
+)]
+pub async fn delete_submission(
+ State(state): State,
+ user_id: UserId,
+ Path(submission_id): Path,
+) -> impl IntoResponse {
+ let user_uuid = match Uuid::parse_str(&user_id.0) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid user ID format").into_response(),
+ };
+
+ let submission_uuid = match Uuid::parse_str(&submission_id) {
+ Ok(id) => id,
+ Err(_) => return (StatusCode::BAD_REQUEST, "Invalid submission ID format").into_response(),
+ };
+
+ // Delete submission only if it belongs to a form owned by the user
+ match sqlx::query!(
+ r#"
+ DELETE FROM submissions
+ WHERE id = $1 AND form_id IN (
+ SELECT id FROM forms WHERE user_id = $2
+ )
+ "#,
+ submission_uuid,
+ user_uuid
+ )
+ .execute(&state.db_pool)
+ .await
+ {
+ Ok(result) if result.rows_affected() > 0 => {
+ (StatusCode::OK, "Submission deleted successfully").into_response()
+ }
+ Ok(_) => (StatusCode::NOT_FOUND, "Submission not found").into_response(),
+ Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete submission").into_response(),
+ }
+}
\ No newline at end of file
diff --git a/apps/api/src/forms/mod.rs b/apps/api/src/forms/mod.rs
new file mode 100644
index 0000000..7d1414b
--- /dev/null
+++ b/apps/api/src/forms/mod.rs
@@ -0,0 +1,2 @@
+// File: apps/api/src/forms/mod.rs
+pub mod handler;
\ No newline at end of file
diff --git a/apps/api/src/lib.rs b/apps/api/src/lib.rs
new file mode 100644
index 0000000..09f04e7
--- /dev/null
+++ b/apps/api/src/lib.rs
@@ -0,0 +1,167 @@
+// File: src/lib.rs
+
+// --- Module Declarations ---
+pub mod auth;
+pub mod forms;
+pub mod models;
+// The openapi mod is removed, as its contents are now in this file.
+pub mod router;
+pub mod state;
+
+// --- Imports ---
+use crate::state::AppState;
+use axum::Router; // Import Axum Router
+use lettre::transport::smtp::authentication::Credentials;
+use lettre::{AsyncSmtpTransport, Tokio1Executor};
+use sqlx::postgres::PgPoolOptions;
+use std::env;
+use std::net::SocketAddr;
+use std::sync::Arc;
+use tokio::net::TcpListener;
+use tower_http::cors::{Any, CorsLayer};
+
+// --- OpenAPI Imports and Definition (Moved from openapi.rs) ---
+use utoipa::{
+ openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
+ Modify, OpenApi,
+};
+use utoipa_swagger_ui::SwaggerUi;
+
+#[derive(OpenApi)]
+#[openapi(
+ paths(
+ crate::auth::handler::handle_signup,
+ crate::auth::handler::handle_login,
+ crate::forms::handler::create_form,
+ crate::forms::handler::get_user_forms,
+ crate::forms::handler::get_form_by_id,
+ crate::forms::handler::delete_form,
+ crate::forms::handler::handle_form_submission,
+ crate::forms::handler::get_form_submissions,
+ crate::forms::handler::delete_submission,
+ ),
+ components(
+ schemas(
+ crate::models::SignupPayload,
+ crate::models::LoginPayload,
+ crate::models::LoginResponse,
+ crate::models::Form,
+ crate::models::CreateFormPayload,
+ crate::models::FormPayload,
+ crate::models::Submission
+ ),
+ ),
+ modifiers(&SecurityAddon),
+ tags(
+ (name = "Authentication", description = "User authentication endpoints"),
+ (name = "Forms", description = "Form management endpoints (requires authentication)"),
+ (name = "Form Submissions", description = "Public form submission endpoints")
+ ),
+ info(
+ title = "RustForms API",
+ version = "1.0.0",
+ description = "A self-hostable form backend service built with Rust",
+ contact(
+ name = "RustForms",
+ email = "contact@rustforms.dev"
+ ),
+ license(
+ name = "MIT",
+ url = "https://opensource.org/licenses/MIT"
+ )
+ ),
+ servers(
+ (url = "http://localhost:3001", description = "Local development server"),
+ (url = "https://api.rustforms.dev", description = "Production server")
+ )
+)]
+struct ApiDoc;
+
+struct SecurityAddon;
+
+impl Modify for SecurityAddon {
+ fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
+ if let Some(components) = openapi.components.as_mut() {
+ components.add_security_scheme(
+ "bearer_auth",
+ SecurityScheme::Http(
+ HttpBuilder::new()
+ .scheme(HttpAuthScheme::Bearer)
+ .bearer_format("JWT")
+ .build(),
+ ),
+ )
+ }
+ }
+}
+
+
+// --- Main Application Logic ---
+pub async fn run() {
+ if std::path::Path::new(".env").exists() {
+ dotenvy::dotenv().expect("Failed to read .env file");
+ println!("Loaded local .env file");
+ } else if dotenvy::from_filename("../../.env.docker").is_ok() {
+ println!("Loaded .env.docker file");
+ } else {
+ panic!("Could not find .env or .env.docker file");
+ }
+ println!("Environment variables loaded.");
+
+ let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
+ let pool = PgPoolOptions::new()
+ .max_connections(5)
+ .connect(&db_url)
+ .await
+ .expect("Failed to create database pool.");
+ println!("Database pool created.");
+
+ sqlx::migrate!("./migrations")
+ .run(&pool)
+ .await
+ .expect("Failed to run database migrations.");
+ println!("Database migrations ran successfully.");
+
+ let state = AppState {
+ db_pool: pool,
+ mailer: Arc::new(setup_mailer()),
+ jwt_secret: env::var("JWT_SECRET").expect("JWT_SECRET must be set"),
+ };
+
+ // --- Final Router Assembly (Mirrors your working project) ---
+ let app = Router::new()
+ // 1. The Swagger UI router, pointing to the ApiDoc generated in this file.
+ .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
+ // 2. Your API router, now nested under the "/api" prefix.
+ .nest("/api", router::create_api_router(state))
+ // 3. Add CORS layer to allow frontend requests
+ .layer(
+ CorsLayer::new()
+ .allow_origin(Any)
+ .allow_methods(Any)
+ .allow_headers(Any)
+ );
+
+
+ let addr = SocketAddr::from(([0, 0, 0, 0], 3001));
+ println!("API listening on {}", addr);
+ println!("Docs available at http://{}/swagger-ui", addr); // Helpful link
+ let listener = TcpListener::bind(addr).await.unwrap();
+ axum::serve(
+ listener,
+ app.into_make_service_with_connect_info::()
+ )
+ .await
+ .unwrap();
+}
+
+fn setup_mailer() -> AsyncSmtpTransport {
+ let smtp_host = env::var("SMTP_HOST").expect("SMTP_HOST must be set");
+ let smtp_user = env::var("SMTP_USERNAME").expect("SMTP_USERNAME must be set");
+ let smtp_pass = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD must be set");
+ let creds = Credentials::new(smtp_user, smtp_pass);
+ AsyncSmtpTransport::::relay(&smtp_host)
+ .unwrap()
+ .credentials(creds)
+ .build()
+}
\ No newline at end of file
diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs
index 43836c3..d2fab15 100644
--- a/apps/api/src/main.rs
+++ b/apps/api/src/main.rs
@@ -1,104 +1,7 @@
-use axum::{
- extract::{Json, Path, State},
- http::StatusCode,
- response::IntoResponse,
- routing::post,
- Router,
-};
-use lettre::{
- transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message,
-};
-use serde::Deserialize;
-use std::collections::HashMap;
-use std::env;
-use std::net::SocketAddr;
-use std::sync::Arc;
-use tokio::net::TcpListener;
-
-// Use a flexible HashMap to accept any form fields.
-#[derive(Deserialize, Debug)]
-struct FormPayload(HashMap);
-
-// Application state to hold our configuration
-#[derive(Clone)]
-struct AppState {
- form_secret: String,
- recipient_email: String,
- mailer: Arc>,
-}
+// File: apps/api/src/main.rs
+use api::run; // Use the run function from our library
#[tokio::main]
async fn main() {
- // Load environment variables from .env file
- dotenvy::dotenv().expect("Failed to read .env file");
-
- let state = AppState {
- form_secret: env::var("FORM_SECRET").expect("FORM_SECRET must be set"),
- recipient_email: env::var("RECIPIENT_EMAIL").expect("RECIPIENT_EMAIL must be set"),
- mailer: Arc::new(setup_mailer()),
- };
-
- // Build our application router
- let app = Router::new()
- .route("/api/forms/:secret", post(handle_form_submission))
- .with_state(state);
-
- // Run the server
- let addr = SocketAddr::from(([0, 0, 0, 0], 3001)); // Run API on a different port
- println!("API listening on {}", addr);
- let listener = TcpListener::bind(addr).await.unwrap();
- axum::serve(listener, app.into_make_service())
- .await
- .unwrap();
-}
-
-async fn handle_form_submission(
- State(state): State,
- Path(secret): Path,
- Json(payload): Json,
-) -> impl IntoResponse {
- // 1. Validate the secret
- if secret != state.form_secret {
- return (StatusCode::UNAUTHORIZED, "Invalid secret").into_response();
- }
-
- // 2. Construct the email body from the form payload
- let mut email_body = String::from("New form submission:\n\n");
- for (key, value) in payload.0.iter() {
- email_body.push_str(&format!("{}: {}\n", key, value));
- }
-
- // 3. Create the email message
- let email = Message::builder()
- .from("RustForms ".parse().unwrap())
- .to(state.recipient_email.parse().unwrap())
- .subject("New Form Submission!")
- .body(email_body)
- .unwrap();
-
- // 4. Send the email asynchronously
- // We clone the mailer Arc to move it into the spawned task
- let mailer = Arc::clone(&state.mailer);
- tokio::spawn(async move {
- match mailer.send(email).await {
- Ok(_) => println!("Email sent successfully!"),
- Err(e) => eprintln!("Failed to send email: {:?}", e),
- }
- });
-
- // 5. Return a quick success response to the client
- (StatusCode::OK, "Form received").into_response()
-}
-
-fn setup_mailer() -> AsyncSmtpTransport {
- let smtp_host = env::var("SMTP_HOST").expect("SMTP_HOST must be set");
- let smtp_user = env::var("SMTP_USERNAME").expect("SMTP_USERNAME must be set");
- let smtp_pass = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD must be set");
-
- let creds = Credentials::new(smtp_user, smtp_pass);
-
- AsyncSmtpTransport::::relay(&smtp_host)
- .unwrap()
- .credentials(creds)
- .build()
+ run().await;
}
\ No newline at end of file
diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs
new file mode 100644
index 0000000..5e7be9c
--- /dev/null
+++ b/apps/api/src/models.rs
@@ -0,0 +1,85 @@
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use uuid::Uuid;
+use utoipa::ToSchema;
+
+#[derive(Deserialize, Debug, ToSchema)]
+#[schema(example = json!({"field1": "value1", "field2": "value2"}))]
+pub struct FormPayload(pub HashMap);
+
+#[derive(Deserialize, ToSchema)]
+#[schema(example = json!({"email": "user@example.com", "password": "password123"}))]
+pub struct SignupPayload {
+ #[schema(example = "user@example.com")]
+ pub email: String,
+ #[schema(example = "password123")]
+ pub password: String,
+}
+
+/// Payload for logging in a user.
+#[derive(Deserialize, ToSchema)]
+#[schema(example = json!({"email": "user@example.com", "password": "password123"}))]
+pub struct LoginPayload {
+ #[schema(example = "user@example.com")]
+ pub email: String,
+ #[schema(example = "password123")]
+ pub password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, ToSchema)]
+pub struct Claims {
+ /// Subject (user id)
+ pub sub: String,
+ /// Expiration time
+ pub exp: usize,
+}
+
+/// Response for successful login
+#[derive(Serialize, ToSchema)]
+#[schema(example = json!({"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."}))]
+pub struct LoginResponse {
+ pub token: String,
+}
+
+/// Model for the forms table
+#[derive(Debug, Serialize, Deserialize, ToSchema)]
+pub struct Form {
+ /// Unique form identifier
+ pub id: Uuid,
+ /// ID of the user who owns this form
+ pub user_id: Uuid,
+ /// Display name for the form
+ #[schema(example = "Contact Form")]
+ pub name: String,
+ /// Unique secret used for form submissions
+ pub secret: String,
+ /// When the form was created
+ pub created_at: DateTime,
+ /// When the form was last updated
+ pub updated_at: DateTime,
+}
+
+/// Payload for creating a new form
+#[derive(Deserialize, ToSchema)]
+#[schema(example = json!({"name": "Contact Form"}))]
+pub struct CreateFormPayload {
+ /// Display name for the form
+ #[schema(example = "Contact Form")]
+ pub name: String,
+}
+
+/// Model for form submissions
+#[derive(Debug, Serialize, Deserialize, ToSchema)]
+pub struct Submission {
+ /// Unique submission identifier
+ pub id: Uuid,
+ /// ID of the form this submission belongs to
+ pub form_id: Uuid,
+ /// The actual form data submitted
+ pub data: serde_json::Value,
+ /// IP address of the submitter
+ pub ip_address: String,
+ /// When the submission was created
+ pub created_at: DateTime,
+}
\ No newline at end of file
diff --git a/apps/api/src/router.rs b/apps/api/src/router.rs
new file mode 100644
index 0000000..8f55247
--- /dev/null
+++ b/apps/api/src/router.rs
@@ -0,0 +1,34 @@
+// File: src/router.rs
+use crate::{
+ auth::{handler as auth_handler, middleware as auth_middleware},
+ forms::handler as forms_handler,
+ state::AppState,
+};
+use axum::{
+ middleware,
+ routing::{delete, get, post},
+ Router,
+};
+
+/// Creates a router for the application's API endpoints.
+/// Note that the "/api" prefix is no longer here; it will be added when nesting this router.
+pub fn create_api_router(state: AppState) -> Router {
+ let protected_routes = Router::new()
+ .route("/forms", get(forms_handler::get_user_forms))
+ .route("/forms", post(forms_handler::create_form))
+ .route("/forms/:id", get(forms_handler::get_form_by_id))
+ .route("/forms/:id", delete(forms_handler::delete_form))
+ .route("/forms/:id/submissions", get(forms_handler::get_form_submissions))
+ .route("/submissions/:id", delete(forms_handler::delete_submission))
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ auth_middleware::auth_middleware,
+ ));
+
+ Router::new()
+ .route("/submit/:secret", post(forms_handler::handle_form_submission))
+ .route("/auth/signup", post(auth_handler::handle_signup))
+ .route("/auth/login", post(auth_handler::handle_login))
+ .merge(protected_routes)
+ .with_state(state)
+}
\ No newline at end of file
diff --git a/apps/api/src/state.rs b/apps/api/src/state.rs
new file mode 100644
index 0000000..01c94a3
--- /dev/null
+++ b/apps/api/src/state.rs
@@ -0,0 +1,11 @@
+use lettre::transport::smtp::AsyncSmtpTransport;
+use lettre::Tokio1Executor;
+use sqlx::PgPool;
+use std::sync::Arc;
+
+#[derive(Clone)]
+pub struct AppState {
+ pub db_pool: PgPool,
+ pub mailer: Arc>,
+ pub jwt_secret: String,
+}
\ No newline at end of file
diff --git a/apps/api/tests/auth_flow.rs b/apps/api/tests/auth_flow.rs
new file mode 100644
index 0000000..89d6fa2
--- /dev/null
+++ b/apps/api/tests/auth_flow.rs
@@ -0,0 +1,117 @@
+// File: apps/api/tests/auth_flow.rs
+
+// We need to bring our app's code (the library) into the test scope
+use api::router::create_api_router;
+use api::state::AppState;
+
+// Dependencies for running tests
+use axum_test::TestServer;
+use http::StatusCode;
+// Use SMTP transport for testing
+use lettre::{AsyncSmtpTransport, Tokio1Executor};
+use sqlx::postgres::PgPoolOptions;
+use sqlx::PgPool;
+use std::sync::Arc;
+
+/// Helper function to create a test server with a fresh, isolated database.
+async fn setup_test_server() -> (TestServer, PgPool) {
+ // Use a different database for testing to keep it isolated from development data
+ let test_db_url = "postgres://user:password@localhost:5432/rustforms_test_db";
+
+ // Create a connection to the main 'postgres' database to manage our test DB
+ // This allows us to drop and create the test database for a clean slate every time.
+ let root_pool = PgPoolOptions::new()
+ .connect("postgres://user:password@localhost:5432/postgres")
+ .await
+ .expect("Failed to connect to root postgres DB");
+
+ // Drop the test DB if it exists from a previous failed run and create a fresh one
+ sqlx::query("DROP DATABASE IF EXISTS rustforms_test_db")
+ .execute(&root_pool)
+ .await
+ .expect("Failed to drop test database");
+ sqlx::query("CREATE DATABASE rustforms_test_db")
+ .execute(&root_pool)
+ .await
+ .expect("Failed to create test database");
+
+ // Now, connect to the newly created, empty test database
+ let pool = PgPoolOptions::new()
+ .connect(test_db_url)
+ .await
+ .expect("Failed to connect to test database");
+
+ // Run migrations on the test database to create our tables
+ sqlx::migrate!("./migrations")
+ .run(&pool)
+ .await
+ .expect("Failed to run migrations on test database");
+
+ // Create a mock mailer for testing. We'll use a dummy SMTP transport
+ // that doesn't actually connect anywhere. For testing, we just need
+ // something that implements the right trait.
+ let mailer = Arc::new(
+ AsyncSmtpTransport::::builder_dangerous("localhost")
+ .port(1) // Use a dummy port that won't actually work
+ .build()
+ );
+
+ // Create the application state required by our router
+ let state = AppState {
+ db_pool: pool.clone(),
+ mailer, // Mock SMTP transport for testing
+ jwt_secret: "a-very-secure-and-secret-test-key".to_string(),
+ };
+
+ // Create the router and wrap it in the TestServer
+ let app = create_api_router(state);
+ (TestServer::new(app).expect("Failed to create test server"), pool)
+}
+
+
+#[tokio::test]
+async fn test_full_auth_flow() {
+ let (server, _pool) = setup_test_server().await;
+
+ // --- Step 1: Test User Signup ---
+ let signup_response = server
+ .post("/api/auth/signup")
+ .json(&serde_json::json!({
+ "email": "testuser@example.com",
+ "password": "strongPassword123"
+ }))
+ .await;
+
+ signup_response.assert_status(StatusCode::CREATED);
+ signup_response.assert_text("User created successfully");
+
+
+ // --- Step 2: Test User Login ---
+ let login_response = server
+ .post("/api/auth/login")
+ .json(&serde_json::json!({
+ "email": "testuser@example.com",
+ "password": "strongPassword123"
+ }))
+ .await;
+
+ login_response.assert_status(StatusCode::OK);
+ // Extract the token from the response body
+ let token_body: serde_json::Value = login_response.json();
+ let auth_token = token_body["token"].as_str().expect("Token not found in response");
+
+
+ // --- Step 3: Test Protected Route Access ---
+ let protected_response = server
+ .get("/api/forms")
+ .add_header(
+ "Authorization".parse().unwrap(),
+ format!("Bearer {}", auth_token).parse().unwrap(),
+ )
+ .await;
+
+ protected_response.assert_status(StatusCode::OK);
+ let protected_body: serde_json::Value = protected_response.json();
+ // The response should be an array of forms (empty for a new user)
+ assert!(protected_body.is_array(), "Expected array of forms but got: {:?}", protected_body);
+}
diff --git a/apps/api/tests/form_submission_flow.rs b/apps/api/tests/form_submission_flow.rs
new file mode 100644
index 0000000..c2217ab
--- /dev/null
+++ b/apps/api/tests/form_submission_flow.rs
@@ -0,0 +1,246 @@
+// File: apps/api/tests/form_submission_flow.rs
+
+use api::router::create_api_router;
+use api::state::AppState;
+use axum_test::TestServer;
+use http::StatusCode;
+use lettre::{AsyncSmtpTransport, Tokio1Executor};
+use sqlx::postgres::PgPoolOptions;
+use sqlx::PgPool;
+use std::sync::Arc;
+
+/// Helper function to create a test server with a fresh, isolated database.
+async fn setup_test_server(db_name: &str) -> (TestServer, PgPool) {
+ // Use a different database for testing to keep it isolated from development data
+ let test_db_url = format!("postgres://user:password@localhost:5432/{}", db_name);
+
+ // Create a connection to the main 'postgres' database to manage our test DB
+ let root_pool = PgPoolOptions::new()
+ .connect("postgres://user:password@localhost:5432/postgres")
+ .await
+ .expect("Failed to connect to root postgres DB");
+
+ // Drop the test DB if it exists from a previous failed run and create a fresh one
+ sqlx::query(&format!("DROP DATABASE IF EXISTS {}", db_name))
+ .execute(&root_pool)
+ .await
+ .expect("Failed to drop test database");
+ sqlx::query(&format!("CREATE DATABASE {}", db_name))
+ .execute(&root_pool)
+ .await
+ .expect("Failed to create test database");
+
+ // Now, connect to the newly created, empty test database
+ let pool = PgPoolOptions::new()
+ .connect(&test_db_url)
+ .await
+ .expect("Failed to connect to test database");
+
+ // Run migrations on the test database to create our tables
+ sqlx::migrate!("./migrations")
+ .run(&pool)
+ .await
+ .expect("Failed to run migrations on test database");
+
+ // Create a mock mailer for testing
+ let mailer = Arc::new(
+ AsyncSmtpTransport::::builder_dangerous("localhost")
+ .port(1) // Use a dummy port that won't actually work
+ .build()
+ );
+
+ // Create the application state required by our router
+ let state = AppState {
+ db_pool: pool.clone(),
+ mailer, // Mock SMTP transport for testing
+ jwt_secret: "a-very-secure-and-secret-test-key".to_string(),
+ };
+
+ // Create the router and wrap it in the TestServer
+ let app = create_api_router(state);
+ (TestServer::new(app).expect("Failed to create test server"), pool)
+}
+
+#[tokio::test]
+async fn test_complete_form_submission_flow() {
+ let (server, _pool) = setup_test_server("rustforms_submission_test").await;
+
+ // --- Step 1: Sign up a user ---
+ let signup_response = server
+ .post("/api/auth/signup")
+ .json(&serde_json::json!({
+ "email": "formowner@example.com",
+ "password": "strongPassword123"
+ }))
+ .await;
+
+ signup_response.assert_status(StatusCode::CREATED);
+
+ // --- Step 2: Log in to get the JWT token ---
+ let login_response = server
+ .post("/api/auth/login")
+ .json(&serde_json::json!({
+ "email": "formowner@example.com",
+ "password": "strongPassword123"
+ }))
+ .await;
+
+ login_response.assert_status(StatusCode::OK);
+ let token_body: serde_json::Value = login_response.json();
+ let auth_token = token_body["token"].as_str().expect("Token not found in response");
+
+ // --- Step 3: Create a new form ---
+ let create_form_response = server
+ .post("/api/forms")
+ .add_header(
+ "Authorization".parse().unwrap(),
+ format!("Bearer {}", auth_token).parse().unwrap(),
+ )
+ .json(&serde_json::json!({
+ "name": "Contact Form"
+ }))
+ .await;
+
+ create_form_response.assert_status(StatusCode::CREATED);
+ let created_form: serde_json::Value = create_form_response.json();
+ assert_eq!(created_form["name"], "Contact Form");
+
+ let form_secret = created_form["secret"].as_str().expect("Form secret should be present");
+ println!("Created form with secret: {}", form_secret);
+
+ // --- Step 4: Submit to the form using its unique secret (public endpoint) ---
+ let form_submission_response = server
+ .post(&format!("/api/forms/{}", form_secret))
+ .json(&serde_json::json!({
+ "name": "John Doe",
+ "email": "john@example.com",
+ "message": "Hello, this is a test message!"
+ }))
+ .await;
+
+ form_submission_response.assert_status(StatusCode::OK);
+ form_submission_response.assert_text("Form submission received");
+
+ // --- Step 5: Test submission to a non-existent form ---
+ let invalid_submission_response = server
+ .post("/api/forms/invalid-secret-123")
+ .json(&serde_json::json!({
+ "name": "Jane Doe",
+ "email": "jane@example.com"
+ }))
+ .await;
+
+ invalid_submission_response.assert_status(StatusCode::NOT_FOUND);
+ invalid_submission_response.assert_text("Form not found");
+
+ // --- Step 6: Verify we can still get the user's forms ---
+ let get_forms_response = server
+ .get("/api/forms")
+ .add_header(
+ "Authorization".parse().unwrap(),
+ format!("Bearer {}", auth_token).parse().unwrap(),
+ )
+ .await;
+
+ get_forms_response.assert_status(StatusCode::OK);
+ let forms: serde_json::Value = get_forms_response.json();
+ assert!(forms.is_array());
+ let forms_array = forms.as_array().unwrap();
+ assert_eq!(forms_array.len(), 1);
+ assert_eq!(forms_array[0]["name"], "Contact Form");
+}
+
+#[tokio::test]
+async fn test_multiple_users_isolated_forms() {
+ let (server, _pool) = setup_test_server("rustforms_isolation_test").await;
+
+ // --- Create User A ---
+ server
+ .post("/api/auth/signup")
+ .json(&serde_json::json!({
+ "email": "usera@example.com",
+ "password": "password123"
+ }))
+ .await
+ .assert_status(StatusCode::CREATED);
+
+ let login_a_response = server
+ .post("/api/auth/login")
+ .json(&serde_json::json!({
+ "email": "usera@example.com",
+ "password": "password123"
+ }))
+ .await;
+ let token_a = login_a_response.json::()["token"].as_str().unwrap().to_string();
+
+ // --- Create User B ---
+ server
+ .post("/api/auth/signup")
+ .json(&serde_json::json!({
+ "email": "userb@example.com",
+ "password": "password123"
+ }))
+ .await
+ .assert_status(StatusCode::CREATED);
+
+ let login_b_response = server
+ .post("/api/auth/login")
+ .json(&serde_json::json!({
+ "email": "userb@example.com",
+ "password": "password123"
+ }))
+ .await;
+ let token_b = login_b_response.json::()["token"].as_str().unwrap().to_string();
+
+ // --- User A creates a form ---
+ let form_a_response = server
+ .post("/api/forms")
+ .add_header("Authorization".parse().unwrap(), format!("Bearer {}", token_a).parse().unwrap())
+ .json(&serde_json::json!({"name": "User A's Form"}))
+ .await;
+ let form_a = form_a_response.json::();
+ let secret_a = form_a["secret"].as_str().unwrap();
+
+ // --- User B creates a form ---
+ let form_b_response = server
+ .post("/api/forms")
+ .add_header("Authorization".parse().unwrap(), format!("Bearer {}", token_b).parse().unwrap())
+ .json(&serde_json::json!({"name": "User B's Form"}))
+ .await;
+ let form_b = form_b_response.json::();
+ let secret_b = form_b["secret"].as_str().unwrap();
+
+ // --- Submit to User A's form ---
+ server
+ .post(&format!("/api/forms/{}", secret_a))
+ .json(&serde_json::json!({"message": "Submission to User A"}))
+ .await
+ .assert_status(StatusCode::OK);
+
+ // --- Submit to User B's form ---
+ server
+ .post(&format!("/api/forms/{}", secret_b))
+ .json(&serde_json::json!({"message": "Submission to User B"}))
+ .await
+ .assert_status(StatusCode::OK);
+
+ // --- Verify User A only sees their own form ---
+ let user_a_forms = server
+ .get("/api/forms")
+ .add_header("Authorization".parse().unwrap(), format!("Bearer {}", token_a).parse().unwrap())
+ .await
+ .json::();
+
+ assert_eq!(user_a_forms.as_array().unwrap().len(), 1);
+ assert_eq!(user_a_forms[0]["name"], "User A's Form");
+
+ // --- Verify User B only sees their own form ---
+ let user_b_forms = server
+ .get("/api/forms")
+ .add_header("Authorization".parse().unwrap(), format!("Bearer {}", token_b).parse().unwrap())
+ .await
+ .json::();
+
+ assert_eq!(user_b_forms.as_array().unwrap().len(), 1);
+ assert_eq!(user_b_forms[0]["name"], "User B's Form");
+}
diff --git a/apps/api/tests/forms_flow.rs b/apps/api/tests/forms_flow.rs
new file mode 100644
index 0000000..f7d36ff
--- /dev/null
+++ b/apps/api/tests/forms_flow.rs
@@ -0,0 +1,125 @@
+// File: apps/api/tests/forms_flow.rs
+
+use api::router::create_api_router;
+use api::state::AppState;
+use axum_test::TestServer;
+use http::StatusCode;
+use lettre::{AsyncSmtpTransport, Tokio1Executor};
+use sqlx::postgres::PgPoolOptions;
+use sqlx::PgPool;
+use std::sync::Arc;
+
+/// Helper function to create a test server with a fresh, isolated database.
+async fn setup_test_server() -> (TestServer, PgPool) {
+ // Use a different database for testing to keep it isolated from development data
+ let test_db_url = "postgres://user:password@localhost:5432/rustforms_test_db";
+
+ // Create a connection to the main 'postgres' database to manage our test DB
+ let root_pool = PgPoolOptions::new()
+ .connect("postgres://user:password@localhost:5432/postgres")
+ .await
+ .expect("Failed to connect to root postgres DB");
+
+ // Drop the test DB if it exists from a previous failed run and create a fresh one
+ sqlx::query("DROP DATABASE IF EXISTS rustforms_test_db")
+ .execute(&root_pool)
+ .await
+ .expect("Failed to drop test database");
+ sqlx::query("CREATE DATABASE rustforms_test_db")
+ .execute(&root_pool)
+ .await
+ .expect("Failed to create test database");
+
+ // Now, connect to the newly created, empty test database
+ let pool = PgPoolOptions::new()
+ .connect(test_db_url)
+ .await
+ .expect("Failed to connect to test database");
+
+ // Run migrations on the test database to create our tables
+ sqlx::migrate!("./migrations")
+ .run(&pool)
+ .await
+ .expect("Failed to run migrations on test database");
+
+ // Create a mock mailer for testing
+ let mailer = Arc::new(
+ AsyncSmtpTransport::::builder_dangerous("localhost")
+ .port(1) // Use a dummy port that won't actually work
+ .build()
+ );
+
+ // Create the application state required by our router
+ let state = AppState {
+ db_pool: pool.clone(),
+ mailer, // Mock SMTP transport for testing
+ jwt_secret: "a-very-secure-and-secret-test-key".to_string(),
+ };
+
+ // Create the router and wrap it in the TestServer
+ let app = create_api_router(state);
+ (TestServer::new(app).expect("Failed to create test server"), pool)
+}
+
+#[tokio::test]
+async fn test_forms_crud_flow() {
+ let (server, _pool) = setup_test_server().await;
+
+ // --- Step 1: Sign up a user ---
+ let signup_response = server
+ .post("/api/auth/signup")
+ .json(&serde_json::json!({
+ "email": "formuser@example.com",
+ "password": "strongPassword123"
+ }))
+ .await;
+
+ signup_response.assert_status(StatusCode::CREATED);
+
+ // --- Step 2: Log in to get the JWT token ---
+ let login_response = server
+ .post("/api/auth/login")
+ .json(&serde_json::json!({
+ "email": "formuser@example.com",
+ "password": "strongPassword123"
+ }))
+ .await;
+
+ login_response.assert_status(StatusCode::OK);
+ let token_body: serde_json::Value = login_response.json();
+ let auth_token = token_body["token"].as_str().expect("Token not found in response");
+
+ // --- Step 3: Create a new form ---
+ let create_form_response = server
+ .post("/api/forms")
+ .add_header(
+ "Authorization".parse().unwrap(),
+ format!("Bearer {}", auth_token).parse().unwrap(),
+ )
+ .json(&serde_json::json!({
+ "name": "Contact Form"
+ }))
+ .await;
+
+ create_form_response.assert_status(StatusCode::CREATED);
+ let created_form: serde_json::Value = create_form_response.json();
+ assert_eq!(created_form["name"], "Contact Form");
+ assert!(created_form["id"].is_string());
+ assert!(created_form["secret"].is_string());
+
+ // --- Step 4: Get user's forms ---
+ let get_forms_response = server
+ .get("/api/forms")
+ .add_header(
+ "Authorization".parse().unwrap(),
+ format!("Bearer {}", auth_token).parse().unwrap(),
+ )
+ .await;
+
+ get_forms_response.assert_status(StatusCode::OK);
+ let forms: serde_json::Value = get_forms_response.json();
+ assert!(forms.is_array());
+ let forms_array = forms.as_array().unwrap();
+ assert_eq!(forms_array.len(), 1);
+ assert_eq!(forms_array[0]["name"], "Contact Form");
+}
diff --git a/apps/web-ui/Dockerfile b/apps/web-ui/Dockerfile
new file mode 100644
index 0000000..d23487d
--- /dev/null
+++ b/apps/web-ui/Dockerfile
@@ -0,0 +1,77 @@
+# Optimized Multi-stage Dockerfile for RustForms Web UI
+# This creates a lightweight, performant Next.js container
+
+# ---- Stage 1: Dependencies ----
+FROM node:18-alpine AS deps
+RUN apk add --no-cache libc6-compat
+
+WORKDIR /app
+
+# Install pnpm
+RUN npm install -g pnpm
+
+# Copy package files
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+COPY apps/web-ui/package.json ./apps/web-ui/
+COPY nx.json ./
+
+# Install dependencies
+RUN pnpm install --frozen-lockfile
+
+# ---- Stage 2: Builder ----
+FROM node:18-alpine AS builder
+WORKDIR /app
+
+# Install pnpm
+RUN npm install -g pnpm
+
+# Copy dependencies from deps stage
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=deps /app/package.json ./package.json
+COPY --from=deps /app/pnpm-lock.yaml ./pnpm-lock.yaml
+COPY --from=deps /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
+COPY --from=deps /app/nx.json ./nx.json
+
+# Copy source code
+COPY apps/web-ui ./apps/web-ui
+COPY tsconfig.base.json ./
+
+# Set environment variables for build
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+# Build the application
+RUN pnpm nx build @rust-forms/web-ui
+
+# ---- Stage 3: Runner ----
+FROM node:18-alpine AS runner
+WORKDIR /app
+
+# Create non-root user for security
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Copy built application
+COPY --from=builder /app/apps/web-ui/public ./public
+COPY --from=builder /app/apps/web-ui/.next/standalone ./
+COPY --from=builder /app/apps/web-ui/.next/static ./.next/static
+
+# Set correct permissions
+RUN chown -R nextjs:nodejs /app
+USER nextjs
+
+# Expose port
+EXPOSE 3000
+
+# Environment variables
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV HOSTNAME="0.0.0.0"
+ENV PORT=3000
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
+
+# Start the application
+CMD ["node", "server.js"]
diff --git a/apps/web-ui/components.json b/apps/web-ui/components.json
new file mode 100644
index 0000000..0dce603
--- /dev/null
+++ b/apps/web-ui/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "styles/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
\ No newline at end of file
diff --git a/apps/web-ui/components/blur-overlay.tsx b/apps/web-ui/components/blur-overlay.tsx
new file mode 100644
index 0000000..9e8d1be
--- /dev/null
+++ b/apps/web-ui/components/blur-overlay.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import { ReactNode } from "react"
+import { Badge } from "@/components/ui/badge"
+import { Lock, Zap } from "lucide-react"
+import { FEATURE_FLAGS, type FeatureFlag } from "@/lib/feature-flags"
+
+interface BlurOverlayProps {
+ children: ReactNode
+ featureFlag?: FeatureFlag
+ isBlurred?: boolean
+ label?: string
+ variant?: "pro" | "coming-soon"
+ className?: string
+}
+
+export function BlurOverlay({
+ children,
+ featureFlag,
+ isBlurred = true,
+ label = "Coming Soon",
+ variant = "coming-soon",
+ className = ""
+}: BlurOverlayProps) {
+ // If a feature flag is provided, use that to determine if content should be blurred
+ const shouldBlur = featureFlag ? FEATURE_FLAGS[featureFlag] : isBlurred
+
+ if (!shouldBlur) {
+ return <>{children}>
+ }
+
+ const icon = variant === "pro" ? :
+ const badgeText = variant === "pro" ? "Pro Feature" : label
+
+ return (
+