Compare commits
6 Commits
78e1f4ef72
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a66661932 | |||
| b30a6a102b | |||
| 38a3aa6bbf | |||
| 3135a15cb3 | |||
| d083f8ae3d | |||
| 874c406d4a |
@@ -4,3 +4,7 @@ target/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.cargo/
|
||||
.sqlx/
|
||||
docs/
|
||||
dev.db
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
DATABASE_URL=sqlite:./dev.db
|
||||
BASE_URL=http://localhost:3000
|
||||
PORT=3000
|
||||
SECURE_COOKIES=false
|
||||
JWT_SECRET=
|
||||
JWT_TTL_SECONDS=
|
||||
ALLOW_REGISTRATION=true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,7 +8,9 @@
|
||||
.env.prod
|
||||
|
||||
*.db
|
||||
*db-shm
|
||||
*db-wal
|
||||
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
docs/
|
||||
docs/
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Movies Diary</title>
|
||||
<title>{{ ctx.page_title }}</title>
|
||||
<meta name="description" content="A personal movie diary — track what you watch, rate and review films.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Movies Diary">
|
||||
<meta property="og:title" content="{{ ctx.page_title }}">
|
||||
<meta property="og:url" content="{{ ctx.canonical_url }}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{ ctx.page_title }}">
|
||||
<link rel="canonical" href="{{ ctx.canonical_url }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
pub allow_registration: bool,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
@@ -8,6 +9,8 @@ impl AppConfig {
|
||||
let allow_registration = std::env::var("ALLOW_REGISTRATION")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
Self { allow_registration }
|
||||
let base_url = std::env::var("BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
Self { allow_registration, base_url }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ pub struct HtmlPageContext {
|
||||
pub user_id: Option<Uuid>,
|
||||
pub register_enabled: bool,
|
||||
pub rss_url: String,
|
||||
pub page_title: String,
|
||||
pub canonical_url: String,
|
||||
}
|
||||
|
||||
impl HtmlPageContext {
|
||||
|
||||
@@ -2,15 +2,25 @@ use domain::{errors::DomainError, models::User, value_objects::Email};
|
||||
|
||||
use crate::{commands::RegisterCommand, context::AppContext};
|
||||
|
||||
const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> {
|
||||
if !ctx.config.allow_registration {
|
||||
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)?;
|
||||
|
||||
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?;
|
||||
|
||||
@@ -160,7 +160,7 @@ mod tests {
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
user_repository: Arc::new(PanicUserRepo),
|
||||
config: AppConfig { allow_registration: false },
|
||||
config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ mod tests {
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
user_repository: Arc::new(PanicUserRepo),
|
||||
config: application::config::AppConfig { allow_registration: false },
|
||||
config: application::config::AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string() },
|
||||
},
|
||||
html_renderer: Arc::new(PanicRenderer),
|
||||
rss_renderer: Arc::new(PanicRssRenderer),
|
||||
@@ -282,7 +282,7 @@ mod tests {
|
||||
auth_service: Arc::new(PanicAuth2),
|
||||
password_hasher: Arc::new(PanicHasher2),
|
||||
user_repository: Arc::new(PanicUserRepo2),
|
||||
config: application::config::AppConfig { allow_registration: false },
|
||||
config: application::config::AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string() },
|
||||
},
|
||||
html_renderer: Arc::new(PanicRenderer2),
|
||||
rss_renderer: Arc::new(PanicRssRenderer2),
|
||||
@@ -341,7 +341,7 @@ mod tests {
|
||||
auth_service: Arc::new(RejectingAuth),
|
||||
password_hasher: Arc::new(PanicHasher3),
|
||||
user_repository: Arc::new(PanicUserRepo3),
|
||||
config: application::config::AppConfig { allow_registration: false },
|
||||
config: application::config::AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string() },
|
||||
},
|
||||
html_renderer: Arc::new(PanicRenderer3),
|
||||
rss_renderer: Arc::new(PanicRssRenderer3),
|
||||
|
||||
@@ -43,6 +43,8 @@ pub mod html {
|
||||
user_id: uuid,
|
||||
register_enabled: state.app_ctx.config.allow_registration,
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Movies Diary".to_string(),
|
||||
canonical_url: state.app_ctx.config.base_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,10 +55,14 @@ pub mod html {
|
||||
.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) {
|
||||
let val = format!(
|
||||
"token={}; HttpOnly; Path=/; SameSite=Lax; Max-Age={}",
|
||||
token, max_age
|
||||
"token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}",
|
||||
token, max_age, secure_flag()
|
||||
);
|
||||
(SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie"))
|
||||
}
|
||||
@@ -70,6 +76,8 @@ pub mod html {
|
||||
user_id: None,
|
||||
register_enabled: state.app_ctx.config.allow_registration,
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Login — Movies Diary".to_string(),
|
||||
canonical_url: format!("{}/login", state.app_ctx.config.base_url),
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
@@ -104,10 +112,8 @@ pub mod html {
|
||||
}
|
||||
|
||||
pub async fn get_logout() -> impl IntoResponse {
|
||||
let cookie = (
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_static("token=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"),
|
||||
);
|
||||
let val = format!("token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", secure_flag());
|
||||
let cookie = (SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie"));
|
||||
([cookie], Redirect::to("/")).into_response()
|
||||
}
|
||||
|
||||
@@ -123,6 +129,8 @@ pub mod html {
|
||||
user_id: None,
|
||||
register_enabled: true,
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Register — Movies Diary".to_string(),
|
||||
canonical_url: format!("{}/register", state.app_ctx.config.base_url),
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
@@ -162,9 +170,8 @@ pub mod html {
|
||||
Err(_) => Redirect::to("/login").into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!("/register?error={}", msg)).into_response()
|
||||
Err(_) => {
|
||||
Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +181,9 @@ pub mod html {
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, Some(user_id)).await;
|
||||
let mut ctx = build_page_context(&state, Some(user_id)).await;
|
||||
ctx.page_title = "Log a Review — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_new_review_page(NewReviewPageData {
|
||||
@@ -261,7 +270,9 @@ pub mod html {
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id).await;
|
||||
let mut ctx = build_page_context(&state, user_id).await;
|
||||
ctx.page_title = "Members — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
|
||||
match application::use_cases::get_users::execute(&state.app_ctx, application::queries::GetUsersQuery).await {
|
||||
Ok(users) => {
|
||||
let data = application::ports::UsersPageData { ctx, users };
|
||||
@@ -292,6 +303,11 @@ pub mod html {
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
let display_name = profile_user.email().value()
|
||||
.split('@').next().unwrap_or("User");
|
||||
ctx.page_title = format!("{}'s Diary — Movies Diary", display_name);
|
||||
ctx.canonical_url = format!("{}/users/{}", state.app_ctx.config.base_url, profile_user_uuid);
|
||||
|
||||
let query = application::queries::GetUserProfileQuery {
|
||||
user_id: profile_user_uuid,
|
||||
view: view.clone(),
|
||||
|
||||
@@ -32,8 +32,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let app = routes::build_router(state);
|
||||
|
||||
let listener = TcpListener::bind("0.0.0.0:3000").await?;
|
||||
tracing::info!("Listening on 0.0.0.0:3000");
|
||||
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
tracing::info!("Listening on {}", addr);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -48,7 +51,9 @@ async fn wire_dependencies() -> anyhow::Result<AppState> {
|
||||
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
let opts = SqliteConnectOptions::from_str(&database_url)
|
||||
.context("Invalid DATABASE_URL")?
|
||||
.create_if_missing(true);
|
||||
.create_if_missing(true)
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.busy_timeout(std::time::Duration::from_secs(5));
|
||||
let pool = SqlitePool::connect_with(opts)
|
||||
.await
|
||||
.context("Failed to connect to SQLite database")?;
|
||||
|
||||
@@ -1,8 +1,52 @@
|
||||
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 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::Acquire);
|
||||
if now != prev {
|
||||
// compare_exchange ensures only one thread wins the window reset
|
||||
if self.window.compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed).is_ok() {
|
||||
self.count.store(1, Ordering::Release);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.count.fetch_add(1, Ordering::Relaxed) + 1 <= self.limit
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.merge(html_routes())
|
||||
@@ -13,30 +57,73 @@ pub fn build_router(state: AppState) -> Router {
|
||||
}
|
||||
|
||||
fn html_routes() -> Router<AppState> {
|
||||
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))
|
||||
// Auth routes: 20 requests per minute globally.
|
||||
let limiter = RateLimiter::new(API_RATE_LIMIT);
|
||||
let auth = Router::new()
|
||||
.route(
|
||||
"/login",
|
||||
routing::get(handlers::html::get_login_page)
|
||||
.post(handlers::html::post_login),
|
||||
routing::get(handlers::html::get_login_page).post(handlers::html::post_login),
|
||||
)
|
||||
.route("/logout", routing::get(handlers::html::get_logout))
|
||||
.route(
|
||||
"/register",
|
||||
routing::get(handlers::html::get_register_page)
|
||||
.post(handlers::html::post_register),
|
||||
routing::get(handlers::html::get_register_page).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/{id}/delete", routing::post(handlers::html::post_delete_review))
|
||||
.route("/posters/{path}", routing::get(handlers::posters::get_poster))
|
||||
.route(
|
||||
"/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("/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> {
|
||||
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(
|
||||
"/api",
|
||||
Router::new()
|
||||
@@ -46,12 +133,16 @@ fn api_routes() -> Router<AppState> {
|
||||
routing::get(handlers::api::get_review_history),
|
||||
)
|
||||
.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(
|
||||
"/movies/{id}/sync-poster",
|
||||
routing::post(handlers::api::sync_poster),
|
||||
)
|
||||
.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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ async fn test_app() -> Router {
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
user_repository: Arc::new(NobodyUserRepo),
|
||||
config: AppConfig { allow_registration: false },
|
||||
config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string() },
|
||||
},
|
||||
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
|
||||
|
||||
@@ -34,6 +34,21 @@ body {
|
||||
background: url("/static/background.avif") center / cover no-repeat fixed;
|
||||
min-height: 100%;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
Reference in New Issue
Block a user