From 645a38752822468e9066d9e93f28b69922ef6ebf Mon Sep 17 00:00:00 2001 From: Mahmoud Emad Date: Wed, 7 May 2025 14:03:08 +0300 Subject: [PATCH] feat: Add basic project structure and configuration - Add `.env.template` file for environment variable configuration. - Add `.gitignore` file to ignore generated files and IDE artifacts. - Add `Cargo.toml` file specifying project dependencies. - Add basic project documentation in `README.md` and configuration guide in `docs/configuration.md`. - Add Gitea authentication guide in `docs/gitea-auth.md`. - Add installation guide in `docs/installation.md`. - Add MVC architecture guide in `docs/mvc.md`. - Add views guide in `docs/views.md`. --- .env.template | 29 +++ .gitignore | 28 +++ Cargo.toml | 31 +++ README.md | 133 ++++++++++++- docs/configuration.md | 284 +++++++++++++++++++++++++++ docs/gitea-auth.md | 362 +++++++++++++++++++++++++++++++++++ docs/installation.md | 129 +++++++++++++ docs/mvc.md | 273 ++++++++++++++++++++++++++ docs/views.md | 240 +++++++++++++++++++++++ src/config/mod.rs | 68 +++++++ src/config/oauth.rs | 62 ++++++ src/controllers/auth.rs | 193 +++++++++++++++++++ src/controllers/home.rs | 35 ++++ src/controllers/mod.rs | 3 + src/main.rs | 92 +++++++++ src/middleware/mod.rs | 197 +++++++++++++++++++ src/models/mod.rs | 2 + src/models/user.rs | 62 ++++++ src/routes/mod.rs | 35 ++++ src/static/css/styles.css | 49 +++++ src/utils/mod.rs | 86 +++++++++ src/views/auth/login.html | 45 +++++ src/views/auth/register.html | 44 +++++ src/views/base.html | 66 +++++++ src/views/home/about.html | 51 +++++ src/views/home/index.html | 48 +++++ 26 files changed, 2646 insertions(+), 1 deletion(-) create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 docs/configuration.md create mode 100644 docs/gitea-auth.md create mode 100644 docs/installation.md create mode 100644 docs/mvc.md create mode 100644 docs/views.md create mode 100644 src/config/mod.rs create mode 100644 src/config/oauth.rs create mode 100644 src/controllers/auth.rs create mode 100644 src/controllers/home.rs create mode 100644 src/controllers/mod.rs create mode 100644 src/main.rs create mode 100644 src/middleware/mod.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/user.rs create mode 100644 src/routes/mod.rs create mode 100644 src/static/css/styles.css create mode 100644 src/utils/mod.rs create mode 100644 src/views/auth/login.html create mode 100644 src/views/auth/register.html create mode 100644 src/views/base.html create mode 100644 src/views/home/about.html create mode 100644 src/views/home/index.html diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..cd890bf --- /dev/null +++ b/.env.template @@ -0,0 +1,29 @@ +# Server Configuration +APP__SERVER__HOST=127.0.0.1 +APP__SERVER__PORT=9999 +APP__SERVER__WORKERS=4 + +# Templates Configuration +APP__TEMPLATES__DIR=./src/views + +# Authentication +JWT_SECRET=your_jwt_secret_key +JWT_EXPIRATION_HOURS=24 +SECRET_KEY=your_secret_key_for_session_cookies_at_least_32_bytes_long + +# OAuth Configuration - Gitea +GITEA_CLIENT_ID=your_client_id +GITEA_CLIENT_SECRET=your_client_secret +GITEA_INSTANCE_URL=https://your-gitea-instance.com +APP_URL=http://localhost:9999 + +# Database Configuration +APP__DATABASE__URL=postgres://user:password@localhost/hostbasket +APP__DATABASE__POOL_SIZE=5 + +# Logging +RUST_LOG=info + +# Application Environment +APP_ENV=development +APP_CONFIG=config/local.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f163a60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Generated by Cargo +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# Environment variables +.env + +# IDE files +.idea/ +.vscode/ +*.iml + +# macOS files +.DS_Store + +# Windows files +Thumbs.db +ehthumbs.db +Desktop.ini + +# Log files +*.log diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2f8ae69 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "hostbasket" +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] +description = "A web application framework built with Actix Web and Rust" + +[dependencies] +actix-web = "4.3" +actix-files = "0.6" +actix-session = { version = "0.7", features = ["cookie-session"] } +tera = "1.18" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +config = "0.13" +log = "0.4" +env_logger = "0.10" +dotenv = "0.15" +chrono = { version = "0.4", features = ["serde"] } +jsonwebtoken = "8.3" +lazy_static = "1.4" +futures-util = "0.3" +num_cpus = "1.15" +bcrypt = "0.14" +uuid = { version = "1.3", features = ["v4", "serde"] } +oauth2 = "4.3" +reqwest = { version = "0.11", features = ["json"] } + +[dev-dependencies] +actix-rt = "2.8" +tokio = { version = "1.28", features = ["full"] } diff --git a/README.md b/README.md index 633c690..3dd5932 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,133 @@ -# webframework_template +# Hostbasket Starterkit +Welcome to the Hostbasket Starterkit! This guide will help you get started with the Hostbasket project, a comprehensive platform built with Actix Web and Rust. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Installation](#installation) +3. [Usage](#usage) +4. [Creating Views](#creating-views) +5. [Authentication with Gitea](#authentication-with-gitea) +6. [Documentation](#documentation) + +## Introduction + +Hostbasket is a web application framework built with Actix Web, a powerful, pragmatic, and extremely fast web framework for Rust. It follows the MVC (Model-View-Controller) architecture and uses Tera templates for rendering views. + +### Features + +- **Actix Web**: A powerful, pragmatic, and extremely fast web framework for Rust +- **Tera Templates**: A template engine inspired by Jinja2 and Django templates +- **Bootstrap 5.3.5**: A popular CSS framework for responsive web design +- **MVC Architecture**: Clean separation of concerns with Models, Views, and Controllers +- **Middleware Support**: Custom middleware for request timing and security headers +- **Configuration Management**: Flexible configuration system with environment variable support +- **Static File Serving**: Serve CSS, JavaScript, and other static assets + +## Installation + +### Prerequisites + +- Rust and Cargo (latest stable version) +- Git + +### Setup + +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/hostbasket.git + cd hostbasket + ``` + +2. Build the project: + ```bash + cargo build + ``` + +3. Run the application: + ```bash + cargo run + ``` + +4. Open your browser and navigate to `http://localhost:9999` + +For more detailed installation instructions, see [Installation Guide](docs/installation.md). + +## Usage + +### Basic Usage + +Once the application is running, you can access it through your web browser at `http://localhost:9999`. The default configuration provides: + +- Home page at `/` +- About page at `/about` +- Contact page at `/contact` +- Login page at `/login` +- Registration page at `/register` + +### Configuration + +The application can be configured using environment variables or configuration files. The following environment variables are supported: + +- `APP__SERVER__HOST`: The host address to bind to (default: 127.0.0.1) +- `APP__SERVER__PORT`: The port to listen on (default: 9999) +- `APP__SERVER__WORKERS`: The number of worker threads (default: number of CPU cores) +- `APP__TEMPLATES__DIR`: The directory containing templates (default: ./src/views) + +For more detailed usage instructions, see [Usage Guide](docs/usage.md). + +## Creating Views + +Hostbasket uses Tera templates for rendering views. Templates are stored in the `src/views` directory. + +### Basic Template Structure + +```html +{% extends "base.html" %} + +{% block title %}Page Title{% endblock %} + +{% block content %} +
+

Hello, World!

+

This is a basic template.

