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`.
This commit is contained in:
68
src/config/mod.rs
Normal file
68
src/config/mod.rs
Normal 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")
|
||||
}
|
62
src/config/oauth.rs
Normal file
62
src/config/oauth.rs
Normal file
@@ -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,
|
||||
}
|
193
src/controllers/auth.rs
Normal file
193
src/controllers/auth.rs
Normal file
@@ -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<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())
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate a JWT token
|
||||
pub fn validate_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
|
||||
let token_data = decode::<Claims>(
|
||||
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<String> {
|
||||
session.get::<String>("auth_token").ok().flatten()
|
||||
}
|
||||
|
||||
/// Extract token from cookie
|
||||
pub fn extract_token_from_cookie(req: &actix_web::HttpRequest) -> Option<String> {
|
||||
req.cookie("auth_token").map(|c| c.value().to_string())
|
||||
}
|
||||
|
||||
/// Renders the login page
|
||||
pub async fn login_page(tmpl: web::Data<Tera>) -> Result<impl Responder> {
|
||||
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<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();
|
||||
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>) -> Result<impl Responder> {
|
||||
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<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();
|
||||
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())
|
||||
}
|
||||
}
|
35
src/controllers/home.rs
Normal file
35
src/controllers/home.rs
Normal file
@@ -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<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)
|
||||
}
|
||||
|
||||
/// Renders the about page
|
||||
pub async fn about(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("active_page", "about");
|
||||
|
||||
// Add user to context if available
|
||||
if let Ok(Some(user)) = session.get::<String>("user") {
|
||||
ctx.insert("user_json", &user);
|
||||
}
|
||||
|
||||
render_template(&tmpl, "home/about.html", &ctx)
|
||||
}
|
||||
}
|
3
src/controllers/mod.rs
Normal file
3
src/controllers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Export controllers
|
||||
pub mod home;
|
||||
pub mod auth;
|
92
src/main.rs
Normal file
92
src/main.rs
Normal 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
197
src/middleware/mod.rs
Normal 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
2
src/models/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Export models
|
||||
pub mod user;
|
62
src/models/user.rs
Normal file
62
src/models/user.rs
Normal file
@@ -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<i32>,
|
||||
/// 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<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,
|
||||
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,
|
||||
}
|
35
src/routes/mod.rs
Normal file
35
src/routes/mod.rs
Normal file
@@ -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
|
||||
);
|
||||
}
|
49
src/static/css/styles.css
Normal file
49
src/static/css/styles.css
Normal 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;
|
||||
}
|
86
src/utils/mod.rs
Normal file
86
src/utils/mod.rs
Normal 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()))
|
||||
}
|
||||
}
|
45
src/views/auth/login.html
Normal file
45
src/views/auth/login.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% 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">
|
||||
<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">
|
||||
Login with Gitea
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="text-center">
|
||||
<p>Don't have an account? <a href="/register">Register</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
44
src/views/auth/register.html
Normal file
44
src/views/auth/register.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% 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 %}
|
66
src/views/base.html
Normal file
66
src/views/base.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<!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">
|
||||
<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>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ active_class(current=active_page, page=" register") }}"
|
||||
href="/register">Register</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="py-4">
|
||||
{% 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
51
src/views/home/about.html
Normal 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
48
src/views/home/index.html
Normal 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 %}
|
Reference in New Issue
Block a user