Compare commits

...

2 Commits

4 changed files with 127 additions and 25 deletions

View File

@@ -1,6 +1,7 @@
DATABASE_URL=sqlite:./dev.db DATABASE_URL=sqlite:./dev.db
BASE_URL=http://localhost:3000 BASE_URL=http://localhost:3000
PORT=3000 PORT=3000
SECURE_COOKIES=false
JWT_SECRET= JWT_SECRET=
JWT_TTL_SECONDS= JWT_TTL_SECONDS=
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=true

View File

@@ -2,15 +2,25 @@ use domain::{errors::DomainError, models::User, value_objects::Email};
use crate::{commands::RegisterCommand, context::AppContext}; use crate::{commands::RegisterCommand, context::AppContext};
const MIN_PASSWORD_LENGTH: usize = 8;
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> { pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> {
if !ctx.config.allow_registration { if !ctx.config.allow_registration {
return Err(DomainError::Unauthorized("Registration is disabled".into())); return Err(DomainError::Unauthorized("Registration is disabled".into()));
} }
if cmd.password.len() < MIN_PASSWORD_LENGTH {
return Err(DomainError::ValidationError(
"Password must be at least 8 characters".into(),
));
}
let email = Email::new(cmd.email)?; let email = Email::new(cmd.email)?;
if ctx.user_repository.find_by_email(&email).await?.is_some() { if ctx.user_repository.find_by_email(&email).await?.is_some() {
return Err(DomainError::ValidationError("Email already registered".into())); return Err(DomainError::ValidationError(
"Email already registered".into(),
));
} }
let hash = ctx.password_hasher.hash(&cmd.password).await?; let hash = ctx.password_hasher.hash(&cmd.password).await?;

View File

@@ -53,10 +53,14 @@ pub mod html {
.replace('"', "%22") .replace('"', "%22")
} }
fn secure_flag() -> &'static str {
if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") { "; Secure" } else { "" }
}
fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) {
let val = format!( let val = format!(
"token={}; HttpOnly; Path=/; SameSite=Lax; Max-Age={}", "token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}",
token, max_age token, max_age, secure_flag()
); );
(SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie")) (SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie"))
} }
@@ -104,10 +108,8 @@ pub mod html {
} }
pub async fn get_logout() -> impl IntoResponse { pub async fn get_logout() -> impl IntoResponse {
let cookie = ( let val = format!("token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", secure_flag());
SET_COOKIE, let cookie = (SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie"));
HeaderValue::from_static("token=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"),
);
([cookie], Redirect::to("/")).into_response() ([cookie], Redirect::to("/")).into_response()
} }
@@ -162,9 +164,8 @@ pub mod html {
Err(_) => Redirect::to("/login").into_response(), Err(_) => Redirect::to("/login").into_response(),
} }
} }
Err(e) => { Err(_) => {
let msg = encode_error(&e.to_string()); Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
Redirect::to(&format!("/register?error={}", msg)).into_response()
} }
} }
} }

View File

