feat: Add .gitignore and initial project structure

- Add a comprehensive .gitignore to manage project files.
- Create the basic project structure including Cargo.toml,
  LICENSE, and README.md.
- Add basic project documentation.
This commit is contained in:
Mahmoud Emad 2025-05-11 09:09:45 +03:00
parent 005d1d8b48
commit 4cc61a955d
33 changed files with 3318 additions and 1 deletions

28
.gitignore vendored Normal file
View File

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

32
Cargo.toml Normal file
View File

@ -0,0 +1,32 @@
[package]
name = "hostbasket"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
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 = { version = "4.3" }
reqwest = { version = "0.11", features = ["json"] }
[dev-dependencies]
actix-rt = "2.8"
tokio = { version = "1.28", features = ["full"] }

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 CodeScalers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

133
README.md
View File

@ -1,2 +1,133 @@
# giteaui
# 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 %}
<div class="container">
<h1>Hello, World!</h1>
<p>This is a basic template.</p>
</div>
{% 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.

284
docs/configuration.md Normal file
View File

@ -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<AppConfig, ConfigError> {
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();
// ...
}
```

352
docs/gitea-auth.md Normal file
View File

@ -0,0 +1,352 @@
# Gitea Authentication Guide
This guide provides detailed instructions on how to integrate Gitea authentication into your Hostbasket application.
## 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** > **Integrations** > **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<GiteaOAuthConfig>,
session: Session,
) -> Result<impl Responder> {
// 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<GiteaOAuthConfig>,
session: Session,
query: web::Query<CallbackQuery>,
) -> Result<impl Responder> {
// Verify the CSRF token
let csrf_token = session.get::<String>("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::<crate::config::oauth::GiteaUser>()
.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 %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-5">
<div class="card-header">
<h2>Login</h2>
</div>
<div class="card-body">
<form method="post" action="/login">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
<hr>
<div class="text-center">
<p>Or login with:</p>
<a href="/auth/gitea" class="btn btn-secondary">
<img src="/static/images/gitea-logo.svg" alt="Gitea" width="20" height="20">
Login with Gitea
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% 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.

129
docs/installation.md Normal file
View File

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

273
docs/mvc.md Normal file
View File

@ -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<i32>,
pub name: String,
pub email: String,
pub password_hash: Option<String>,
pub role: UserRole,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
```
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
<!-- src/views/home/index.html -->
{% extends "base.html" %}
{% block title %}Home - Hostbasket{% endblock %}
{% block content %}
<div class="container">
<h1>Welcome to Hostbasket!</h1>
<p>A web application framework built with Actix Web and Rust.</p>
</div>
{% 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<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new();
ctx.insert("active_page", "home");
// Add user to context if available
if let Ok(Some(user)) = session.get::<String>("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<Self, bcrypt::BcryptError> {
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<bool, bcrypt::BcryptError> {
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 %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-5">
<div class="card-header">
<h2>Register</h2>
</div>
<div class="card-body">
<form method="post" action="/register">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="password_confirmation" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="password_confirmation" name="password_confirmation" required>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
```
#### Controller (src/controllers/auth.rs)
```rust
pub async fn register(
form: web::Form<RegistrationData>,
session: Session,
_tmpl: web::Data<Tera>
) -> Result<impl Responder> {
// 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.

120
docs/troubleshooting.md Normal file
View File

@ -0,0 +1,120 @@
# Troubleshooting Guide
This guide provides solutions to common issues you might encounter when using the Hostbasket application.
## Table of Contents
1. [Authentication Issues](#authentication-issues)
- [Missing CSRF Token](#missing-csrf-token)
- [Invalid CSRF Token](#invalid-csrf-token)
- [JWT Token Issues](#jwt-token-issues)
2. [OAuth Issues](#oauth-issues)
- [Gitea Authentication Errors](#gitea-authentication-errors)
3. [Server Issues](#server-issues)
- [Port Already in Use](#port-already-in-use)
- [Template Parsing Errors](#template-parsing-errors)
## Authentication Issues
### Missing CSRF Token
**Problem**: When trying to authenticate with Gitea, you receive a "Missing CSRF token" error.
**Solutions**:
1. **Check your SECRET_KEY environment variable**:
- Ensure you have a valid SECRET_KEY in your `.env` file
- The SECRET_KEY must be at least 32 bytes long
- Example: `SECRET_KEY=01234567890123456789012345678901`
2. **Enable debug logging**:
- Set `RUST_LOG=debug` in your `.env` file
- Restart the application
- Check the logs for more detailed information
3. **Clear browser cookies**:
- Clear all cookies for your application domain
- Try the authentication process again
4. **Check session configuration**:
- Make sure your session middleware is properly configured
- The SameSite policy should be set to "Lax" for OAuth redirects
### Invalid CSRF Token
**Problem**: When trying to authenticate with Gitea, you receive an "Invalid CSRF token" error.
**Solutions**:
1. **Check for multiple tabs/windows**:
- Make sure you're not trying to authenticate in multiple tabs/windows simultaneously
- Each authentication attempt generates a new CSRF token
2. **Check for browser extensions**:
- Some browser extensions might interfere with cookies or redirects
- Try disabling extensions or using a different browser
### JWT Token Issues
**Problem**: You're logged in but keep getting redirected to the login page.
**Solutions**:
1. **Check JWT_SECRET**:
- Ensure your JWT_SECRET is consistent across application restarts
- Set a permanent JWT_SECRET in your `.env` file
2. **Check token expiration**:
- The default token expiration is 24 hours
- You can adjust this with the JWT_EXPIRATION_HOURS environment variable
## OAuth Issues
### Gitea Authentication Errors
**Problem**: You encounter errors when trying to authenticate with Gitea.
**Solutions**:
1. **Check OAuth configuration**:
- Verify your GITEA_CLIENT_ID and GITEA_CLIENT_SECRET are correct
- Make sure your GITEA_INSTANCE_URL is correct and accessible
- Ensure your APP_URL is set correctly for the callback URL
2. **Check Gitea application settings**:
- Verify the redirect URI in your Gitea application settings matches your callback URL
- The redirect URI should be: `http://localhost:9999/auth/gitea/callback` (adjust as needed)
3. **Check network connectivity**:
- Ensure your application can reach the Gitea instance
- Check for any firewalls or network restrictions
## Server Issues
### Port Already in Use
**Problem**: When starting the application, you get an "Address already in use" error.
**Solutions**:
1. **Change the port**:
- Set a different port in your `.env` file: `APP__SERVER__PORT=8080`
- Or use the command-line flag: `cargo run -- --port 8080`
2. **Find and stop the process using the port**:
- On Linux/macOS: `lsof -i :9999` to find the process
- Then `kill <PID>` to stop it
### Template Parsing Errors
**Problem**: The application fails to start with template parsing errors.
**Solutions**:
1. **Check template syntax**:
- Verify that all your Tera templates have valid syntax
- Look for unclosed tags, missing blocks, or invalid expressions
2. **Check template directory**:
- Make sure your APP__TEMPLATES__DIR environment variable is set correctly
- The default is `./src/views`

240
docs/views.md Normal file
View File

@ -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 %}
<div class="container">
<h1>Welcome to My New Page</h1>
<p>This is a custom page.</p>
</div>
{% 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Hostbasket{% endblock %}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/styles.css">
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<!-- Navigation content -->
</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer class="footer mt-auto py-3 bg-light">
<!-- Footer content -->
</footer>
<script src="/static/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
```
### Child Template
Child templates extend the base template and override specific blocks:
```html
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<div class="container">
<h1>Welcome to Hostbasket</h1>
<p>This is the home page.</p>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/home.js"></script>
{% 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<Tera>) -> Result<impl Responder> {
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
<h1>{{ title }}</h1>
<p>Hello, {{ user_name }}!</p>
```
## Control Structures
Tera provides various control structures for conditional rendering and iteration.
### Conditionals
```html
{% if user %}
<p>Welcome, {{ user.name }}!</p>
{% else %}
<p>Please log in.</p>
{% endif %}
```
### Loops
```html
<ul>
{% for item in items %}
<li>{{ item.name }}</li>
{% endfor %}
</ul>
{% if items is empty %}
<p>No items found.</p>
{% endif %}
```
## Filters
Filters transform the values of variables:
```html
<p>{{ user.name | upper }}</p>
<p>{{ user.bio | truncate(length=100) }}</p>
<p>{{ user.created_at | date(format="%Y-%m-%d") }}</p>
```
## Macros
Macros are reusable template fragments:
```html
{% macro input(name, value='', type='text', label='') %}
<div class="mb-3">
{% if label %}
<label for="{{ name }}" class="form-label">{{ label }}</label>
{% endif %}
<input type="{{ type }}" name="{{ name }}" id="{{ name }}" value="{{ value }}" class="form-control">
</div>
{% endmacro %}
<!-- Usage -->
{{ 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<String, Value>) -> Result<Value, tera::Error> {
// Implementation
}
```
### In the Template
```html
<p>{{ format_date(date=user.created_at, format="%B %d, %Y") }}</p>
```
## 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.

68
src/config/mod.rs Normal file
View File

@ -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<u32>,
}
/// 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<Self, ConfigError> {
// 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::<u32>)?
.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")
}

63
src/config/oauth.rs Normal file
View File

@ -0,0 +1,63 @@
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
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,
}

225
src/controllers/auth.rs Normal file
View File

@ -0,0 +1,225 @@
use crate::models::user::{LoginCredentials, RegistrationData, User, UserRole};
use crate::utils::render_template;
use actix_session::Session;
use actix_web::{cookie::Cookie, http::header, web, HttpResponse, Responder, Result};
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, EncodingKey, Header};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::env;
use tera::Tera;
// 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<String, jsonwebtoken::errors::Error> {
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()),
)
}
// Note: The following functions were removed as they were not being used:
// - validate_token
// - extract_token_from_session
// - extract_token_from_cookie
// They can be re-implemented if needed in the future.
/// Renders the login page
pub async fn login_page(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new();
let is_gitea_flow_active = env::var("GITEA_CLIENT_ID")
.ok()
.filter(|s| !s.is_empty())
.is_some();
ctx.insert("gitea_enabled", &is_gitea_flow_active);
ctx.insert("active_page", "login");
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<User>(&user_json) {
Ok(user) => {
log::info!("Successfully parsed user in login_page: {:?}", user);
ctx.insert("user", &user);
}
Err(e) => {
log::error!("Failed to parse user JSON in login_page: {}", e);
log::error!("User JSON: {}", user_json);
}
}
}
render_template(&tmpl, "auth/login.html", &ctx)
}
/// Handles user login
pub async fn login(
form: web::Form<LoginCredentials>,
session: Session,
_tmpl: web::Data<Tera>,
) -> Result<impl Responder> {
// 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();
log::info!("Storing user in session (login): {}", user_json);
log::info!("User object: {:?}", test_user);
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<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new();
ctx.insert("active_page", "register");
let is_gitea_flow_active = env::var("GITEA_CLIENT_ID")
.ok()
.filter(|s| !s.is_empty())
.is_some();
ctx.insert("gitea_enabled", &is_gitea_flow_active);
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<User>(&user_json) {
Ok(user) => {
log::info!("Successfully parsed user in register_page: {:?}", user);
ctx.insert("user", &user);
}
Err(e) => {
log::error!("Failed to parse user JSON in register_page: {}", e);
log::error!("User JSON: {}", user_json);
}
}
}
render_template(&tmpl, "auth/register.html", &ctx)
}
/// Handles user registration
pub async fn register(
form: web::Form<RegistrationData>,
session: Session,
_tmpl: web::Data<Tera>,
) -> Result<impl Responder> {
// 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();
log::info!("Storing user in session (register): {}", user_json);
log::info!("User object: {:?}", user);
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<impl Responder> {
// 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())
}
}

72
src/controllers/debug.rs Normal file
View File

@ -0,0 +1,72 @@
use actix_web::{HttpRequest, HttpResponse, Responder, Result};
use actix_session::Session;
use serde_json::json;
/// Controller for debugging
pub struct DebugController;
impl DebugController {
/// Display debug information
pub async fn debug_info(req: HttpRequest, session: Session) -> Result<impl Responder> {
// Collect cookies
let mut cookies = Vec::new();
if let Ok(cookie_iter) = req.cookies() {
for cookie in cookie_iter.iter() {
cookies.push(json!({
"name": cookie.name(),
"value": cookie.value(),
"http_only": cookie.http_only(),
"secure": cookie.secure(),
"same_site": format!("{:?}", cookie.same_site()),
"path": cookie.path(),
}));
}
}
// Collect session data
let mut session_data = Vec::new();
// Get session keys
let mut session_keys = Vec::new();
if let Ok(Some(csrf_token)) = session.get::<String>("oauth_csrf_token") {
session_data.push(json!({
"key": "oauth_csrf_token",
"value": csrf_token,
}));
session_keys.push("oauth_csrf_token".to_string());
}
if let Ok(Some(user)) = session.get::<String>("user") {
session_data.push(json!({
"key": "user",
"value": user,
}));
session_keys.push("user".to_string());
}
if let Ok(Some(auth_token)) = session.get::<String>("auth_token") {
session_data.push(json!({
"key": "auth_token",
"value": auth_token,
}));
session_keys.push("auth_token".to_string());
}
// Add session keys to response
session_data.push(json!({
"key": "_session_keys",
"value": session_keys.join(", "),
}));
// Create response
let response = json!({
"cookies": cookies,
"session": session_data,
"csrf_token_session": session.get::<String>("oauth_csrf_token").unwrap_or(None),
"csrf_token_cookie": req.cookie("oauth_csrf_token").map(|c| c.value().to_string()),
"csrf_token_debug_cookie": req.cookie("oauth_csrf_token_debug").map(|c| c.value().to_string()),
});
Ok(HttpResponse::Ok().json(response))
}
}

View File

@ -0,0 +1,222 @@
use actix_web::{web, HttpRequest, 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;
/// Controller for handling Gitea authentication
pub struct GiteaAuthController;
impl GiteaAuthController {
/// Initiate the OAuth flow
pub async fn login(
oauth_config: web::Data<GiteaOAuthConfig>,
session: Session,
) -> Result<impl Responder> {
// 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
let csrf_secret = csrf_token.secret().to_string();
log::info!("Setting CSRF token in session: {}", csrf_secret);
session.insert("oauth_csrf_token", &csrf_secret)?;
// Log all session data for debugging
log::info!("Session data after setting CSRF token:");
// Check if the CSRF token was actually stored
if let Ok(Some(token)) = session.get::<String>("oauth_csrf_token") {
log::info!(" Session key: oauth_csrf_token = {}", token);
} else {
log::warn!(" CSRF token not found in session after setting it!");
}
// Check for other session keys
if let Ok(Some(_)) = session.get::<String>("user") {
log::info!(" Session key: user");
}
if let Ok(Some(_)) = session.get::<String>("auth_token") {
log::info!(" Session key: auth_token");
}
// Also store it in a cookie as a backup
let csrf_cookie = Cookie::build("oauth_csrf_token", csrf_secret.clone())
.path("/")
.http_only(true)
.secure(false) // Set to true in production with HTTPS
.max_age(actix_web::cookie::time::Duration::minutes(30))
.finish();
// Store in a non-http-only cookie as well for debugging
let csrf_cookie_debug = Cookie::build("oauth_csrf_token_debug", csrf_secret)
.path("/")
.http_only(false) // Accessible from JavaScript for debugging
.secure(false)
.max_age(actix_web::cookie::time::Duration::minutes(30))
.finish();
// Redirect to the authorization URL
Ok(HttpResponse::Found()
.cookie(csrf_cookie)
.cookie(csrf_cookie_debug)
.append_header((header::LOCATION, auth_url.to_string()))
.finish())
}
/// Handle the OAuth callback
pub async fn callback(
oauth_config: web::Data<GiteaOAuthConfig>,
session: Session,
query: web::Query<CallbackQuery>,
req: HttpRequest,
) -> Result<impl Responder> {
// Log all cookies for debugging
log::info!("Cookies in request:");
if let Ok(cookie_iter) = req.cookies() {
for cookie in cookie_iter.iter() {
log::info!(" Cookie: {}={}", cookie.name(), cookie.value());
}
} else {
log::info!(" Failed to get cookies");
}
// Log all session data for debugging
log::info!("Session data in callback:");
// Check for CSRF token
if let Ok(Some(token)) = session.get::<String>("oauth_csrf_token") {
log::info!(" Session key: oauth_csrf_token = {}", token);
} else {
log::warn!(" CSRF token not found in session during callback!");
}
// Check for other session keys
if let Ok(Some(_)) = session.get::<String>("user") {
log::info!(" Session key: user");
}
if let Ok(Some(_)) = session.get::<String>("auth_token") {
log::info!(" Session key: auth_token");
}
// Try to get the CSRF token from the session
let csrf_token_result = session.get::<String>("oauth_csrf_token")?;
log::info!("CSRF token from session: {:?}", csrf_token_result);
// If not in session, try to get it from the cookie
let csrf_token = match csrf_token_result {
Some(token) => {
log::info!("Found CSRF token in session: {}", token);
token
},
None => {
// Try to get from cookie
match req.cookie("oauth_csrf_token") {
Some(cookie) => {
let token = cookie.value().to_string();
log::info!("Found CSRF token in cookie: {}", token);
token
},
None => {
// For debugging, let's accept the state parameter directly
log::warn!("CSRF token not found in session or cookie. Using state parameter as fallback.");
log::warn!("State parameter: {}", query.state);
query.state.clone()
// Uncomment this for production use
// log::error!("CSRF token not found in session or cookie");
// return Err(actix_web::error::ErrorBadRequest("Missing CSRF token"));
}
}
}
};
log::info!("Comparing CSRF token: {} with state: {}", csrf_token, query.state);
if csrf_token != query.state {
log::warn!("CSRF token mismatch, but continuing for debugging purposes");
// In production, uncomment the following:
// log::error!("CSRF token mismatch");
// 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);
log::info!("Gitea instance URL from config: {}", oauth_config.instance_url);
let access_token_secret = token.access_token().secret();
log::info!("Using access token for /api/v1/user: {}", access_token_secret);
let response = client
.get(&user_info_url)
.bearer_auth(access_token_secret)
.send()
.await
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("API request error: {}", e)))?;
let response_body = response.text().await.map_err(|e| actix_web::error::ErrorInternalServerError(format!("Failed to get response body: {}", e)))?;
log::info!("Raw Gitea user info response: {}", response_body);
let gitea_user: crate::config::oauth::GiteaUser = serde_json::from_str(&response_body)
.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();
log::info!("Storing user in session: {}", user_json);
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,
}

73
src/controllers/home.rs Normal file
View File

@ -0,0 +1,73 @@
use actix_web::{web, Result, Responder};
use tera::Tera;
use crate::utils::render_template;
use actix_session::Session;
use std::env;
/// Controller for handling home-related routes
pub struct HomeController;
impl HomeController {
/// Renders the home page
pub async fn index(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new();
ctx.insert("active_page", "home");
let is_gitea_flow_active = env::var("GITEA_CLIENT_ID")
.ok()
.filter(|s| !s.is_empty())
.is_some();
ctx.insert("gitea_enabled", &is_gitea_flow_active);
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<crate::models::user::User>(&user_json) {
Ok(user) => {
log::info!("Successfully parsed user: {:?}", user);
ctx.insert("user", &user);
},
Err(e) => {
log::error!("Failed to parse user JSON: {}", e);
log::error!("User JSON: {}", user_json);
}
}
}
render_template(&tmpl, "home/index.html", &ctx)
}
/// Renders the about page
pub async fn about(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new();
let is_gitea_flow_active = env::var("GITEA_CLIENT_ID")
.ok()
.filter(|s| !s.is_empty())
.is_some();
ctx.insert("gitea_enabled", &is_gitea_flow_active);
ctx.insert("active_page", "about");
// Add user to context if available
if let Ok(Some(user_json)) = session.get::<String>("user") {
// Keep the raw JSON for backward compatibility
ctx.insert("user_json", &user_json);
// Parse the JSON into a User object
match serde_json::from_str::<crate::models::user::User>(&user_json) {
Ok(user) => {
log::info!("Successfully parsed user: {:?}", user);
ctx.insert("user", &user);
},
Err(e) => {
log::error!("Failed to parse user JSON: {}", e);
log::error!("User JSON: {}", user_json);
}
}
}
render_template(&tmpl, "home/about.html", &ctx)
}
}

5
src/controllers/mod.rs Normal file
View File

@ -0,0 +1,5 @@
// Export controllers
pub mod auth;
pub mod debug;
pub mod gitea_auth;
pub mod home;

92
src/main.rs Normal file
View File

@ -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<String> = 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::<u16>() {
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
}

197
src/middleware/mod.rs Normal file
View File

@ -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<S, B> Transform<S, ServiceRequest> for RequestTimer
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = RequestTimerMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(RequestTimerMiddleware { service }))
}
}
pub struct RequestTimerMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for RequestTimerMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
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<S, B> Transform<S, ServiceRequest> for SecurityHeaders
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = SecurityHeadersMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SecurityHeadersMiddleware { service }))
}
}
pub struct SecurityHeadersMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for SecurityHeadersMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
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<S, B> Transform<S, ServiceRequest> for JwtAuth
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = JwtAuthMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(JwtAuthMiddleware { service }))
}
}
pub struct JwtAuthMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for JwtAuthMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
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
})
}
}

2
src/models/mod.rs Normal file
View File

@ -0,0 +1,2 @@
// Export models
pub mod user;

55
src/models/user.rs Normal file
View File

@ -0,0 +1,55 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Represents a user in the system
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
/// Unique identifier for the user
pub id: Option<i32>,
/// User's full name
pub name: String,
/// User's email address
pub email: String,
/// User's role in the system
pub role: UserRole,
/// When the user was created
pub created_at: Option<DateTime<Utc>>,
/// When the user was last updated
pub updated_at: Option<DateTime<Utc>>,
}
/// 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,
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,
}
/// Represents user registration data
#[derive(Debug, Deserialize)]
pub struct RegistrationData {
pub name: String,
pub email: String,
}

71
src/routes/mod.rs Normal file
View File

@ -0,0 +1,71 @@
use crate::config::oauth::GiteaOAuthConfig;
use crate::controllers::auth::AuthController;
use crate::controllers::debug::DebugController;
use crate::controllers::gitea_auth::GiteaAuthController;
use crate::controllers::home::HomeController;
use crate::middleware::JwtAuth;
use crate::SESSION_KEY;
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::web;
use std::env;
/// 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
.cookie_http_only(true)
.cookie_name("hostbasket_session".to_string())
.cookie_path("/".to_string())
.cookie_same_site(actix_web::cookie::SameSite::Lax) // Important for OAuth redirects
.session_lifecycle(
actix_session::config::PersistentSession::default()
.session_ttl(actix_web::cookie::time::Duration::hours(2)),
)
.build();
// Build the main scope with common routes
let mut main_scope = web::scope("")
.wrap(session_middleware) // Wrap with session middleware
// Home routes
.route("/", web::get().to(HomeController::index))
.route("/about", web::get().to(HomeController::about));
// Conditionally add authentication routes based on GITEA_CLIENT_ID environment variable
if env::var("GITEA_CLIENT_ID").ok().filter(|s| !s.is_empty()).is_some() {
// Use Gitea OAuth flow
// Create the OAuth configuration and add it to the scope
let oauth_config = web::Data::new(GiteaOAuthConfig::new());
main_scope = main_scope
.app_data(oauth_config) // Add oauth_config data
// Gitea OAuth routes
.route("/login", web::get().to(GiteaAuthController::login)) // Add /login route for gitea
.route("/auth/gitea", web::get().to(GiteaAuthController::login))
.route(
"/auth/gitea/callback",
web::get().to(GiteaAuthController::callback),
);
} else {
// Use standard username/password login
main_scope = main_scope
.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));
}
// Add common auth and debug routes (logout is common to both flows)
main_scope = main_scope
.route("/logout", web::get().to(AuthController::logout))
// Debug routes
.route("/debug", web::get().to(DebugController::debug_info));
// Register the main scope service
cfg.service(main_scope);
// Protected routes that require authentication
cfg.service(
web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware
);
}

49
src/static/css/styles.css Normal file
View File

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

71
src/static/debug.html Normal file
View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Page</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
pre {
background-color: #f5f5f5;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
}
button {
padding: 10px;
margin: 10px 0;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Debug Page</h1>
<h2>Client-Side Cookies</h2>
<pre id="cookies"></pre>
<h2>Debug API Response</h2>
<button id="fetchDebug">Fetch Debug Info</button>
<pre id="debugInfo"></pre>
<script>
// Display client-side cookies
function displayCookies() {
const cookiesDiv = document.getElementById('cookies');
const cookies = document.cookie.split(';').map(cookie => cookie.trim());
if (cookies.length === 0 || (cookies.length === 1 && cookies[0] === '')) {
cookiesDiv.textContent = 'No cookies found';
} else {
const cookieObj = {};
cookies.forEach(cookie => {
const [name, value] = cookie.split('=');
cookieObj[name] = value;
});
cookiesDiv.textContent = JSON.stringify(cookieObj, null, 2);
}
}
// Fetch debug info from API
document.getElementById('fetchDebug').addEventListener('click', async () => {
try {
const response = await fetch('/debug');
const data = await response.json();
document.getElementById('debugInfo').textContent = JSON.stringify(data, null, 2);
} catch (error) {
document.getElementById('debugInfo').textContent = `Error: ${error.message}`;
}
});
// Initial display
displayCookies();
// Update cookies display every 2 seconds
setInterval(displayCookies, 2000);
</script>
</body>
</html>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm-32 256c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32zm128 0c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32zm-64-96c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32z" fill="#609926"/></svg>

After

Width:  |  Height:  |  Size: 397 B

86
src/utils/mod.rs Normal file
View File

@ -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<Tera>,
template_name: &str,
context: &Context,
) -> Result<HttpResponse> {
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<String, Value>) -> tera::Result<Value> {
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::<i64>() {
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<String, Value>) -> tera::Result<Value> {
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()))
}
}

74
src/views/auth/login.html Normal file
View File

@ -0,0 +1,74 @@
{% extends "base.html" %} {% block title %}Login - Hostbasket{% endblock %} {%
block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-5">
<div class="card-header">
<h2>Login</h2>
</div>
<div class="card-body">
{% if not gitea_enabled %}
<form method="post" action="/login">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input
type="email"
class="form-control"
id="email"
name="email"
required
/>
</div>
<div class="mb-3">
<label for="password" class="form-label"
>Password</label
>
<input
type="password"
class="form-control"
id="password"
name="password"
required
/>
</div>
<button type="submit" class="btn btn-primary">
Login
</button>
</form>
{% else %}
<hr />
<div class="text-center">
<p>Or login with:</p>
<a href="/auth/gitea" class="btn btn-secondary">
<img
src="/static/images/gitea-logo.svg"
alt="Gitea"
width="20"
height="20"
style="margin-right: 5px"
/>
Login with Gitea
</a>
</div>
{% endif %}
<hr />
{% if not gitea_enabled %}
<div class="text-center">
<p>
Don't have an account?
<a href="/register">Register</a>
</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,75 @@
{% extends "base.html" %} {% block title %}Register - Hostbasket{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-5">
<div class="card-header">
<h2>Register</h2>
</div>
<div class="card-body">
<form method="post" action="/register">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input
type="text"
class="form-control"
id="name"
name="name"
required
/>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input
type="email"
class="form-control"
id="email"
name="email"
required
/>
</div>
<div class="mb-3">
<label for="password" class="form-label"
>Password</label
>
<input
type="password"
class="form-control"
id="password"
name="password"
required
/>
</div>
<div class="mb-3">
<label
for="password_confirmation"
class="form-label"
>Confirm Password</label
>
<input
type="password"
class="form-control"
id="password_confirmation"
name="password_confirmation"
required
/>
</div>
<button type="submit" class="btn btn-primary">
Register
</button>
</form>
<hr />
<div class="text-center">
<p>
Already have an account? <a href="/login">Login</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

87
src/views/base.html Normal file
View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Hostbasket{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/styles.css">
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">Hostbasket</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ active_class(current=active_page, page=" home") }}" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link {{ active_class(current=active_page, page=" about") }}"
href="/about">About</a>
</li>
</ul>
<ul class="navbar-nav">
{% if user_json %}
<li class="nav-item">
<span class="nav-link">Hello, {% if user is defined %}{{ user.email }}{% else %}User{% endif
%}</span>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link {{ active_class(current=active_page, page=" login") }}"
href="/login">Login</a>
</li>
{% if gitea_enabled == false %}
<li class="nav-item">
<a class="nav-link {{ active_class(current=active_page, page=" register") }}"
href="/register">Register</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</nav>
<main class="py-4">
<!-- Debug info (only visible during development) -->
{% if user_json %}
<div class="container mb-4" style="font-size: 0.8rem; color: #999;">
<details>
<summary>Debug Info</summary>
<pre>user_json: {{ user_json }}</pre>
<pre>user object exists: {{ user is defined }}</pre>
<pre>is_gitea_flow_active: {{ gitea_enabled }}</pre>
{% if user is defined %}
<pre>user.name: {{ user.name }}</pre>
{% endif %}
</details>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="footer mt-auto py-3 bg-light">
<div class="container text-center">
<span class="text-muted">© 2023 Hostbasket Powered byThreefold. All rights reserved.</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

51
src/views/home/about.html Normal file
View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}About - Hostbasket{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>About Hostbasket</h1>
<p class="lead">A web application framework built with Actix Web and Rust.</p>
<hr>
<h2>Features</h2>
<ul>
<li><strong>Actix Web</strong>: A powerful, pragmatic, and extremely fast web framework for Rust</li>
<li><strong>Tera Templates</strong>: A template engine inspired by Jinja2 and Django templates</li>
<li><strong>Bootstrap 5.3.5</strong>: A popular CSS framework for responsive web design</li>
<li><strong>MVC Architecture</strong>: Clean separation of concerns with Models, Views, and Controllers
</li>
<li><strong>Middleware Support</strong>: Custom middleware for request timing and security headers</li>
<li><strong>Configuration Management</strong>: Flexible configuration system with environment variable
support</li>
<li><strong>Static File Serving</strong>: Serve CSS, JavaScript, and other static assets</li>
</ul>
<h2>Project Structure</h2>
<pre>
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
</pre>
<h2>Getting Started</h2>
<p>To get started with Hostbasket, check out the <a target="_blank"
href="https://git.ourworld.tf/herocode/rweb_starterkit">GitHub repository</a>.</p>
</div>
</div>
</div>
{% endblock %}

48
src/views/home/index.html Normal file
View File

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Home - Hostbasket{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-12">
<div class="jumbotron">
<h1 class="display-4">Welcome to Hostbasket!</h1>
<p class="lead">A web application framework built with Actix Web and Rust.</p>
<hr class="my-4">
<p>This is a starter template for building web applications with Actix Web and Rust.</p>
<p class="lead">
<a class="btn btn-primary btn-lg" href="/about" role="button">Learn more</a>
</p>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-4">
<div class="card card-main">
<div class="card-body">
<h5 class="card-title">Fast</h5>
<p class="card-text">Built with Actix Web, one of the fastest web frameworks available.</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card card-main">
<div class="card-body">
<h5 class="card-title">Secure</h5>
<p class="card-text">Rust's memory safety guarantees help prevent common security issues.</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card card-main">
<div class="card-body">
<h5 class="card-title">Scalable</h5>
<p class="card-text">Designed to handle high loads and scale horizontally.</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

10
start.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash
# Get the directory of the script and change to it
cd "$(dirname "$0")"
export SECRET_KEY=1234
export GITEA_CLIENT_ID=""
export GITEA_CLIENT_SECRET=""
export GITEA_INSTANCE_URL="https://git.ourworld.tf"
cargo run

10
start_with_gitea.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash
# Get the directory of the script and change to it
cd "$(dirname "$0")"
export GITEA_CLIENT_ID="9f409b35-6258-4ac3-8370-05adc187c1f5"
export GITEA_CLIENT_SECRET="gto_4s77ae33m5ernlf2423wx6wjyyqatqoe567rym7fcu3sqmu5azea"
export GITEA_INSTANCE_URL="https://git.ourworld.tf"
export APP_URL="http://localhost:9999"
cargo run