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:
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,