+
+{% endblock %} +``` + +### Adding a New Page + +1. Create a new template in the `src/views` directory +2. Add a new handler method in the appropriate controller +3. Add a new route in the `src/routes/mod.rs` file + +For more detailed information on creating views, see [Views Guide](docs/views.md). + +## Authentication with Gitea + +Hostbasket supports authentication with Gitea using OAuth. This allows users to log in using their Gitea accounts. + +### Setup + +1. Register a new OAuth application in your Gitea instance +2. Configure the OAuth credentials in your Hostbasket application +3. Implement the OAuth flow in your application + +For more detailed information on Gitea authentication, see [Gitea Authentication Guide](docs/gitea-auth.md). + +## Documentation + +For more detailed documentation, please refer to the following guides: + +- [Installation Guide](docs/installation.md) +- [Usage Guide](docs/usage.md) +- [Views Guide](docs/views.md) +- [MVC Architecture Guide](docs/mvc.md) +- [Gitea Authentication Guide](docs/gitea-auth.md) +- [API Documentation](docs/api.md) +- [Configuration Guide](docs/configuration.md) + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..92895a6 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,284 @@ +# Configuration Guide + +This guide provides detailed information on how to configure the Hostbasket application. + +## Table of Contents + +1. [Configuration Methods](#configuration-methods) +2. [Server Configuration](#server-configuration) +3. [Templates Configuration](#templates-configuration) +4. [Authentication Configuration](#authentication-configuration) +5. [OAuth Configuration](#oauth-configuration) +6. [Database Configuration](#database-configuration) +7. [Logging Configuration](#logging-configuration) +8. [Environment-Specific Configuration](#environment-specific-configuration) +9. [Advanced Configuration](#advanced-configuration) + +## Configuration Methods + +Hostbasket supports multiple configuration methods: + +1. **Environment Variables**: Set configuration values using environment variables. +2. **Configuration Files**: Use TOML configuration files in the `config` directory. +3. **Command-Line Arguments**: Override specific settings using command-line arguments. + +The configuration is loaded in the following order, with later methods overriding earlier ones: + +1. Default values +2. Configuration files +3. Environment variables +4. Command-line arguments + +## Server Configuration + +### Environment Variables + +```bash +export APP__SERVER__HOST=127.0.0.1 +export APP__SERVER__PORT=9999 +export APP__SERVER__WORKERS=4 +``` + +### Configuration File + +```toml +[server] +host = "127.0.0.1" +port = 9999 +workers = 4 +``` + +### Available Settings + +| Setting | Description | Default Value | +|---------|-------------|---------------| +| `host` | The host address to bind to | `127.0.0.1` | +| `port` | The port to listen on | `9999` | +| `workers` | The number of worker threads | Number of CPU cores | + +## Templates Configuration + +### Environment Variables + +```bash +export APP__TEMPLATES__DIR=./src/views +``` + +### Configuration File + +```toml +[templates] +dir = "./src/views" +``` + +### Available Settings + +| Setting | Description | Default Value | +|---------|-------------|---------------| +| `dir` | The directory containing templates | `./src/views` | + +## Authentication Configuration + +### Environment Variables + +```bash +export JWT_SECRET=your_jwt_secret_key +export JWT_EXPIRATION_HOURS=24 +``` + +### Configuration File + +```toml +[auth] +jwt_secret = "your_jwt_secret_key" +jwt_expiration_hours = 24 +``` + +### Available Settings + +| Setting | Description | Default Value | +|---------|-------------|---------------| +| `jwt_secret` | The secret key used to sign JWT tokens | `your_jwt_secret_key` | +| `jwt_expiration_hours` | The number of hours before a JWT token expires | `24` | + +## OAuth Configuration + +### Environment Variables + +```bash +export GITEA_CLIENT_ID=your_client_id +export GITEA_CLIENT_SECRET=your_client_secret +export GITEA_INSTANCE_URL=https://your-gitea-instance.com +``` + +### Configuration File + +```toml +[oauth.gitea] +client_id = "your_client_id" +client_secret = "your_client_secret" +instance_url = "https://your-gitea-instance.com" +``` + +### Available Settings + +| Setting | Description | Default Value | +|---------|-------------|---------------| +| `client_id` | The OAuth client ID | None | +| `client_secret` | The OAuth client secret | None | +| `instance_url` | The URL of your Gitea instance | None | + +## Database Configuration + +### Environment Variables + +```bash +export APP__DATABASE__URL=postgres://user:password@localhost/hostbasket +export APP__DATABASE__POOL_SIZE=5 +``` + +### Configuration File + +```toml +[database] +url = "postgres://user:password@localhost/hostbasket" +pool_size = 5 +``` + +### Available Settings + +| Setting | Description | Default Value | +|---------|-------------|---------------| +| `url` | The database connection URL | None | +| `pool_size` | The size of the connection pool | `5` | + +## Logging Configuration + +### Environment Variables + +```bash +export RUST_LOG=info +``` + +### Configuration File + +```toml +[logging] +level = "info" +``` + +### Available Settings + +| Setting | Description | Default Value | +|---------|-------------|---------------| +| `level` | The log level (trace, debug, info, warn, error) | `info` | + +## Environment-Specific Configuration + +You can create environment-specific configuration files: + +- `config/default.toml`: Default configuration for all environments +- `config/development.toml`: Configuration for development environment +- `config/production.toml`: Configuration for production environment +- `config/test.toml`: Configuration for test environment + +To specify the environment: + +```bash +export APP_ENV=production +``` + +## Advanced Configuration + +### Custom Configuration + +You can add custom configuration sections to the configuration file: + +```toml +[custom] +setting1 = "value1" +setting2 = "value2" +``` + +To access these settings in your code: + +```rust +let config = config::get_config(); +let custom_config = config.get_table("custom").unwrap(); +let setting1 = custom_config.get("setting1").unwrap().as_str().unwrap(); +``` + +### Configuration Validation + +You can validate the configuration when loading it: + +```rust +impl AppConfig { + pub fn validate(&self) -> Result<(), String> { + if self.server.port < 1024 && !cfg!(debug_assertions) { + return Err("Port number should be >= 1024 in production".to_string()); + } + + Ok(()) + } +} + +pub fn get_config() -> AppConfig { + let config = AppConfig::new().expect("Failed to load configuration"); + + if let Err(e) = config.validate() { + panic!("Invalid configuration: {}", e); + } + + config +} +``` + +### Reloading Configuration + +You can implement configuration reloading to update the configuration without restarting the application: + +```rust +pub async fn reload_config() -> Result { + let config = AppConfig::new()?; + + // Update the global configuration + let mut global_config = GLOBAL_CONFIG.write().await; + *global_config = config.clone(); + + Ok(config) +} +``` + +### Secrets Management + +For production environments, consider using a secrets management solution like HashiCorp Vault or AWS Secrets Manager instead of storing secrets in configuration files or environment variables. + +You can implement a custom secrets provider: + +```rust +pub async fn load_secrets() -> Result<(), Error> { + // Load secrets from your secrets manager + let jwt_secret = secrets_manager.get_secret("jwt_secret").await?; + + // Set the secrets as environment variables + std::env::set_var("JWT_SECRET", jwt_secret); + + Ok(()) +} +``` + +Call this function before loading the configuration: + +```rust +#[actix_web::main] +async fn main() -> io::Result<()> { + // Load secrets + load_secrets().await.expect("Failed to load secrets"); + + // Load configuration + let config = config::get_config(); + + // ... +} +``` diff --git a/docs/gitea-auth.md b/docs/gitea-auth.md new file mode 100644 index 0000000..3ac5741 --- /dev/null +++ b/docs/gitea-auth.md @@ -0,0 +1,362 @@ +# Gitea Authentication Guide + +This guide provides detailed instructions on how to integrate Gitea authentication into your Hostbasket application. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Prerequisites](#prerequisites) +3. [Setting Up Gitea OAuth Application](#setting-up-gitea-oauth-application) +4. [Configuring Hostbasket for Gitea Authentication](#configuring-hostbasket-for-gitea-authentication) +5. [Implementing the OAuth Flow](#implementing-the-oauth-flow) +6. [Testing the Integration](#testing-the-integration) +7. [Troubleshooting](#troubleshooting) + +## Introduction + +Gitea is a self-hosted Git service that provides a GitHub-like interface. By integrating Gitea authentication into your Hostbasket application, you can allow users to log in using their Gitea accounts, simplifying the authentication process and providing a seamless experience. + +## Prerequisites + +Before you begin, ensure you have: + +- A running Gitea instance +- Administrative access to your Gitea instance +- A running Hostbasket application + +## Setting Up Gitea OAuth Application + +1. Log in to your Gitea instance as an administrator +2. Navigate to **Site Administration** > **Applications** +3. Click **Create a New OAuth2 Application** +4. Fill in the application details: + - **Application Name**: Hostbasket + - **Redirect URI**: `http://localhost:9999/auth/gitea/callback` (adjust the URL to match your Hostbasket instance) + - **Confidential Client**: Check this box +5. Click **Create Application** +6. Note the **Client ID** and **Client Secret** that are generated + +## Configuring Hostbasket for Gitea Authentication + +1. Add the following environment variables to your Hostbasket configuration: + +```bash +export GITEA_CLIENT_ID=your_client_id +export GITEA_CLIENT_SECRET=your_client_secret +export GITEA_INSTANCE_URL=https://your-gitea-instance.com +``` + +2. Alternatively, add these settings to your configuration file (`config/default.toml`): + +```toml +[oauth.gitea] +client_id = "your_client_id" +client_secret = "your_client_secret" +instance_url = "https://your-gitea-instance.com" +``` + +## Implementing the OAuth Flow + +To implement the OAuth flow, you need to add new routes and controllers to your Hostbasket application. + +### 1. Add Required Dependencies + +Add the following dependencies to your `Cargo.toml`: + +```toml +[dependencies] +oauth2 = "4.3" +reqwest = { version = "0.11", features = ["json"] } +``` + +### 2. Create a Gitea OAuth Configuration Module + +Create a new file `src/config/oauth.rs`: + +```rust +use oauth2::{ + AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl, + basic::BasicClient, AuthorizationCode, CsrfToken, Scope, TokenResponse, +}; +use serde::{Deserialize, Serialize}; +use std::env; + +#[derive(Clone, Debug)] +pub struct GiteaOAuthConfig { + pub client: BasicClient, + pub instance_url: String, +} + +impl GiteaOAuthConfig { + pub fn new() -> Self { + // Get configuration from environment variables + let client_id = env::var("GITEA_CLIENT_ID") + .expect("Missing GITEA_CLIENT_ID environment variable"); + let client_secret = env::var("GITEA_CLIENT_SECRET") + .expect("Missing GITEA_CLIENT_SECRET environment variable"); + let instance_url = env::var("GITEA_INSTANCE_URL") + .expect("Missing GITEA_INSTANCE_URL environment variable"); + + // Create OAuth client + let auth_url = format!("{}/login/oauth/authorize", instance_url); + let token_url = format!("{}/login/oauth/access_token", instance_url); + + let client = BasicClient::new( + ClientId::new(client_id), + Some(ClientSecret::new(client_secret)), + AuthUrl::new(auth_url).unwrap(), + Some(TokenUrl::new(token_url).unwrap()), + ) + .set_redirect_uri( + RedirectUrl::new("http://localhost:9999/auth/gitea/callback".to_string()).unwrap(), + ); + + Self { + client, + instance_url, + } + } +} + +// Gitea user information structure +#[derive(Debug, Deserialize, Serialize)] +pub struct GiteaUser { + pub id: i64, + pub login: String, + pub full_name: String, + pub email: String, + pub avatar_url: String, +} +``` + +### 3. Create a Gitea Authentication Controller + +Create a new file `src/controllers/gitea_auth.rs`: + +```rust +use actix_web::{web, HttpResponse, Responder, Result, http::header, cookie::Cookie}; +use actix_session::Session; +use oauth2::{AuthorizationCode, CsrfToken, Scope, TokenResponse}; +use reqwest::Client; +use crate::config::oauth::GiteaOAuthConfig; +use crate::models::user::{User, UserRole}; +use crate::controllers::auth::AuthController; +use serde_json::json; + +pub struct GiteaAuthController; + +impl GiteaAuthController { + // Initiate the OAuth flow + pub async fn login( + oauth_config: web::Data, + session: Session, + ) -> Result { + // Generate the authorization URL + let (auth_url, csrf_token) = oauth_config + .client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("read:user".to_string())) + .add_scope(Scope::new("user:email".to_string())) + .url(); + + // Store the CSRF token in the session + session.insert("oauth_csrf_token", csrf_token.secret())?; + + // Redirect to the authorization URL + Ok(HttpResponse::Found() + .append_header((header::LOCATION, auth_url.to_string())) + .finish()) + } + + // Handle the OAuth callback + pub async fn callback( + oauth_config: web::Data, + session: Session, + query: web::Query, + ) -> Result { + // Verify the CSRF token + let csrf_token = session.get::("oauth_csrf_token")? + .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing CSRF token"))?; + + if csrf_token != query.state { + return Err(actix_web::error::ErrorBadRequest("Invalid CSRF token")); + } + + // Exchange the authorization code for an access token + let token = oauth_config + .client + .exchange_code(AuthorizationCode::new(query.code.clone())) + .request_async(oauth2::reqwest::async_http_client) + .await + .map_err(|e| actix_web::error::ErrorInternalServerError(format!("Token exchange error: {}", e)))?; + + // Get the user information from Gitea + let client = Client::new(); + let user_info_url = format!("{}/api/v1/user", oauth_config.instance_url); + + let gitea_user = client + .get(&user_info_url) + .bearer_auth(token.access_token().secret()) + .send() + .await + .map_err(|e| actix_web::error::ErrorInternalServerError(format!("API request error: {}", e)))? + .json::() + .await + .map_err(|e| actix_web::error::ErrorInternalServerError(format!("JSON parsing error: {}", e)))?; + + // Create or update the user in your system + let mut user = User::new( + gitea_user.full_name.clone(), + gitea_user.email.clone(), + ); + + // Set the user ID and role + user.id = Some(gitea_user.id as i32); + user.role = UserRole::User; + + // Generate JWT token + let token = AuthController::generate_token(&user.email, &user.role) + .map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?; + + // Store user data in session + let user_json = serde_json::to_string(&user).unwrap(); + session.insert("user", &user_json)?; + session.insert("auth_token", &token)?; + + // Create a cookie with the JWT token + let cookie = Cookie::build("auth_token", token) + .path("/") + .http_only(true) + .secure(false) // Set to true in production with HTTPS + .max_age(actix_web::cookie::time::Duration::hours(24)) + .finish(); + + // Redirect to the home page with JWT token in cookie + Ok(HttpResponse::Found() + .cookie(cookie) + .append_header((header::LOCATION, "/")) + .finish()) + } +} + +// Query parameters for the OAuth callback +#[derive(serde::Deserialize)] +pub struct CallbackQuery { + pub code: String, + pub state: String, +} +``` + +### 4. Update the Routes Configuration + +Update your `src/routes/mod.rs` file to include the new Gitea authentication routes: + +```rust +use crate::controllers::gitea_auth::GiteaAuthController; +use crate::config::oauth::GiteaOAuthConfig; + +pub fn configure_routes(cfg: &mut web::ServiceConfig) { + // Create the OAuth configuration + let oauth_config = web::Data::new(GiteaOAuthConfig::new()); + + // Configure session middleware with the consistent key + let session_middleware = SessionMiddleware::builder( + CookieSessionStore::default(), + SESSION_KEY.clone() + ) + .cookie_secure(false) // Set to true in production with HTTPS + .build(); + + // Public routes that don't require authentication + cfg.service( + web::scope("") + .wrap(session_middleware) + .app_data(oauth_config.clone()) + // Existing routes... + + // Gitea OAuth routes + .route("/auth/gitea", web::get().to(GiteaAuthController::login)) + .route("/auth/gitea/callback", web::get().to(GiteaAuthController::callback)) + ); +} +``` + +### 5. Update the Login Page + +Update your login page template (`src/views/auth/login.html`) to include a "Login with Gitea" button: + +```html +{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+
+
+
+
+