@@ -1,8 +1,51 @@
use axum::{Router, routing}; use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{Router, http::StatusCode, middleware, response::IntoResponse, routing};
use tower_http::{services::ServeDir, trace::TraceLayer}; use tower_http::{services::ServeDir, trace::TraceLayer};
use crate::{handlers, state::AppState}; use crate::{handlers, state::AppState};
const API_RATE_LIMIT: u64 = 20; // 20 requests per minute globally for API routes
/// Simple global rate limiter: tracks request count per 60-second window.
/// Not per-IP — suitable for a low-traffic personal app.
#[derive(Clone)]
struct RateLimiter {
window: Arc<AtomicU64>,
count: Arc<AtomicU64>,
limit: u64,
}
impl RateLimiter {
fn new(limit: u64) -> Self {
Self {
window: Arc::new(AtomicU64::new(0)),
count: Arc::new(AtomicU64::new(0)),
limit,
}
}
fn check(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
/ 60;
let prev = self.window.load(Ordering::Relaxed);
if now != prev {
self.window.store(now, Ordering::Relaxed);
self.count.store(1, Ordering::Relaxed);
true
} else {
self.count.fetch_add(1, Ordering::Relaxed) + 1 <= self.limit
}
}
}
pub fn build_router(state: AppState) -> Router { pub fn build_router(state: AppState) -> Router {
Router::new() Router::new()
.merge(html_routes()) .merge(html_routes())
@@ -13,30 +56,73 @@ pub fn build_router(state: AppState) -> Router {
} }
fn html_routes() -> Router<AppState> { fn html_routes() -> Router<AppState> {
Router::new() // Auth routes: 20 requests per minute globally.
.route("/", routing::get(handlers::html::get_activity_feed)) let limiter = RateLimiter::new(API_RATE_LIMIT);
.route("/users", routing::get(handlers::html::get_users_list)) let auth = Router::new()
.route("/users/{id}", routing::get(handlers::html::get_user_profile))
.route( .route(
"/login", "/login",
routing::get(handlers::html::get_login_page) routing::get(handlers::html::get_login_page).post(handlers::html::post_login),
.post(handlers::html::post_login),
) )
.route("/logout", routing::get(handlers::html::get_logout)) .route("/logout", routing::get(handlers::html::get_logout))
.route( .route(
"/register", "/register",
routing::get(handlers::html::get_register_page) routing::get(handlers::html::get_register_page).post(handlers::html::post_register),
.post(handlers::html::post_register), )
.route_layer(middleware::from_fn(
move |req: axum::extract::Request, next: middleware::Next| {
let limiter = limiter.clone();
async move {
if limiter.check() {
next.run(req).await
} else {
StatusCode::TOO_MANY_REQUESTS.into_response()
}
}
},
));
Router::new()
.route("/", routing::get(handlers::html::get_activity_feed))
.route("/users", routing::get(handlers::html::get_users_list))
.route(
"/users/{id}",
routing::get(handlers::html::get_user_profile),
)
.merge(auth)
.route(
"/reviews/new",
routing::get(handlers::html::get_new_review_page),
) )
.route("/reviews/new", routing::get(handlers::html::get_new_review_page))
.route("/reviews", routing::post(handlers::html::post_review)) .route("/reviews", routing::post(handlers::html::post_review))
.route("/reviews/{id}/delete", routing::post(handlers::html::post_delete_review)) .route(
.route("/posters/{path}", routing::get(handlers::posters::get_poster)) "/reviews/{id}/delete",
routing::post(handlers::html::post_delete_review),
)
.route(
"/posters/{path}",
routing::get(handlers::posters::get_poster),
)
.route("/feed.rss", routing::get(handlers::rss::get_feed)) .route("/feed.rss", routing::get(handlers::rss::get_feed))
.route("/users/{id}/feed.rss", routing::get(handlers::rss::get_user_feed)) .route(
"/users/{id}/feed.rss",
routing::get(handlers::rss::get_user_feed),
)
} }
fn api_routes() -> Router<AppState> { fn api_routes() -> Router<AppState> {
let limiter = RateLimiter::new(API_RATE_LIMIT);
let auth_rate_limit =
middleware::from_fn(move |req: axum::extract::Request, next: middleware::Next| {
let limiter = limiter.clone();
async move {
if limiter.check() {
next.run(req).await
} else {
StatusCode::TOO_MANY_REQUESTS.into_response()
}
}
});
Router::new().nest( Router::new().nest(
"/api", "/api",
Router::new() Router::new()
@@ -46,12 +132,16 @@ fn api_routes() -> Router<AppState> {
routing::get(handlers::api::get_review_history), routing::get(handlers::api::get_review_history),
) )
.route("/reviews", routing::post(handlers::api::post_review)) .route("/reviews", routing::post(handlers::api::post_review))
.route("/reviews/{id}", routing::delete(handlers::api::delete_review)) .route(
"/reviews/{id}",
routing::delete(handlers::api::delete_review),
)
.route( .route(
"/movies/{id}/sync-poster", "/movies/{id}/sync-poster",
routing::post(handlers::api::sync_poster), routing::post(handlers::api::sync_poster),
) )
.route("/auth/login", routing::post(handlers::api::login)) .route("/auth/login", routing::post(handlers::api::login))
.route("/auth/register", routing::post(handlers::api::register)), .route("/auth/register", routing::post(handlers::api::register))
.route_layer(auth_rate_limit),
) )
} }