simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit cb0e3705551167533a63848c622300d72504375c
parent a302bcc59be3eba6eb778914c481cbfd46d0611a
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sat, 13 Jun 2026 13:34:20 +0200

refactor: separate Config from AppState

AppState now holds Arc<Config> instead of copying config fields.
Config is immutable after startup, AppState holds runtime state
(db, comment_tx). Clear separation of fixed vs ephemeral.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Msrc/handlers/auth.rs | 10+++++-----
Msrc/handlers/comment.rs | 6+++---
Msrc/handlers/feed.rs | 8++++----
Msrc/handlers/search.rs | 2+-
Msrc/handlers/story.rs | 12++++++------
Msrc/handlers/tag.rs | 2+-
Msrc/handlers/user.rs | 2+-
Msrc/handlers/vote.rs | 4++--
Msrc/main.rs | 4++--
Msrc/state.rs | 15+++------------
10 files changed, 28 insertions(+), 37 deletions(-)

diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs @@ -37,9 +37,9 @@ fn send_verification_email(state: &AppState, email: &str, token: &str) -> Result use std::io::{BufRead, BufReader, Write}; use std::net::TcpStream; - let verify_url = format!("{}/verify-email?token={}", state.site_url, token); + let verify_url = format!("{}/verify-email?token={}", state.config.site_url, token); - let mut stream = TcpStream::connect(format!("{}:{}", state.smtp_host, state.smtp_port)) + let mut stream = TcpStream::connect(format!("{}:{}", state.config.smtp_host, state.config.smtp_port)) .map_err(|e| format!("SMTP connect failed: {e}"))?; stream .set_read_timeout(Some(std::time::Duration::from_secs(10))) @@ -75,7 +75,7 @@ fn send_verification_email(state: &AppState, email: &str, token: &str) -> Result } } - write!(stream, "MAIL FROM:<{}>\r\n", state.smtp_from).map_err(|e| format!("SMTP write: {e}"))?; + write!(stream, "MAIL FROM:<{}>\r\n", state.config.smtp_from).map_err(|e| format!("SMTP write: {e}"))?; read_reply(&mut reader, "250")?; write!(stream, "RCPT TO:<{email}>\r\n").map_err(|e| format!("SMTP write: {e}"))?; @@ -87,7 +87,7 @@ fn send_verification_email(state: &AppState, email: &str, token: &str) -> Result write!( stream, "From: <{}>\r\nTo: <{}>\r\nSubject: Verify your email\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nClick to verify your email: {}\r\n.\r\n", - state.smtp_from, email, verify_url + state.config.smtp_from, email, verify_url ) .map_err(|e| format!("SMTP write: {e}"))?; read_reply(&mut reader, "250")?; @@ -129,7 +129,7 @@ pub async fn login( ) -> Result<(CookieJar, Redirect), Html<String>> { let email = form.email.clone(); let password = form.password.clone(); - let jwt_secret = state.jwt_secret.clone(); + let jwt_secret = state.config.jwt_secret.clone(); let result = state.db_query(move |db| { let user = db.get_user_by_email(&email)? diff --git a/src/handlers/comment.rs b/src/handlers/comment.rs @@ -24,7 +24,7 @@ pub async fn create_comment( Path(story_id): Path<i64>, Form(form): Form<CommentForm>, ) -> Result<Redirect, Html<String>> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret).ok_or_else(|| { + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret).ok_or_else(|| { Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) })?; @@ -51,7 +51,7 @@ pub async fn show_reply_form( jar: CookieJar, Path(comment_id): Path<i64>, ) -> Result<Html<String>, AppError> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret); if user_id.is_none() { return Ok(Html( @@ -102,7 +102,7 @@ pub async fn submit_reply( Path(parent_id): Path<i64>, Form(form): Form<ReplyForm>, ) -> Result<Redirect, Html<String>> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret).ok_or_else(|| { + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret).ok_or_else(|| { Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) })?; diff --git a/src/handlers/feed.rs b/src/handlers/feed.rs @@ -42,7 +42,7 @@ async fn fetch_feed( sort: FeedSort, ) -> Result<(Vec<StoryWithMeta>, Vec<Category>, bool), AppError> { let offset = query.page * PAGE_LIMIT; - let user_id = get_user_id_from_cookies(jar, &state.jwt_secret); + let user_id = get_user_id_from_cookies(jar, &state.config.jwt_secret); let tag_id = query.tag; let page = query.page; @@ -114,7 +114,7 @@ pub async fn show_feed( Query(query): Query<FeedQuery>, ) -> Result<Html<String>, AppError> { let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::Hot).await?; - let is_logged_in = get_user_id_from_cookies(&jar, &state.jwt_secret).is_some(); + let is_logged_in = get_user_id_from_cookies(&jar, &state.config.jwt_secret).is_some(); let feed = FeedTemplate { stories, @@ -138,7 +138,7 @@ pub async fn show_new( Query(query): Query<FeedQuery>, ) -> Result<Html<String>, AppError> { let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::New).await?; - let is_logged_in = get_user_id_from_cookies(&jar, &state.jwt_secret).is_some(); + let is_logged_in = get_user_id_from_cookies(&jar, &state.config.jwt_secret).is_some(); let feed = FeedTemplate { stories, @@ -162,7 +162,7 @@ pub async fn show_top( Query(query): Query<FeedQuery>, ) -> Result<Html<String>, AppError> { let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::Top).await?; - let is_logged_in = get_user_id_from_cookies(&jar, &state.jwt_secret).is_some(); + let is_logged_in = get_user_id_from_cookies(&jar, &state.config.jwt_secret).is_some(); let feed = FeedTemplate { stories, diff --git a/src/handlers/search.rs b/src/handlers/search.rs @@ -17,7 +17,7 @@ pub async fn open_search( State(state): State<AppState>, jar: CookieJar, ) -> Result<Html<String>, AppError> { - let is_logged_in = get_user_id_from_cookies(&jar, &state.jwt_secret).is_some(); + let is_logged_in = get_user_id_from_cookies(&jar, &state.config.jwt_secret).is_some(); let template = SearchTemplate { is_logged_in }; let app = ApplicationTemplate { content: template.render()?, diff --git a/src/handlers/story.rs b/src/handlers/story.rs @@ -71,7 +71,7 @@ pub async fn show_story( jar: CookieJar, Path(id): Path<i64>, ) -> Result<Html<String>, AppError> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret); let (story, tags, author_username, story_voted) = state.db_query(move |db| { let story = db.get_story(id)? @@ -128,7 +128,7 @@ pub async fn show_submit( State(state): State<AppState>, jar: CookieJar, ) -> Result<Html<String>, AppError> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret); if user_id.is_none() { return Ok(Html( r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string(), @@ -170,7 +170,7 @@ pub async fn submit_story( jar: CookieJar, Form(form): Form<SubmitForm>, ) -> Result<Redirect, Html<String>> { - let uid = get_user_id_from_cookies(&jar, &state.jwt_secret).ok_or_else(|| { + let uid = get_user_id_from_cookies(&jar, &state.config.jwt_secret).ok_or_else(|| { Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) })?; @@ -258,7 +258,7 @@ pub async fn show_edit( jar: CookieJar, Path(id): Path<i64>, ) -> Result<Html<String>, AppError> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret) + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret) .ok_or_else(|| AppError::Unauthorized("Login required".to_string()))?; let (story, all_tags, story_tags) = state.db_query(move |db| { @@ -312,7 +312,7 @@ pub async fn edit_story( Path(id): Path<i64>, Form(form): Form<EditForm>, ) -> Result<Redirect, Html<String>> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret).ok_or_else(|| { + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret).ok_or_else(|| { Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) })?; @@ -393,7 +393,7 @@ pub async fn comments_stream( jar: CookieJar, Path(story_id): Path<i64>, ) -> Response { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret); let is_logged_in = user_id.is_some(); let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(10); let mut broadcast_rx = state.comment_tx.subscribe(); diff --git a/src/handlers/tag.rs b/src/handlers/tag.rs @@ -27,7 +27,7 @@ pub async fn show_tag( Path(tag_name): Path<String>, Query(query): Query<TagQuery>, ) -> Result<Html<String>, AppError> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret); let offset = query.page * PAGE_LIMIT; let page = query.page; let tag_name_clone = tag_name.clone(); diff --git a/src/handlers/user.rs b/src/handlers/user.rs @@ -29,7 +29,7 @@ pub async fn show_user( Path(username): Path<String>, Query(query): Query<UserQuery>, ) -> Result<Html<String>, AppError> { - let current_user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + let current_user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret); let tab = query.tab.clone(); let username_clone = username.clone(); diff --git a/src/handlers/vote.rs b/src/handlers/vote.rs @@ -35,7 +35,7 @@ pub async fn vote_story( jar: CookieJar, Path(story_id): Path<i64>, ) -> Result<Response, AppError> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret) + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret) .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; let (voted, new_score) = state.db_query(move |db| { @@ -53,7 +53,7 @@ pub async fn vote_comment( jar: CookieJar, Path(comment_id): Path<i64>, ) -> Result<Response, AppError> { - let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret) + let user_id = get_user_id_from_cookies(&jar, &state.config.jwt_secret) .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; let (voted, new_score) = state.db_query(move |db| { diff --git a/src/main.rs b/src/main.rs @@ -31,12 +31,12 @@ async fn async_main() -> Result<(), Box<dyn std::error::Error>> { eprintln!("Starting server with DEBUG={}", config.debug); eprintln!("Database path: {}", config.db_path); + let addr = format!("0.0.0.0:{}", config.port); let db = Database::new(&config.db_path)?; - let state = AppState::new(&config, db); + let state = AppState::new(config, db); let app = routes::create_router(state); - let addr = format!("0.0.0.0:{}", config.port); let listener = tokio::net::TcpListener::bind(&addr).await?; eprintln!("Server listening on http://{}", addr); diff --git a/src/state.rs b/src/state.rs @@ -6,30 +6,21 @@ use crate::error::AppError; #[derive(Clone)] pub struct AppState { + pub config: Arc<Config>, pub db: Arc<Database>, - pub jwt_secret: String, pub comment_tx: tokio::sync::broadcast::Sender<i64>, - pub smtp_host: String, - pub smtp_port: u16, - pub smtp_from: String, - pub site_url: String, } impl AppState { - pub fn new(config: &Config, db: Database) -> Self { + pub fn new(config: Config, db: Database) -> Self { let (comment_tx, _) = tokio::sync::broadcast::channel(64); Self { + config: Arc::new(config), db: Arc::new(db), - jwt_secret: config.jwt_secret.clone(), comment_tx, - smtp_host: config.smtp_host.clone(), - smtp_port: config.smtp_port, - smtp_from: config.smtp_from.clone(), - site_url: config.site_url.clone(), } } - /// Run a blocking database query on the Tokio blocking thread pool. pub async fn db_query<F, T>(&self, f: F) -> Result<T, AppError> where F: FnOnce(&Database) -> Result<T, AppError> + Send + 'static,