Login

+
+
+
+
+ + +
+
+ + +
+ +
+ +
+ +
+

Or login with:

+ + Gitea + Login with Gitea + +
+
+
+
+
+
+{% endblock %} +``` + +## Testing the Integration + +1. Start your Hostbasket application +2. Navigate to the login page +3. Click the "Login with Gitea" button +4. You should be redirected to your Gitea instance's authorization page +5. Authorize the application +6. You should be redirected back to your Hostbasket application and logged in + +## Troubleshooting + +### Common Issues + +1. **Redirect URI Mismatch**: + - Error: `The redirect URI provided is missing or does not match` + - Solution: Ensure that the redirect URI in your Gitea OAuth application settings matches the one in your code. + +2. **Invalid Client ID or Secret**: + - Error: `Invalid client_id or client_secret` + - Solution: Double-check your client ID and secret. + +3. **CSRF Token Mismatch**: + - Error: `Invalid CSRF token` + - Solution: Ensure that your session is properly configured and that the CSRF token is being stored and retrieved correctly. + +4. **API Request Errors**: + - Error: `API request error` + - Solution: Check your Gitea instance URL and ensure that the API is accessible. + +5. **JSON Parsing Errors**: + - Error: `JSON parsing error` + - Solution: Ensure that the API response matches the expected structure. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..be346b8 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,129 @@ +# Installation Guide + +This guide provides detailed instructions for installing and setting up the Hostbasket application. + +## Prerequisites + +Before you begin, ensure you have the following installed: + +- **Rust and Cargo**: The latest stable version is recommended. You can install Rust using [rustup](https://rustup.rs/). +- **Git**: For cloning the repository. +- **A text editor or IDE**: Such as Visual Studio Code, IntelliJ IDEA with Rust plugin, or any other editor of your choice. + +## Step 1: Clone the Repository + +```bash +git clone https://git.ourworld.tf/herocode/rweb_starterkit +cd rweb_starterkit +``` + +## Step 2: Configure the Application + +The application can be configured using environment variables or configuration files. + +### Using Environment Variables + +You can set the following environment variables: + +```bash +export APP__SERVER__HOST=127.0.0.1 +export APP__SERVER__PORT=9999 +export APP__SERVER__WORKERS=4 +export APP__TEMPLATES__DIR=./src/views +export JWT_SECRET=your_jwt_secret_key +``` + +### Using Configuration Files + +Alternatively, you can create a configuration file in the `config` directory: + +1. Create a file named `config/default.toml` with the following content: + +```toml +[server] +host = "127.0.0.1" +port = 9999 +workers = 4 + +[templates] +dir = "./src/views" +``` + +2. You can also create environment-specific configuration files like `config/development.toml` or `config/production.toml`. + +## Step 3: Build the Project + +```bash +cargo build +``` + +This will download and compile all dependencies and build the project. + +## Step 4: Run the Application + +### Development Mode + +```bash +cargo run +``` + +### Production Mode + +```bash +cargo build --release +./target/release/hostbasket +``` + +You can also specify a custom port: + +```bash +cargo run -- --port 8080 +``` + +## Step 5: Verify the Installation + +Open your web browser and navigate to `http://localhost:9999` (or the port you specified). You should see the Hostbasket home page. + +## Troubleshooting + +### Common Issues + +1. **Port already in use**: + ``` + Error: Address already in use (os error 98) + ``` + Solution: Change the port using the `--port` flag or the `APP__SERVER__PORT` environment variable. + +2. **Missing dependencies**: + ``` + error: failed to run custom build command for `openssl-sys v0.9.58` + ``` + Solution: Install OpenSSL development libraries: + ```bash + # Ubuntu/Debian + sudo apt-get install libssl-dev pkg-config + + # Fedora/CentOS + sudo dnf install openssl-devel pkgconfig + + # macOS + brew install openssl pkg-config + ``` + +3. **Template parsing errors**: + ``` + Parsing error(s): Failed to parse template... + ``` + Solution: Check your template files for syntax errors. + +## Next Steps + +After successfully installing the application, you can: + +1. Explore the [Usage Guide](usage.md) to learn how to use the application. +2. Check the [Views Guide](views.md) to learn how to create and customize views. +3. Set up [Gitea Authentication](gitea-auth.md) to enable login with Gitea. + +## Advanced Configuration + +For advanced configuration options, please refer to the [Configuration Guide](configuration.md). diff --git a/docs/mvc.md b/docs/mvc.md new file mode 100644 index 0000000..1c33497 --- /dev/null +++ b/docs/mvc.md @@ -0,0 +1,273 @@ +# MVC Architecture in Hostbasket + +This document explains the Model-View-Controller (MVC) architecture used in the Hostbasket application. + +## Table of Contents + +1. [Introduction to MVC](#introduction-to-mvc) +2. [MVC Components in Hostbasket](#mvc-components-in-hostbasket) +3. [Data Flow in MVC](#data-flow-in-mvc) +4. [Benefits of MVC](#benefits-of-mvc) +5. [Best Practices](#best-practices) +6. [Examples](#examples) + +## Introduction to MVC + +Model-View-Controller (MVC) is a software architectural pattern that separates an application into three main logical components: + +- **Model**: Represents the data and business logic of the application +- **View**: Represents the user interface +- **Controller**: Acts as an intermediary between Model and View + +This separation helps in organizing code, making it more maintainable, testable, and scalable. + +## MVC Components in Hostbasket + +### Model + +In Hostbasket, models are defined in the `src/models` directory. They represent the data structures and business logic of the application. + +```rust +// src/models/user.rs +pub struct User { + pub id: Option, + pub name: String, + pub email: String, + pub password_hash: Option, + pub role: UserRole, + pub created_at: Option>, + pub updated_at: Option>, +} +``` + +Models are responsible for: +- Defining data structures +- Implementing business logic +- Validating data +- Interacting with the database (in a full implementation) + +### View + +Views in Hostbasket are implemented using Tera templates, located in the `src/views` directory. They are responsible for rendering the user interface. + +```html + +{% extends "base.html" %} + +{% block title %}Home - Hostbasket{% endblock %} + +{% block content %} +
+

Welcome to Hostbasket!

+

A web application framework built with Actix Web and Rust.

+
+{% endblock %} +``` + +Views are responsible for: +- Presenting data to the user +- Capturing user input +- Providing a user interface +- Implementing client-side logic (with JavaScript) + +### Controller + +Controllers in Hostbasket are defined in the `src/controllers` directory. They handle HTTP requests, process user input, interact with models, and render views. + +```rust +// src/controllers/home.rs +pub struct HomeController; + +impl HomeController { + pub async fn index(tmpl: web::Data, session: Session) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("active_page", "home"); + + // Add user to context if available + if let Ok(Some(user)) = session.get::("user") { + ctx.insert("user_json", &user); + } + + render_template(&tmpl, "home/index.html", &ctx) + } +} +``` + +Controllers are responsible for: +- Handling HTTP requests +- Processing user input +- Interacting with models +- Preparing data for views +- Rendering views + +## Data Flow in MVC + +The typical data flow in an MVC application like Hostbasket is as follows: + +1. The user interacts with the view (e.g., submits a form) +2. The controller receives the request +3. The controller processes the request, often interacting with models +4. The controller prepares data for the view +5. The controller renders the view with the prepared data +6. The view is displayed to the user + +This flow ensures a clear separation of concerns and makes the application easier to maintain and extend. + +## Benefits of MVC + +Using the MVC architecture in Hostbasket provides several benefits: + +1. **Separation of Concerns**: Each component has a specific responsibility, making the code more organized and maintainable. +2. **Code Reusability**: Models and controllers can be reused across different views. +3. **Parallel Development**: Different team members can work on models, views, and controllers simultaneously. +4. **Testability**: Components can be tested in isolation, making unit testing easier. +5. **Flexibility**: Changes to one component have minimal impact on others. +6. **Scalability**: The application can grow without becoming unwieldy. + +## Best Practices + +When working with the MVC architecture in Hostbasket, follow these best practices: + +### Models + +- Keep models focused on data and business logic +- Implement validation in models +- Use traits to define common behavior +- Keep database interactions separate from business logic + +### Views + +- Keep views simple and focused on presentation +- Use template inheritance to avoid duplication +- Minimize logic in templates +- Use partials for reusable components + +### Controllers + +- Keep controllers thin +- Focus on request handling and coordination +- Delegate business logic to models +- Use dependency injection for services + +## Examples + +### Complete MVC Example + +Here's a complete example of how the MVC pattern works in Hostbasket for a user registration flow: + +#### Model (src/models/user.rs) + +```rust +impl User { + pub fn new_with_password(name: String, email: String, password: &str) -> Result { + let password_hash = hash(password, DEFAULT_COST)?; + + Ok(Self { + id: None, + name, + email, + password_hash: Some(password_hash), + role: UserRole::User, + created_at: Some(Utc::now()), + updated_at: Some(Utc::now()), + }) + } + + pub fn verify_password(&self, password: &str) -> Result { + match &self.password_hash { + Some(hash) => verify(password, hash), + None => Ok(false), + } + } +} +``` + +#### View (src/views/auth/register.html) + +```html +{% extends "base.html" %} + +{% block title %}Register - Hostbasket{% endblock %} + +{% block content %} +
+
+
+
+
+

Register

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+{% endblock %} +``` + +#### Controller (src/controllers/auth.rs) + +```rust +pub async fn register( + form: web::Form, + session: Session, + _tmpl: web::Data +) -> Result { + // Create a new user with the form data + let user = match User::new_with_password( + form.name.clone(), + form.email.clone(), + &form.password + ) { + Ok(user) => user, + Err(_) => return Err(actix_web::error::ErrorInternalServerError("Failed to create user")), + }; + + // In a real application, you would save the user to a database here + + // Generate JWT token + let token = Self::generate_token(&user.email, &user.role) + .map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?; + + // Store user data in session + let user_json = serde_json::to_string(&user).unwrap(); + session.insert("user", &user_json)?; + session.insert("auth_token", &token)?; + + // Create a cookie with the JWT token + let cookie = Cookie::build("auth_token", token) + .path("/") + .http_only(true) + .secure(false) // Set to true in production with HTTPS + .max_age(actix_web::cookie::time::Duration::hours(24)) + .finish(); + + // Redirect to the home page with JWT token in cookie + Ok(HttpResponse::Found() + .cookie(cookie) + .append_header((header::LOCATION, "/")) + .finish()) +} +``` + +This example demonstrates how the MVC pattern separates concerns while allowing the components to work together to handle user registration. diff --git a/docs/views.md b/docs/views.md new file mode 100644 index 0000000..49a4d5e --- /dev/null +++ b/docs/views.md @@ -0,0 +1,240 @@ +# Views Guide + +This guide provides detailed instructions on how to create and customize views in the Hostbasket application. + +## Table of Contents + +1. [Introduction to Tera Templates](#introduction-to-tera-templates) +2. [Template Structure](#template-structure) +3. [Creating a New View](#creating-a-new-view) +4. [Template Inheritance](#template-inheritance) +5. [Using Variables](#using-variables) +6. [Control Structures](#control-structures) +7. [Filters](#filters) +8. [Macros](#macros) +9. [Custom Functions](#custom-functions) +10. [Best Practices](#best-practices) + +## Introduction to Tera Templates + +Hostbasket uses [Tera](https://tera.netlify.app/) as its template engine. Tera is inspired by Jinja2 and Django templates and provides a powerful way to create dynamic HTML pages. + +## Template Structure + +Templates are stored in the `src/views` directory. The directory structure typically follows the application's features: + +``` +src/views/ +├── auth/ +│ ├── login.html +│ └── register.html +├── home/ +│ ├── index.html +│ ├── about.html +│ └── contact.html +├── tickets/ +│ ├── index.html +│ ├── new.html +│ └── show.html +├── assets/ +│ ├── index.html +│ ├── create.html +│ └── detail.html +└── base.html +``` + +## Creating a New View + +To create a new view: + +1. Create a new HTML file in the appropriate directory under `src/views` +2. Use template inheritance to extend the base template +3. Add your content within the appropriate blocks + +Example: + +```html +{% extends "base.html" %} + +{% block title %}My New Page{% endblock %} + +{% block content %} +
+

Welcome to My New Page

+

This is a custom page.

+
+{% endblock %} +``` + +## Template Inheritance + +Tera supports template inheritance, which allows you to define a base template with common elements and extend it in child templates. + +### Base Template + +The base template (`base.html`) typically contains the HTML structure, header, footer, and navigation menu: + +```html + + + + + + {% block title %}Hostbasket{% endblock %} + + + {% block head %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ +
+ +
+ + + {% block scripts %}{% endblock %} + + +``` + +### Child Template + +Child templates extend the base template and override specific blocks: + +```html +{% extends "base.html" %} + +{% block title %}Home{% endblock %} + +{% block content %} +
+

Welcome to Hostbasket

+

This is the home page.

+
+{% endblock %} + +{% block scripts %} + +{% endblock %} +``` + +## Using Variables + +You can pass variables from your controller to the template and use them in your HTML: + +### In the Controller + +```rust +pub async fn index(tmpl: web::Data) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("title", "Welcome to Hostbasket"); + ctx.insert("user_name", "John Doe"); + + render_template(&tmpl, "home/index.html", &ctx) +} +``` + +### In the Template + +```html +

{{ title }}

+

Hello, {{ user_name }}!

+``` + +## Control Structures + +Tera provides various control structures for conditional rendering and iteration. + +### Conditionals + +```html +{% if user %} +

Welcome, {{ user.name }}!

+{% else %} +

Please log in.

+{% endif %} +``` + +### Loops + +```html +
    + {% for item in items %} +
  • {{ item.name }}
  • + {% endfor %} +
+ +{% if items is empty %} +

No items found.

+{% endif %} +``` + +## Filters + +Filters transform the values of variables: + +```html +

{{ user.name | upper }}

+

{{ user.bio | truncate(length=100) }}

+

{{ user.created_at | date(format="%Y-%m-%d") }}

+``` + +## Macros + +Macros are reusable template fragments: + +```html +{% macro input(name, value='', type='text', label='') %} +
+ {% if label %} + + {% endif %} + +
+{% endmacro %} + + +{{ input(name="email", type="email", label="Email Address") }} +``` + +## Custom Functions + +You can register custom functions in Rust and use them in your templates: + +### In Rust + +```rust +fn register_tera_functions(tera: &mut Tera) { + tera.register_function("format_date", format_date); +} + +fn format_date(args: &HashMap) -> Result { + // Implementation +} +``` + +### In the Template + +```html +

{{ format_date(date=user.created_at, format="%B %d, %Y") }}

+``` + +## Best Practices + +1. **Use Template Inheritance**: Extend the base template to maintain consistency across pages. +2. **Organize Templates by Feature**: Group related templates in subdirectories. +3. **Keep Templates Simple**: Move complex logic to the controller or custom functions. +4. **Use Meaningful Variable Names**: Choose descriptive names for variables and blocks. +5. **Comment Your Templates**: Add comments to explain complex sections. +6. **Validate User Input**: Always validate and sanitize user input in the controller before passing it to the template. +7. **Use Partials for Reusable Components**: Extract common components into separate files and include them where needed. +8. **Optimize for Performance**: Minimize the use of expensive operations in templates. +9. **Test Your Templates**: Ensure that your templates render correctly with different data. +10. **Follow Accessibility Guidelines**: Make your templates accessible to all users. diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..56204e3 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,68 @@ +use config::{Config, ConfigError, File}; +use serde::Deserialize; +use std::env; + +// Export OAuth module +pub mod oauth; + +/// Application configuration +#[derive(Debug, Deserialize, Clone)] +pub struct AppConfig { + /// Server configuration + pub server: ServerConfig, + /// Template configuration + pub templates: TemplateConfig, +} + +/// Server configuration +#[derive(Debug, Deserialize, Clone)] +pub struct ServerConfig { + /// Host address to bind to + pub host: String, + /// Port to listen on + pub port: u16, + /// Workers count + pub workers: Option, +} + +/// Template configuration +#[derive(Debug, Deserialize, Clone)] +pub struct TemplateConfig { + /// Directory containing templates + pub dir: String, +} + +impl AppConfig { + /// Loads configuration from files and environment variables + pub fn new() -> Result { + // Set default values + let mut config_builder = Config::builder() + .set_default("server.host", "127.0.0.1")? + .set_default("server.port", 9999)? + .set_default("server.workers", None::)? + .set_default("templates.dir", "./src/views")?; + + // Load from config file if it exists + if let Ok(config_path) = env::var("APP_CONFIG") { + config_builder = config_builder.add_source(File::with_name(&config_path)); + } else { + // Try to load from default locations + config_builder = config_builder + .add_source(File::with_name("config/default").required(false)) + .add_source(File::with_name("config/local").required(false)); + } + + // Override with environment variables (e.g., SERVER__HOST, SERVER__PORT) + config_builder = + config_builder.add_source(config::Environment::with_prefix("APP").separator("__")); + + // Build and deserialize the config + let config = config_builder.build()?; + config.try_deserialize() + } +} + +/// Returns the application configuration +pub fn get_config() -> AppConfig { + AppConfig::new().expect("Failed to load configuration") +} diff --git a/src/config/oauth.rs b/src/config/oauth.rs new file mode 100644 index 0000000..30f9c85 --- /dev/null +++ b/src/config/oauth.rs @@ -0,0 +1,62 @@ +use oauth2::{ + AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl, + basic::BasicClient, AuthorizationCode, CsrfToken, Scope, TokenResponse, +}; +use serde::{Deserialize, Serialize}; +use std::env; + +/// Gitea OAuth configuration +#[derive(Clone, Debug)] +pub struct GiteaOAuthConfig { + /// OAuth client + pub client: BasicClient, + /// Gitea instance URL + pub instance_url: String, +} + +impl GiteaOAuthConfig { + /// Creates a new Gitea OAuth configuration + pub fn new() -> Self { + // Get configuration from environment variables + let client_id = env::var("GITEA_CLIENT_ID") + .expect("Missing GITEA_CLIENT_ID environment variable"); + let client_secret = env::var("GITEA_CLIENT_SECRET") + .expect("Missing GITEA_CLIENT_SECRET environment variable"); + let instance_url = env::var("GITEA_INSTANCE_URL") + .expect("Missing GITEA_INSTANCE_URL environment variable"); + + // Create OAuth client + let auth_url = format!("{}/login/oauth/authorize", instance_url); + let token_url = format!("{}/login/oauth/access_token", instance_url); + + let client = BasicClient::new( + ClientId::new(client_id), + Some(ClientSecret::new(client_secret)), + AuthUrl::new(auth_url).unwrap(), + Some(TokenUrl::new(token_url).unwrap()), + ) + .set_redirect_uri( + RedirectUrl::new(format!("{}/auth/gitea/callback", env::var("APP_URL").unwrap_or_else(|_| "http://localhost:9999".to_string()))).unwrap(), + ); + + Self { + client, + instance_url, + } + } +} + +/// Gitea user information structure +#[derive(Debug, Deserialize, Serialize)] +pub struct GiteaUser { + /// User ID + pub id: i64, + /// Username + pub login: String, + /// Full name + pub full_name: String, + /// Email address + pub email: String, + /// Avatar URL + pub avatar_url: String, +} diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs new file mode 100644 index 0000000..fea26ca --- /dev/null +++ b/src/controllers/auth.rs @@ -0,0 +1,193 @@ +use actix_web::{web, HttpResponse, Responder, Result, http::header, cookie::Cookie}; +use actix_session::Session; +use tera::Tera; +use crate::models::user::{User, LoginCredentials, RegistrationData, UserRole}; +use crate::utils::render_template; +use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey}; +use serde::{Deserialize, Serialize}; +use chrono::{Utc, Duration}; +use lazy_static::lazy_static; + +// JWT Claims structure +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: String, // Subject (email) + pub exp: usize, // Expiration time + pub iat: usize, // Issued at + pub role: String, // User role +} + +// JWT Secret key +lazy_static! { + static ref JWT_SECRET: String = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your_jwt_secret_key".to_string()); +} + +/// Controller for handling authentication-related routes +pub struct AuthController; + +impl AuthController { + /// Generate a JWT token for a user + pub fn generate_token(email: &str, role: &UserRole) -> Result { + let role_str = match role { + UserRole::Admin => "admin", + UserRole::User => "user", + }; + + let expiration = Utc::now() + .checked_add_signed(Duration::hours(24)) + .expect("valid timestamp") + .timestamp() as usize; + + let claims = Claims { + sub: email.to_owned(), + exp: expiration, + iat: Utc::now().timestamp() as usize, + role: role_str.to_string(), + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(JWT_SECRET.as_bytes()) + ) + } + + /// Validate a JWT token + pub fn validate_token(token: &str) -> Result { + let validation = Validation::new(Algorithm::HS256); + + let token_data = decode::( + token, + &DecodingKey::from_secret(JWT_SECRET.as_bytes()), + &validation + )?; + + Ok(token_data.claims) + } + + /// Extract token from session + pub fn extract_token_from_session(session: &Session) -> Option { + session.get::("auth_token").ok().flatten() + } + + /// Extract token from cookie + pub fn extract_token_from_cookie(req: &actix_web::HttpRequest) -> Option { + req.cookie("auth_token").map(|c| c.value().to_string()) + } + + /// Renders the login page + pub async fn login_page(tmpl: web::Data) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("active_page", "login"); + + render_template(&tmpl, "auth/login.html", &ctx) + } + + /// Handles user login + pub async fn login( + form: web::Form, + session: Session, + _tmpl: web::Data + ) -> Result { + // For simplicity, always log in the user without checking credentials + // Create a user object with admin role + let mut test_user = User::new( + "Admin User".to_string(), + form.email.clone() + ); + + // Set the ID and admin role + test_user.id = Some(1); + test_user.role = UserRole::Admin; + + // Generate JWT token + let token = Self::generate_token(&test_user.email, &test_user.role) + .map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?; + + // Store user data in session + let user_json = serde_json::to_string(&test_user).unwrap(); + session.insert("user", &user_json)?; + session.insert("auth_token", &token)?; + + // Create a cookie with the JWT token + let cookie = Cookie::build("auth_token", token) + .path("/") + .http_only(true) + .secure(false) // Set to true in production with HTTPS + .max_age(actix_web::cookie::time::Duration::hours(24)) + .finish(); + + // Redirect to the home page with JWT token in cookie + Ok(HttpResponse::Found() + .cookie(cookie) + .append_header((header::LOCATION, "/")) + .finish()) + } + + /// Renders the registration page + pub async fn register_page(tmpl: web::Data) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("active_page", "register"); + + render_template(&tmpl, "auth/register.html", &ctx) + } + + /// Handles user registration + pub async fn register( + form: web::Form, + session: Session, + _tmpl: web::Data + ) -> Result { + // Skip validation and always create an admin user + let mut user = User::new( + form.name.clone(), + form.email.clone() + ); + + // Set the ID and admin role + user.id = Some(1); + user.role = UserRole::Admin; + + // Generate JWT token + let token = Self::generate_token(&user.email, &user.role) + .map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?; + + // Store user data in session + let user_json = serde_json::to_string(&user).unwrap(); + session.insert("user", &user_json)?; + session.insert("auth_token", &token)?; + + // Create a cookie with the JWT token + let cookie = Cookie::build("auth_token", token) + .path("/") + .http_only(true) + .secure(false) // Set to true in production with HTTPS + .max_age(actix_web::cookie::time::Duration::hours(24)) + .finish(); + + // Redirect to the home page with JWT token in cookie + Ok(HttpResponse::Found() + .cookie(cookie) + .append_header((header::LOCATION, "/")) + .finish()) + } + + /// Handles user logout + pub async fn logout(session: Session) -> Result { + // Clear the session + session.purge(); + + // Create an expired cookie to remove the JWT token + let cookie = Cookie::build("auth_token", "") + .path("/") + .http_only(true) + .max_age(actix_web::cookie::time::Duration::seconds(0)) + .finish(); + + // Redirect to the home page and clear the auth token cookie + Ok(HttpResponse::Found() + .cookie(cookie) + .append_header((header::LOCATION, "/")) + .finish()) + } +} diff --git a/src/controllers/home.rs b/src/controllers/home.rs new file mode 100644 index 0000000..7e923ae --- /dev/null +++ b/src/controllers/home.rs @@ -0,0 +1,35 @@ +use actix_web::{web, Result, Responder}; +use tera::Tera; +use crate::utils::render_template; +use actix_session::Session; + +/// Controller for handling home-related routes +pub struct HomeController; + +impl HomeController { + /// Renders the home page + pub async fn index(tmpl: web::Data, session: Session) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("active_page", "home"); + + // Add user to context if available + if let Ok(Some(user)) = session.get::("user") { + ctx.insert("user_json", &user); + } + + render_template(&tmpl, "home/index.html", &ctx) + } + + /// Renders the about page + pub async fn about(tmpl: web::Data, session: Session) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("active_page", "about"); + + // Add user to context if available + if let Ok(Some(user)) = session.get::("user") { + ctx.insert("user_json", &user); + } + + render_template(&tmpl, "home/about.html", &ctx) + } +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs new file mode 100644 index 0000000..399c721 --- /dev/null +++ b/src/controllers/mod.rs @@ -0,0 +1,3 @@ +// Export controllers +pub mod home; +pub mod auth; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b5f85e5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,92 @@ +use actix_web::{web, App, HttpServer, middleware::Logger}; +use actix_files as fs; +use tera::Tera; +use std::{io, env}; +use dotenv::dotenv; + +mod config; +mod controllers; +mod middleware; +mod models; +mod routes; +mod utils; + +// Session key for cookie store +use actix_web::cookie::Key; +use lazy_static::lazy_static; + +lazy_static! { + static ref SESSION_KEY: Key = { + // Load key from environment variable or generate a random one + match env::var("SECRET_KEY") { + Ok(key) if key.as_bytes().len() >= 32 => { + log::info!("Using SECRET_KEY from environment"); + Key::from(key.as_bytes()) + } + _ => { + log::warn!("No valid SECRET_KEY provided; generating random key (sessions will be invalidated on restart)"); + Key::generate() // Generates a secure 32-byte key + } + } + }; +} + +#[actix_web::main] +async fn main() -> io::Result<()> { + // Initialize environment + dotenv().ok(); + env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); + + // Load configuration + let config = config::get_config(); + + // Check for port override from command line arguments + let args: Vec = env::args().collect(); + let mut port = config.server.port; + + for i in 1..args.len() { + if args[i] == "--port" && i + 1 < args.len() { + if let Ok(p) = args[i + 1].parse::() { + port = p; + break; + } + } + } + + let bind_address = format!("{}:{}", config.server.host, port); + + log::info!("Starting server at http://{}", bind_address); + + // Create and configure the HTTP server + HttpServer::new(move || { + // Initialize Tera templates + let mut tera = match Tera::new(&format!("{}/**/*.html", config.templates.dir)) { + Ok(t) => t, + Err(e) => { + log::error!("Parsing error(s): {}", e); + ::std::process::exit(1); + } + }; + + // Register custom Tera functions + utils::register_tera_functions(&mut tera); + + App::new() + // Enable logger middleware + .wrap(Logger::default()) + // Add custom middleware + .wrap(middleware::RequestTimer) + .wrap(middleware::SecurityHeaders) + .wrap(middleware::JwtAuth) + // Configure static files + .service(fs::Files::new("/static", "./src/static")) + // Add Tera template engine + .app_data(web::Data::new(tera)) + // Configure routes + .configure(routes::configure_routes) + }) + .workers(config.server.workers.unwrap_or_else(|| num_cpus::get() as u32) as usize) + .bind(bind_address)? + .run() + .await +} \ No newline at end of file diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..066b8e3 --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1,197 @@ +use std::{ + future::{ready, Ready}, + time::Instant, +}; +use actix_web::{ + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + Error, +}; +use futures_util::future::LocalBoxFuture; + +// Request Timer Middleware +pub struct RequestTimer; + +impl Transform for RequestTimer +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = RequestTimerMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(RequestTimerMiddleware { service })) + } +} + +pub struct RequestTimerMiddleware { + service: S, +} + +impl Service for RequestTimerMiddleware +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let start = Instant::now(); + let path = req.path().to_string(); + + let fut = self.service.call(req); + + Box::pin(async move { + let res = fut.await?; + let elapsed = start.elapsed(); + + log::info!("Request to {} took {:?}", path, elapsed); + + Ok(res) + }) + } +} + +// Security Headers Middleware +pub struct SecurityHeaders; + +impl Transform for SecurityHeaders +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = SecurityHeadersMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(SecurityHeadersMiddleware { service })) + } +} + +pub struct SecurityHeadersMiddleware { + service: S, +} + +impl Service for SecurityHeadersMiddleware +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let fut = self.service.call(req); + + Box::pin(async move { + let mut res = fut.await?; + + // Add security headers + res.headers_mut().insert( + actix_web::http::header::X_CONTENT_TYPE_OPTIONS, + actix_web::http::header::HeaderValue::from_static("nosniff"), + ); + + res.headers_mut().insert( + actix_web::http::header::X_FRAME_OPTIONS, + actix_web::http::header::HeaderValue::from_static("DENY"), + ); + + res.headers_mut().insert( + actix_web::http::header::X_XSS_PROTECTION, + actix_web::http::header::HeaderValue::from_static("1; mode=block"), + ); + + Ok(res) + }) + } +} + +// JWT Authentication Middleware +pub struct JwtAuth; + +impl Transform for JwtAuth +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = JwtAuthMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(JwtAuthMiddleware { service })) + } +} + +pub struct JwtAuthMiddleware { + service: S, +} + +impl Service for JwtAuthMiddleware +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + // Define public routes that don't require authentication + let path = req.path().to_string(); + let public_routes = vec![ + "/login", + "/register", + "/static", + "/favicon.ico", + "/", + "/about", + "/auth/gitea", + "/auth/gitea/callback" + ]; + + // Check if the current path is a public route + let is_public_route = public_routes.iter().any(|route| path.starts_with(route)); + + if is_public_route { + // For public routes, just pass through without authentication check + let fut = self.service.call(req); + return Box::pin(async move { + fut.await + }); + } + + // For protected routes, check for authentication + // This is a simplified version - in a real application, you would check for a valid JWT token + + // For now, just pass through all requests + let fut = self.service.call(req); + Box::pin(async move { + fut.await + }) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..ce5b214 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,2 @@ +// Export models +pub mod user; diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..09a7e12 --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// Represents a user in the system +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + /// Unique identifier for the user + pub id: Option, + /// User's full name + pub name: String, + /// User's email address + pub email: String, + /// User's hashed password + #[serde(skip_serializing)] + pub password_hash: Option, + /// User's role in the system + pub role: UserRole, + /// When the user was created + pub created_at: Option>, + /// When the user was last updated + pub updated_at: Option>, +} + +/// Represents the possible roles a user can have +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum UserRole { + /// Regular user with limited permissions + User, + /// Administrator with full permissions + Admin, +} + +impl User { + /// Creates a new user with default values + pub fn new(name: String, email: String) -> Self { + Self { + id: None, + name, + email, + password_hash: None, + role: UserRole::User, + created_at: Some(Utc::now()), + updated_at: Some(Utc::now()), + } + } +} + +/// Represents user login credentials +#[derive(Debug, Deserialize)] +pub struct LoginCredentials { + pub email: String, + pub password: String, +} + +/// Represents user registration data +#[derive(Debug, Deserialize)] +pub struct RegistrationData { + pub name: String, + pub email: String, + pub password: String, + pub password_confirmation: String, +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..0080260 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,35 @@ +use crate::controllers::auth::AuthController; +use crate::controllers::home::HomeController; +use crate::middleware::JwtAuth; +use crate::SESSION_KEY; +use actix_session::{storage::CookieSessionStore, SessionMiddleware}; +use actix_web::web; + +/// Configures all application routes +pub fn configure_routes(cfg: &mut web::ServiceConfig) { + // Configure session middleware with the consistent key + let session_middleware = + SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone()) + .cookie_secure(false) // Set to true in production with HTTPS + .build(); + + // Public routes that don't require authentication + cfg.service( + web::scope("") + .wrap(session_middleware) + // Home routes + .route("/", web::get().to(HomeController::index)) + .route("/about", web::get().to(HomeController::about)) + // Auth routes + .route("/login", web::get().to(AuthController::login_page)) + .route("/login", web::post().to(AuthController::login)) + .route("/register", web::get().to(AuthController::register_page)) + .route("/register", web::post().to(AuthController::register)) + .route("/logout", web::get().to(AuthController::logout)), + ); + + // Protected routes that require authentication + cfg.service( + web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware + ); +} diff --git a/src/static/css/styles.css b/src/static/css/styles.css new file mode 100644 index 0000000..7639c9b --- /dev/null +++ b/src/static/css/styles.css @@ -0,0 +1,49 @@ +/* Custom styles for Hostbasket */ + +/* Global styles */ +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +/* Navigation */ +.navbar-brand { + font-weight: bold; +} + +/* Cards */ +.card { + margin-bottom: 1rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + transition: all 0.3s ease; +} + +.card-main { + height: 120px; +} + +.card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + transform: translateY(-0.25rem); +} + +/* Buttons */ +.btn { + border-radius: 0.25rem; +} + +/* Forms */ +.form-control:focus { + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +/* Footer */ +.footer { + margin-top: auto; +} \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..cd10322 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,86 @@ +use actix_web::{web, HttpResponse, Result}; +use chrono::{DateTime, Utc}; +use serde_json::Value; +use std::collections::HashMap; +use tera::{Context, Tera}; + +/// Renders a template with the given context +pub fn render_template( + tmpl: &web::Data, + template_name: &str, + context: &Context, +) -> Result { + let rendered = tmpl.render(template_name, context).map_err(|e| { + log::error!("Template rendering error: {}", e); + actix_web::error::ErrorInternalServerError("Template rendering error") + })?; + + Ok(HttpResponse::Ok().content_type("text/html").body(rendered)) +} + +/// Registers custom functions with Tera +pub fn register_tera_functions(tera: &mut Tera) { + tera.register_function("format_date", format_date); + tera.register_function("active_class", active_class); +} + +/// Custom Tera function to format dates +fn format_date(args: &HashMap) -> tera::Result { + let date = match args.get("date") { + Some(val) => val + .as_str() + .ok_or_else(|| tera::Error::msg("Date must be a string"))?, + None => return Err(tera::Error::msg("Date is required")), + }; + + let format = match args.get("format") { + Some(val) => val.as_str().unwrap_or("%Y-%m-%d %H:%M:%S"), + None => "%Y-%m-%d %H:%M:%S", + }; + + // Parse the date string + let datetime = match DateTime::parse_from_rfc3339(date) { + Ok(dt) => dt.with_timezone(&Utc), + Err(_) => { + // Try parsing as a timestamp + match date.parse::() { + Ok(ts) => DateTime::from_timestamp(ts, 0) + .ok_or_else(|| tera::Error::msg("Invalid timestamp"))?, + Err(_) => return Err(tera::Error::msg("Invalid date format")), + } + } + }; + + // Format the date + let formatted = datetime.format(format).to_string(); + + Ok(Value::String(formatted)) +} + +/// Custom Tera function to set active class for navigation +fn active_class(args: &HashMap) -> tera::Result { + let current = match args.get("current") { + Some(val) => val + .as_str() + .ok_or_else(|| tera::Error::msg("Current must be a string"))?, + None => return Err(tera::Error::msg("Current is required")), + }; + + let page = match args.get("page") { + Some(val) => val + .as_str() + .ok_or_else(|| tera::Error::msg("Page must be a string"))?, + None => return Err(tera::Error::msg("Page is required")), + }; + + let class = match args.get("class") { + Some(val) => val.as_str().unwrap_or("active"), + None => "active", + }; + + if current == page { + Ok(Value::String(class.to_string())) + } else { + Ok(Value::String("".to_string())) + } +} diff --git a/src/views/auth/login.html b/src/views/auth/login.html new file mode 100644 index 0000000..4dc0d99 --- /dev/null +++ b/src/views/auth/login.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Login - Hostbasket{% endblock %} + +{% block content %} +
+
+
+
+
+

Login

+
+
+
+
+ + +
+
+ + +
+ +
+ +
+ +
+

Or login with:

+ + Login with Gitea + +
+ +
+ +
+

Don't have an account? Register

+
+
+
+
+
+
+{% endblock %} diff --git a/src/views/auth/register.html b/src/views/auth/register.html new file mode 100644 index 0000000..65a0824 --- /dev/null +++ b/src/views/auth/register.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}Register - Hostbasket{% endblock %} + +{% block content %} +
+
+
+
+
+

Register

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+

Already have an account? Login

+
+
+
+
+
+
+{% endblock %} diff --git a/src/views/base.html b/src/views/base.html new file mode 100644 index 0000000..0386e6c --- /dev/null +++ b/src/views/base.html @@ -0,0 +1,66 @@ + + + + + + + {% block title %}Hostbasket{% endblock %} + + + {% block head %}{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ +
+
+ © 2023 Hostbasket Powered byThreefold. All rights reserved. +
+
+ + + {% block scripts %}{% endblock %} + + + \ No newline at end of file diff --git a/src/views/home/about.html b/src/views/home/about.html new file mode 100644 index 0000000..ef9d450 --- /dev/null +++ b/src/views/home/about.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}About - Hostbasket{% endblock %} + +{% block content %} +
+
+
+

About Hostbasket

+

A web application framework built with Actix Web and Rust.

+
+ +

Features

+
    +
  • Actix Web: A powerful, pragmatic, and extremely fast web framework for Rust
  • +
  • Tera Templates: A template engine inspired by Jinja2 and Django templates
  • +
  • Bootstrap 5.3.5: A popular CSS framework for responsive web design
  • +
  • MVC Architecture: Clean separation of concerns with Models, Views, and Controllers +
  • +
  • Middleware Support: Custom middleware for request timing and security headers
  • +
  • Configuration Management: Flexible configuration system with environment variable + support
  • +
  • Static File Serving: Serve CSS, JavaScript, and other static assets
  • +
+ +

Project Structure

+
+hostbasket/
+├── Cargo.toml                 # Project dependencies
+├── src/
+│   ├── config/                # Configuration management
+│   ├── controllers/           # Request handlers
+│   ├── middleware/            # Custom middleware components
+│   ├── models/                # Data models and business logic
+│   ├── routes/                # Route definitions
+│   ├── static/                # Static assets (CSS, JS, images)
+│   │   ├── css/               # CSS files including Bootstrap
+│   │   ├── js/                # JavaScript files
+│   │   └── images/            # Image files
+│   ├── utils/                 # Utility functions
+│   ├── views/                 # Tera templates
+│   └── main.rs                # Application entry point
+            
+ +

Getting Started

+

To get started with Hostbasket, check out the GitHub repository.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/src/views/home/index.html b/src/views/home/index.html new file mode 100644 index 0000000..5b3bd08 --- /dev/null +++ b/src/views/home/index.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block title %}Home - Hostbasket{% endblock %} + +{% block content %} +
+
+
+
+

Welcome to Hostbasket!

+

A web application framework built with Actix Web and Rust.

+
+

This is a starter template for building web applications with Actix Web and Rust.

+

+ Learn more +

+
+
+
+ +
+
+
+
+
Fast
+

Built with Actix Web, one of the fastest web frameworks available.

+
+
+
+
+
+
+
Secure
+

Rust's memory safety guarantees help prevent common security issues.

+
+
+
+
+
+
+
Scalable
+

Designed to handle high loads and scale horizontally.

+
+
+
+
+
+{% endblock %} \ No newline at end of file