Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate pages top-level + email_actions Migrate pages/{blog,feeds,sandbox}.rs and pages/email_actions/** to slices: - feeds: State<PgPool> + State<Config>; blog reads/sandbox/most email_actions: State<PgPool> (+ Config/EmailClient/AppCaches/BackgroundTx per handler) - helpers seed_demo_content, validate_deletion_link, dispatch_unsubscribe, perform_*unsubscribe narrowed to &PgPool/&Config - spawn_email! inlined in links; forgot_password local email renamed Holdouts kept on State<AppState>: - account::confirm_delete_handler (AppState::delete_user_account method) - blog::blog_post_page/changelog_post (call helpers::fetch_discussion_info, a shared &AppState helper reading mt_client; narrows with the public batch) No behavior change; email_action 1 + blog 13 + feed 2 + verif 12 + reset 10 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 02:36 UTC
Commit: 4a7964297482dbd3d600855f4cd64078be271e26
Parent: ea98dee
6 files changed, +150 insertions, -114 deletions
@@ -5,6 +5,7 @@ use axum::{
5 5 response::IntoResponse,
6 6 routing::get,
7 7 };
8 + use sqlx::PgPool;
8 9 use tower_sessions::Session;
9 10
10 11 use crate::{
@@ -32,7 +33,7 @@ pub fn blog_routes() -> CsrfRouter<AppState> {
32 33 /// Public blog index page for a project.
33 34 #[tracing::instrument(skip_all, name = "blog_pages::project_blog_page")]
34 35 async fn project_blog_page(
35 - State(state): State<AppState>,
36 + State(db): State<PgPool>,
36 37 session: Session,
37 38 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
38 39 Path(slug): Path<String>,
@@ -40,15 +41,15 @@ async fn project_blog_page(
40 41 let csrf_token = get_csrf_token(&session).await;
41 42
42 43 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
43 - let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
44 + let db_project = db::projects::get_public_project_by_slug(&db, &slug)
44 45 .await?
45 46 .ok_or(AppError::NotFound)?;
46 47
47 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
48 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
48 49 .await?
49 50 .ok_or(AppError::NotFound)?;
50 51
51 - let db_posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?;
52 + let db_posts = db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
52 53
53 54 let project = Project::from_db(&db_project, 0);
54 55
@@ -131,7 +132,7 @@ async fn blog_post_page(
131 132 /// Platform changelog index (alias for the "changelog" project blog).
132 133 #[tracing::instrument(skip_all, name = "blog_pages::changelog_index")]
133 134 async fn changelog_index(
134 - State(state): State<AppState>,
135 + State(db): State<PgPool>,
135 136 session: Session,
136 137 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
137 138 ) -> Result<impl IntoResponse> {
@@ -142,16 +143,16 @@ async fn changelog_index(
142 143 // flagged this site historically; the constant origin is the
143 144 // justification.
144 145 let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
145 - let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
146 + let db_project = db::projects::get_public_project_by_slug(&db, &slug)
146 147 .await?
147 148 .ok_or(AppError::NotFound)?;
148 149
149 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
150 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
150 151 .await?
151 152 .ok_or(AppError::NotFound)?;
152 153
153 154 let db_posts =
154 - db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?;
155 + db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
155 156
156 157 let project = Project::from_db(&db_project, 0);
157 158 let posts: Vec<BlogPostSummary> = db_posts.iter().map(BlogPostSummary::from).collect();
@@ -6,9 +6,11 @@ use axum::{
6 6 Form,
7 7 };
8 8 use serde::Deserialize;
9 + use sqlx::PgPool;
9 10 use tower_sessions::Session;
10 11
11 12 use crate::{
13 + config::Config,
12 14 db::{self, UserId},
13 15 email,
14 16 error::{AppError, Result},
@@ -36,7 +38,8 @@ pub struct ConfirmDeleteForm {
36 38 /// Validate the deletion link parameters and return parsed values, or an error
37 39 /// response if the link is expired or the signature is invalid.
38 40 async fn validate_deletion_link(
39 - state: &AppState,
41 + db: &PgPool,
42 + config: &Config,
40 43 user_str: &str,
41 44 expires_str: &str,
42 45 sig: &str,
@@ -85,7 +88,7 @@ async fn validate_deletion_link(
85 88 }
86 89
87 90 // Get user to verify they exist and get their email for signature verification
88 - let user = match db::users::get_user_by_id(&state.db, user_id).await? {
91 + let user = match db::users::get_user_by_id(db, user_id).await? {
89 92 Some(u) => u,
90 93 None => {
91 94 return Ok(Err(EmailResultTemplate {
@@ -101,7 +104,7 @@ async fn validate_deletion_link(
101 104
102 105 // Verify signature
103 106 let expected_sig =
104 - email::generate_deletion_signature(&state.config.signing_secret, user_id, expires, &user.email);
107 + email::generate_deletion_signature(&config.signing_secret, user_id, expires, &user.email);
105 108 if !constant_time_compare(sig, &expected_sig) {
106 109 return Ok(Err(EmailResultTemplate {
107 110 csrf_token: None,
@@ -123,12 +126,13 @@ async fn validate_deletion_link(
123 126 /// cannot accidentally trigger the deletion.
124 127 #[tracing::instrument(skip_all, name = "email_actions::confirm_delete_page")]
125 128 pub(super) async fn confirm_delete_page(
126 - State(state): State<AppState>,
129 + State(db): State<PgPool>,
130 + State(config): State<Config>,
127 131 session: Session,
128 132 Query(query): Query<ConfirmDeleteQuery>,
129 133 ) -> Result<Response> {
130 134 // Validate the deletion link parameters
131 - match validate_deletion_link(&state, &query.user, &query.expires, &query.sig).await? {
135 + match validate_deletion_link(&db, &config, &query.user, &query.expires, &query.sig).await? {
132 136 Err(error_response) => Ok(error_response),
133 137 Ok(_user_id) => {
134 138 let csrf_token = get_csrf_token(&session).await;
@@ -155,7 +159,9 @@ pub(super) async fn confirm_delete_handler(
155 159 ) -> Result<Response> {
156 160 // Validate the deletion link parameters
157 161 let user_id =
158 - match validate_deletion_link(&state, &form.user, &form.expires, &form.sig).await? {
162 + match validate_deletion_link(&state.db, &state.config, &form.user, &form.expires, &form.sig)
163 + .await?
164 + {
159 165 Err(error_response) => return Ok(error_response),
160 166 Ok(id) => id,
161 167 };
@@ -206,7 +212,8 @@ pub struct UnsubscribeQuery {
206 212 /// This handles clicks from email body links.
207 213 #[tracing::instrument(skip_all, name = "email_actions::unsubscribe_page")]
208 214 pub(super) async fn unsubscribe_page(
209 - State(state): State<AppState>,
215 + State(db): State<PgPool>,
216 + State(config): State<Config>,
210 217 Query(query): Query<UnsubscribeQuery>,
211 218 ) -> Result<Response> {
212 219 let result = |title: &str, msg: &str| -> Response {
@@ -220,7 +227,7 @@ pub(super) async fn unsubscribe_page(
220 227 .into_response()
221 228 };
222 229
223 - match dispatch_unsubscribe(&state, &query).await? {
230 + match dispatch_unsubscribe(&db, &config, &query).await? {
224 231 Some(message) => Ok(result("Unsubscribed", &message)),
225 232 None => Ok(result("Invalid Link", "This unsubscribe link is invalid.")),
226 233 }
@@ -232,10 +239,11 @@ pub(super) async fn unsubscribe_page(
232 239 /// `List-Unsubscribe=One-Click` in the body. CSRF is exempted for this path.
233 240 #[tracing::instrument(skip_all, name = "email_actions::unsubscribe_handler")]
234 241 pub(super) async fn unsubscribe_handler(
235 - State(state): State<AppState>,
242 + State(db): State<PgPool>,
243 + State(config): State<Config>,
236 244 Query(query): Query<UnsubscribeQuery>,
237 245 ) -> Result<Response> {
238 - match dispatch_unsubscribe(&state, &query).await? {
246 + match dispatch_unsubscribe(&db, &config, &query).await? {
239 247 Some(_) => Ok(axum::http::StatusCode::OK.into_response()),
240 248 None => Err(AppError::BadRequest("Invalid unsubscribe link".to_string())),
241 249 }
@@ -245,7 +253,8 @@ pub(super) async fn unsubscribe_handler(
245 253 /// Returns `Some(message)` on success, `None` if the link is invalid/unverified.
246 254 /// The email-keyed form serves imported subscribers who have no MNW account.
247 255 async fn dispatch_unsubscribe(
248 - state: &AppState,
256 + db: &PgPool,
257 + config: &Config,
249 258 query: &UnsubscribeQuery,
250 259 ) -> Result<Option<String>> {
251 260 let (Some(action_str), Some(target), Some(sig)) = (&query.action, &query.target, &query.sig)
@@ -263,11 +272,11 @@ async fn dispatch_unsubscribe(
263 272 action,
264 273 target,
265 274 sig,
266 - &state.config.signing_secret,
275 + &config.signing_secret,
267 276 ) {
268 277 return Ok(None);
269 278 }
270 - return Ok(Some(perform_email_unsubscribe(state, email_addr, action, target).await?));
279 + return Ok(Some(perform_email_unsubscribe(db, email_addr, action, target).await?));
271 280 }
272 281
273 282 let Some(user_str) = &query.user else {
@@ -281,18 +290,18 @@ async fn dispatch_unsubscribe(
281 290 action,
282 291 target,
283 292 sig,
284 - &state.config.signing_secret,
293 + &config.signing_secret,
285 294 ) {
286 295 return Ok(None);
287 296 }
288 - Ok(Some(perform_unsubscribe(state, user_id, action, target).await?))
297 + Ok(Some(perform_unsubscribe(db, user_id, action, target).await?))
289 298 }
290 299
291 300 /// Perform an email-keyed unsubscribe (imported subscriber, no MNW account).
292 301 /// Only mailing-list unsubscribe is meaningful without an account — the
293 302 /// preference/follow actions all key on a user.
294 303 async fn perform_email_unsubscribe(
295 - state: &AppState,
304 + db: &PgPool,
296 305 email_addr: &str,
297 306 action: email::UnsubscribeAction,
298 307 target: &str,
@@ -302,7 +311,7 @@ async fn perform_email_unsubscribe(
302 311 let list_id: db::MailingListId = target
303 312 .parse()
304 313 .map_err(|_| AppError::BadRequest("Invalid mailing list ID".to_string()))?;
305 - db::mailing_lists::unsubscribe_by_email(&state.db, list_id, email_addr).await?;
314 + db::mailing_lists::unsubscribe_by_email(db, list_id, email_addr).await?;
306 315 Ok("You have been unsubscribed from this mailing list.".to_string())
307 316 }
308 317 _ => Err(AppError::BadRequest("This unsubscribe link is invalid.".to_string())),
@@ -311,7 +320,7 @@ async fn perform_email_unsubscribe(
311 320
312 321 /// Execute the unsubscribe action and return a human-readable message.
313 322 async fn perform_unsubscribe(
314 - state: &AppState,
323 + db: &PgPool,
315 324 user_id: UserId,
316 325 action: email::UnsubscribeAction,
317 326 target: &str,
@@ -324,7 +333,7 @@ async fn perform_unsubscribe(
324 333 .parse()
325 334 .map_err(|_| AppError::BadRequest("Invalid target".to_string()))?;
326 335 db::follows::unfollow(
327 - &state.db,
336 + db,
328 337 user_id,
329 338 db::FollowTargetType::User,
330 339 target_id.into(),
@@ -334,46 +343,46 @@ async fn perform_unsubscribe(
334 343 .to_string())
335 344 }
336 345 UnsubscribeAction::Release => {
337 - db::users::disable_notification(&state.db, user_id, "notify_release").await?;
346 + db::users::disable_notification(db, user_id, "notify_release").await?;
338 347 Ok("You will no longer receive emails about new releases from creators you follow."
339 348 .to_string())
340 349 }
341 350 UnsubscribeAction::Sale => {
342 - db::users::disable_notification(&state.db, user_id, "notify_sale").await?;
351 + db::users::disable_notification(db, user_id, "notify_sale").await?;
343 352 Ok("You will no longer receive email notifications when someone buys your content."
344 353 .to_string())
345 354 }
346 355 UnsubscribeAction::Follower => {
347 - db::users::disable_notification(&state.db, user_id, "notify_follower").await?;
356 + db::users::disable_notification(db, user_id, "notify_follower").await?;
348 357 Ok(
349 358 "You will no longer receive email notifications for new followers."
350 359 .to_string(),
351 360 )
352 361 }
353 362 UnsubscribeAction::Login => {
354 - db::users::disable_notification(&state.db, user_id, "login_notification_enabled")
363 + db::users::disable_notification(db, user_id, "login_notification_enabled")
355 364 .await?;
356 365 Ok("You will no longer receive email notifications for new device sign-ins."
357 366 .to_string())
358 367 }
359 368 UnsubscribeAction::Issue => {
360 - db::users::disable_notification(&state.db, user_id, "notify_issues").await?;
369 + db::users::disable_notification(db, user_id, "notify_issues").await?;
361 370 Ok("You will no longer receive email notifications for issues on your repositories."
362 371 .to_string())
363 372 }
364 373 UnsubscribeAction::Status => {
365 - db::users::disable_notification(&state.db, user_id, "notify_status").await?;
374 + db::users::disable_notification(db, user_id, "notify_status").await?;
366 375 Ok("You will no longer receive platform status notifications.".to_string())
367 376 }
368 377 UnsubscribeAction::MailingList => {
369 378 let list_id: db::MailingListId = target
370 379 .parse()
371 380 .map_err(|_| AppError::BadRequest("Invalid mailing list ID".to_string()))?;
372 - db::mailing_lists::unsubscribe(&state.db, list_id, user_id).await?;
381 + db::mailing_lists::unsubscribe(db, list_id, user_id).await?;
373 382 Ok("You have been unsubscribed from this mailing list.".to_string())
374 383 }
375 384 UnsubscribeAction::NotifyTip => {
376 - db::users::disable_notification(&state.db, user_id, "notify_tip").await?;
385 + db::users::disable_notification(db, user_id, "notify_tip").await?;
377 386 Ok("You will no longer receive email notifications for tips.".to_string())
378 387 }
379 388 }
@@ -5,17 +5,18 @@ use axum::{
5 5 response::{IntoResponse, Redirect, Response},
6 6 };
7 7 use serde::Deserialize;
8 + use sqlx::PgPool;
8 9 use tower_sessions::Session;
9 10
10 11 use crate::{
11 12 auth::{login_user, track_session, SessionUser},
13 + background::BackgroundTx,
14 + config::Config,
12 15 constants,
13 16 db::{self, UserId},
14 - email,
17 + email::{self, EmailClient},
15 18 error::{Result, ResultExt},
16 - helpers::spawn_email,
17 19 templates::*,
18 - AppState,
19 20 };
20 21
21 22 /// Query parameters for the email verification link.
@@ -29,7 +30,8 @@ pub struct VerifyEmailQuery {
29 30 /// Verify a user's email address via a signed link.
30 31 #[tracing::instrument(skip_all, name = "email_actions::verify_email_handler")]
31 32 pub(super) async fn verify_email_handler(
32 - State(state): State<AppState>,
33 + State(db): State<PgPool>,
34 + State(config): State<Config>,
33 35 _session: Session,
34 36 Query(query): Query<VerifyEmailQuery>,
35 37 ) -> Result<Response> {
@@ -87,7 +89,7 @@ pub(super) async fn verify_email_handler(
87 89 // Get user. A missing user yields the same generic response as a bad
88 90 // signature (the signature is keyed on the user's email, so a genuine link
89 91 // can only exist for a real user anyway).
90 - let user = match db::users::get_user_by_id(&state.db, user_id).await {
92 + let user = match db::users::get_user_by_id(&db, user_id).await {
91 93 Ok(Some(u)) => u,
92 94 _ => return Ok(invalid_link()),
93 95 };
@@ -99,7 +101,7 @@ pub(super) async fn verify_email_handler(
99 101 expires,
100 102 &user.email,
101 103 &sig,
102 - &state.config.signing_secret,
104 + &config.signing_secret,
103 105 ) {
104 106 return Ok(invalid_link());
105 107 }
@@ -110,10 +112,10 @@ pub(super) async fn verify_email_handler(
110 112 }
111 113
112 114 // Mark email as verified
113 - db::users::verify_user_email(&state.db, user_id).await?;
115 + db::users::verify_user_email(&db, user_id).await?;
114 116
115 117 // Auto-attach any guest purchases made with this email before signup
116 - match db::transactions::attach_guest_purchases_by_email(&state.db, &user.email, user_id).await {
118 + match db::transactions::attach_guest_purchases_by_email(&db, &user.email, user_id).await {
117 119 Ok(0) => {}
118 120 Ok(n) => tracing::info!(user_id = %user_id, count = n, "auto-attached guest purchases on email verification"),
119 121 Err(e) => tracing::warn!(user_id = %user_id, error = ?e, "failed to attach guest purchases"),
@@ -141,7 +143,10 @@ pub struct LoginLinkQuery {
141 143 /// Authenticate a user via a one-time login link token.
142 144 #[tracing::instrument(skip_all, name = "email_actions::login_link_handler")]
143 145 pub(super) async fn login_link_handler(
144 - State(state): State<AppState>,
146 + State(db): State<PgPool>,
147 + State(config): State<Config>,
148 + State(email): State<EmailClient>,
149 + State(bg): State<BackgroundTx>,
145 150 headers: axum::http::header::HeaderMap,
146 151 session: Session,
147 152 Query(query): Query<LoginLinkQuery>,
@@ -172,7 +177,7 @@ pub(super) async fn login_link_handler(
172 177 };
173 178
174 179 // Atomically consume the token (marks it used and returns it in one query)
175 - let login_token = match db::auth::consume_login_token(&state.db, &token_hash).await? {
180 + let login_token = match db::auth::consume_login_token(&db, &token_hash).await? {
176 181 Some(t) => t,
177 182 None => {
178 183 return Ok(error_page(
@@ -182,13 +187,13 @@ pub(super) async fn login_link_handler(
182 187 };
183 188
184 189 // Get the user
185 - let user = match db::users::get_user_by_id(&state.db, login_token.user_id).await? {
190 + let user = match db::users::get_user_by_id(&db, login_token.user_id).await? {
186 191 Some(u) => u,
187 192 None => return Ok(error_page("User not found")),
188 193 };
189 194
190 195 // Reset failed login attempts and unlock account
191 - db::auth::reset_failed_login(&state.db, user.id).await?;
196 + db::auth::reset_failed_login(&db, user.id).await?;
192 197
193 198 // If user has TOTP 2FA enabled, redirect to 2FA verification instead of creating session
194 199 if user.totp_enabled {
@@ -222,7 +227,7 @@ pub(super) async fn login_link_handler(
222 227 .map(|s| s.chars().take(crate::constants::USER_AGENT_MAX_LENGTH).collect::<String>());
223 228 let ip = crate::helpers::extract_client_ip(&headers);
224 229 let tracking_id = db::sessions::create_pending_2fa_session(
225 - &state.db, user.id, ua.as_deref(), ip.as_deref(),
230 + &db, user.id, ua.as_deref(), ip.as_deref(),
226 231 ).await?;
227 232 session.insert("pending_2fa_tracking_id", tracking_id).await.context("session insert")?;
228 233
@@ -237,15 +242,15 @@ pub(super) async fn login_link_handler(
237 242 let notify_enabled = user.login_notification_enabled;
238 243
239 244 // Create session
240 - let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await;
245 + let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
241 246
242 247 login_user(&session, session_user).await?;
243 - track_session(&session, &state.db, user_id, &headers).await?;
248 + track_session(&session, &db, user_id, &headers).await?;
244 249 tracing::info!(user_id = %user_id, event = "login_link_used", "One-time login link used");
245 250
246 251 // Send new-device login notification (fire-and-forget)
247 252 if notify_enabled {
248 - let session_count = db::sessions::count_user_sessions(&state.db, user_id)
253 + let session_count = db::sessions::count_user_sessions(&db, user_id)
249 254 .await
250 255 .unwrap_or(0);
251 256 if session_count > 1 {
@@ -264,20 +269,26 @@ pub(super) async fn login_link_handler(
264 269 // presented to a human as a security signal.
265 270 let ip = crate::helpers::extract_client_ip(&headers);
266 271 let unsub_url = email::generate_unsubscribe_url(
267 - &state.config.host_url,
272 + &config.host_url,
268 273 user_id,
269 274 email::UnsubscribeAction::Login,
270 275 &user_id.to_string(),
271 - &state.config.signing_secret,
276 + &config.signing_secret,
272 277 );
273 - spawn_email!(state, "login notification", |email| {
274 - email.send_new_login_notification(
275 - &notify_email,
276 - notify_name.as_deref(),
277 - user_agent.as_deref(),
278 - ip.as_deref(),
279 - Some(&unsub_url),
280 - )
278 + let email = email.clone();
279 + bg.spawn("login notification", async move {
280 + if let Err(e) = email
281 + .send_new_login_notification(
282 + &notify_email,
283 + notify_name.as_deref(),
284 + user_agent.as_deref(),
285 + ip.as_deref(),
286 + Some(&unsub_url),
287 + )
288 + .await
289 + {
290 + tracing::error!(error = ?e, "failed to send login notification");
291 + }
281 292 });
282 293 }
283 294 }
@@ -7,15 +7,18 @@ use axum::{
7 7 Form,
8 8 };
9 9 use serde::Deserialize;
10 + use sqlx::PgPool;
10 11 use tower_sessions::Session;
11 12
12 13 use crate::{
13 14 auth::hash_password_async,
15 + config::Config,
14 16 db::{self},
17 + email::EmailClient,
15 18 error::Result,
16 19 helpers::{get_csrf_token, is_htmx_request},
17 20 templates::*,
18 - AppState,
21 + AppCaches,
19 22 };
20 23
21 24 /// Render the forgot-password form page.
@@ -35,7 +38,9 @@ pub struct ForgotPasswordForm {
35 38 /// Handle forgot-password submission and send a reset link email.
36 39 #[tracing::instrument(skip_all, name = "email_actions::forgot_password_handler")]
37 40 pub(super) async fn forgot_password_handler(
38 - State(state): State<AppState>,
41 + State(db): State<PgPool>,
42 + State(config): State<Config>,
43 + State(email): State<EmailClient>,
39 44 headers: HeaderMap,
40 45 Form(form): Form<ForgotPasswordForm>,
41 46 ) -> Result<Response> {
@@ -48,11 +53,11 @@ pub(super) async fn forgot_password_handler(
48 53 );
49 54
50 55 // Look up user by email
51 - let Ok(email) = db::Email::new(&form.email) else {
56 + let Ok(parsed_email) = db::Email::new(&form.email) else {
52 57 // Same generic response as "email exists but no account" to avoid leaking validity.
53 58 return Ok(success_alert.into_response());
54 59 };
55 - let user = match db::users::get_user_by_email(&state.db, &email).await? {
60 + let user = match db::users::get_user_by_email(&db, &parsed_email).await? {
56 61 Some(u) => u,
57 62 None => {
58 63 // Don't reveal that email doesn't exist
@@ -74,7 +79,7 @@ pub(super) async fn forgot_password_handler(
74 79 let expires_at = chrono::Utc::now()
75 80 + chrono::Duration::seconds(crate::constants::PASSWORD_RESET_EXPIRY_SECS);
76 81 if let Err(e) =
77 - db::auth::create_password_reset_token(&state.db, user.id, &token_hash, expires_at).await
82 + db::auth::create_password_reset_token(&db, user.id, &token_hash, expires_at).await
78 83 {
79 84 tracing::error!(error = ?e, "failed to persist password reset token");
80 85 // Still return the generic success response to avoid enumeration.
@@ -83,11 +88,10 @@ pub(super) async fn forgot_password_handler(
83 88 }
84 89 return Ok(Redirect::to("/login").into_response());
85 90 }
86 - let reset_url = crate::email::generate_reset_link_url(&state.config.host_url, &token);
91 + let reset_url = crate::email::generate_reset_link_url(&config.host_url, &token);
87 92
88 93 // Send email
89 - if let Err(e) = state
90 - .email
94 + if let Err(e) = email
91 95 .send_password_reset(&user.email, user.display_name.as_deref(), &reset_url)
92 96 .await
93 97 {
@@ -116,7 +120,7 @@ pub struct ResetPasswordQuery {
116 120 /// so a prefetch (link scanner, browser preview) can't burn the user's link.
117 121 #[tracing::instrument(skip_all, name = "email_actions::reset_password_page")]
118 122 pub(super) async fn reset_password_page(
119 - State(state): State<AppState>,
123 + State(db): State<PgPool>,
120 124 session: Session,
121 125 Query(query): Query<ResetPasswordQuery>,
122 126 ) -> impl IntoResponse {
@@ -135,7 +139,7 @@ pub(super) async fn reset_password_page(
135 139
136 140 let token_hash = crate::email::hash_opaque_token(&token);
137 141 let valid = matches!(
138 - db::auth::peek_password_reset_token(&state.db, &token_hash).await,
142 + db::auth::peek_password_reset_token(&db, &token_hash).await,
139 143 Ok(Some(_))
140 144 );
141 145
@@ -158,7 +162,8 @@ pub struct ResetPasswordForm {
158 162 /// Verify the reset signature and update the user's password.
159 163 #[tracing::instrument(skip_all, name = "email_actions::reset_password_handler")]
160 164 pub(super) async fn reset_password_handler(
161 - State(state): State<AppState>,
165 + State(db): State<PgPool>,
166 + State(caches): State<AppCaches>,
162 167 session: Session,
163 168 headers: HeaderMap,
164 169 Form(form): Form<ResetPasswordForm>,
@@ -209,7 +214,7 @@ pub(super) async fn reset_password_handler(
209 214 // cheap form validations so a mistyped confirmation doesn't burn the link.
210 215 let token_hash = crate::email::hash_opaque_token(&form.token);
211 216 let Some(user_id) =
212 - db::auth::consume_password_reset_token(&state.db, &token_hash).await?
217 + db::auth::consume_password_reset_token(&db, &token_hash).await?
213 218 else {
214 219 // Token is gone — re-rendering the form would be a dead end, so show the
215 220 // expired/invalid state with a path to request a fresh link.
@@ -246,16 +251,16 @@ pub(super) async fn reset_password_handler(
246 251
247 252 // Hash new password and update
248 253 let new_password_hash = hash_password_async(form.password.clone()).await?;
249 - db::users::update_user_password(&state.db, user_id, &new_password_hash).await?;
254 + db::users::update_user_password(&db, user_id, &new_password_hash).await?;
250 255
251 256 // Kill any other outstanding reset links for this user (e.g. a double
252 257 // request): completing one reset invalidates them all.
253 - db::auth::invalidate_password_reset_tokens(&state.db, user_id).await?;
258 + db::auth::invalidate_password_reset_tokens(&db, user_id).await?;
254 259
255 260 // Invalidate all sessions so stolen sessions can't survive a password reset
256 - let revoked = db::sessions::delete_all_sessions_for_user(&state.db, user_id).await?;
261 + let revoked = db::sessions::delete_all_sessions_for_user(&db, user_id).await?;
257 262 for sid in &revoked {
258 - state.caches.session_cache.remove(sid);
263 + caches.session_cache.remove(sid);
259 264 }
260 265 if !revoked.is_empty() {
261 266 tracing::info!(user_id = %user_id, revoked = revoked.len(), event = "password_reset_revoke_sessions", "Revoked sessions on password reset");
@@ -6,8 +6,10 @@ use axum::{
6 6 routing::get,
7 7 };
8 8 use serde::Deserialize;
9 + use sqlx::PgPool;
9 10
10 11 use crate::{
12 + config::Config,
11 13 constants,
12 14 csrf::CsrfRouter,
13 15 db::{self, Slug, UserId, Username},
@@ -34,11 +36,12 @@ pub fn feed_routes() -> CsrfRouter<AppState> {
34 36 /// GET /u/{username}/rss
35 37 #[tracing::instrument(skip_all, name = "feeds::user_rss_feed")]
36 38 async fn user_rss_feed(
37 - State(state): State<AppState>,
39 + State(db): State<PgPool>,
40 + State(config): State<Config>,
38 41 Path(username): Path<String>,
39 42 ) -> Result<Response> {
40 43 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
41 - let db_user = db::users::get_user_by_username(&state.db, &username)
44 + let db_user = db::users::get_user_by_username(&db, &username)
42 45 .await?
43 46 .ok_or(AppError::NotFound)?;
44 47
@@ -47,13 +50,13 @@ async fn user_rss_feed(
47 50 }
48 51
49 52 // Single joined query instead of O(projects) loop
50 - let db_items = db::items::get_public_items_by_user(&state.db, db_user.id).await?;
53 + let db_items = db::items::get_public_items_by_user(&db, db_user.id).await?;
51 54
52 55 let feed_items: Vec<FeedItem> = db_items
53 56 .into_iter()
54 57 .map(|item| FeedItem {
55 58 title: item.title,
56 - link: format!("{}/i/{}", state.config.host_url, item.id),
59 + link: format!("{}/i/{}", config.host_url, item.id),
57 60 description: item.description.unwrap_or_default(),
58 61 pub_date: item.created_at,
59 62 guid: item.id.to_string(),
@@ -71,7 +74,7 @@ async fn user_rss_feed(
71 74 &db_user.username,
72 75 bio,
73 76 &feed_items,
74 - &state.config.host_url,
77 + &config.host_url,
75 78 );
76 79
77 80 Ok((
@@ -89,15 +92,16 @@ async fn user_rss_feed(
89 92 /// GET /p/{slug}/rss
90 93 #[tracing::instrument(skip_all, name = "feeds::project_rss_feed")]
91 94 async fn project_rss_feed(
92 - State(state): State<AppState>,
95 + State(db): State<PgPool>,
96 + State(config): State<Config>,
93 97 Path(slug): Path<String>,
94 98 ) -> Result<Response> {
95 99 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
96 - let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
100 + let db_project = db::projects::get_public_project_by_slug(&db, &slug)
97 101 .await?
98 102 .ok_or(AppError::NotFound)?;
99 103
100 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
104 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
101 105 .await?
102 106 .ok_or(AppError::NotFound)?;
103 107
@@ -105,13 +109,13 @@ async fn project_rss_feed(
105 109 return Err(AppError::NotFound);
106 110 }
107 111
108 - let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?;
112 + let db_items = db::items::get_public_items_by_project(&db, db_project.id).await?;
109 113
110 114 let feed_items: Vec<FeedItem> = db_items
111 115 .into_iter()
112 116 .map(|item| FeedItem {
113 117 title: item.title,
114 - link: format!("{}/i/{}", state.config.host_url, item.id),
118 + link: format!("{}/i/{}", config.host_url, item.id),
115 119 description: item.description.unwrap_or_default(),
116 120 pub_date: item.created_at,
117 121 guid: item.id.to_string(),
@@ -124,7 +128,7 @@ async fn project_rss_feed(
124 128 db_project.description.as_deref().unwrap_or(""),
125 129 &db_user.username,
126 130 &feed_items,
127 - &state.config.host_url,
131 + &config.host_url,
128 132 );
129 133
130 134 Ok((
@@ -140,15 +144,16 @@ async fn project_rss_feed(
140 144 /// RSS feed for a project's blog posts.
141 145 #[tracing::instrument(skip_all, name = "feeds::project_blog_rss")]
142 146 async fn project_blog_rss(
143 - State(state): State<AppState>,
147 + State(db): State<PgPool>,
148 + State(config): State<Config>,
144 149 Path(slug): Path<String>,
145 150 ) -> Result<Response> {
146 151 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
147 - let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
152 + let db_project = db::projects::get_public_project_by_slug(&db, &slug)
148 153 .await?
149 154 .ok_or(AppError::NotFound)?;
150 155
151 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
156 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
152 157 .await?
153 158 .ok_or(AppError::NotFound)?;
154 159
@@ -156,13 +161,13 @@ async fn project_blog_rss(
156 161 return Err(AppError::NotFound);
157 162 }
158 163
159 - let db_posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?;
164 + let db_posts = db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
160 165
161 166 let feed_items: Vec<FeedItem> = db_posts
162 167 .into_iter()
163 168 .map(|post| FeedItem {
164 169 title: post.title,
165 - link: format!("{}/p/{}/blog/{}", state.config.host_url, db_project.slug, post.slug),
170 + link: format!("{}/p/{}/blog/{}", config.host_url, db_project.slug, post.slug),
166 171 description: post.body_markdown.chars().take(300).collect::<String>(),
167 172 pub_date: post.published_at.unwrap_or(post.created_at),
168 173 guid: post.id.to_string(),
@@ -175,7 +180,7 @@ async fn project_blog_rss(
175 180 db_project.description.as_deref().unwrap_or(""),
176 181 &db_user.username,
177 182 &feed_items,
178 - &state.config.host_url,
183 + &config.host_url,
179 184 );
180 185
181 186 Ok((
@@ -194,15 +199,18 @@ async fn project_blog_rss(
194 199 /// rendering, so subscribers can follow the canonical `/changelog/feed.xml`
195 200 /// path instead of discovering the underlying `/p/changelog/blog/feed.xml`.
196 201 #[tracing::instrument(skip_all, name = "feeds::changelog_rss")]
197 - async fn changelog_rss(State(state): State<AppState>) -> Result<Response> {
202 + async fn changelog_rss(
203 + State(db): State<PgPool>,
204 + State(config): State<Config>,
205 + ) -> Result<Response> {
198 206 // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a
199 207 // compile-time constant (`&'static str`), not user input.
200 208 let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
201 - let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
209 + let db_project = db::projects::get_public_project_by_slug(&db, &slug)
202 210 .await?
203 211 .ok_or(AppError::NotFound)?;
204 212
205 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
213 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
206 214 .await?
207 215 .ok_or(AppError::NotFound)?;
208 216
@@ -211,13 +219,13 @@ async fn changelog_rss(State(state): State<AppState>) -> Result<Response> {
211 219 }
212 220
213 221 let db_posts =
214 - db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?;
222 + db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
215 223
216 224 let feed_items: Vec<FeedItem> = db_posts
217 225 .into_iter()
218 226 .map(|post| FeedItem {
219 227 title: post.title,
220 - link: format!("{}/changelog/{}", state.config.host_url, post.slug),
228 + link: format!("{}/changelog/{}", config.host_url, post.slug),
221 229 description: post.body_markdown.chars().take(300).collect::<String>(),
222 230 pub_date: post.published_at.unwrap_or(post.created_at),
223 231 guid: post.id.to_string(),
@@ -230,7 +238,7 @@ async fn changelog_rss(State(state): State<AppState>) -> Result<Response> {
230 238 db_project.description.as_deref().unwrap_or(""),
231 239 &db_user.username,
232 240 &feed_items,
233 - &state.config.host_url,
241 + &config.host_url,
234 242 );
235 243
236 244 Ok((
@@ -262,18 +270,19 @@ struct FeedQuery {
262 270 /// can fetch the feed without cookies or headers.
263 271 #[tracing::instrument(skip_all, name = "feeds::personal_feed")]
264 272 async fn personal_feed(
265 - State(state): State<AppState>,
273 + State(db): State<PgPool>,
274 + State(config): State<Config>,
266 275 Path(user_id): Path<UserId>,
267 276 Query(query): Query<FeedQuery>,
268 277 ) -> Result<Response> {
269 278 // Verify HMAC signature (cheap, no DB) before touching the database.
270 - if !helpers::verify_feed_signature(user_id, query.v, &query.sig, &state.config.signing_secret)
279 + if !helpers::verify_feed_signature(user_id, query.v, &query.sig, &config.signing_secret)
271 280 {
272 281 return Err(AppError::Forbidden);
273 282 }
274 283
275 284 // Verify the user exists
276 - let db_user = db::users::get_user_by_id(&state.db, user_id)
285 + let db_user = db::users::get_user_by_id(&db, user_id)
277 286 .await?
278 287 .ok_or(AppError::NotFound)?;
279 288
@@ -291,13 +300,13 @@ async fn personal_feed(
291 300 }
292 301
293 302 // Get items from followed users and projects
294 - let db_items = db::follows::get_followed_items(&state.db, user_id).await?;
303 + let db_items = db::follows::get_followed_items(&db, user_id).await?;
295 304
296 305 let feed_items: Vec<FeedItem> = db_items
297 306 .into_iter()
298 307 .map(|item| FeedItem {
299 308 title: item.title,
300 - link: format!("{}/i/{}", state.config.host_url, item.id),
309 + link: format!("{}/i/{}", config.host_url, item.id),
301 310 description: item.description.unwrap_or_default(),
302 311 pub_date: item.created_at,
303 312 guid: item.id.to_string(),
@@ -312,7 +321,7 @@ async fn personal_feed(
312 321
313 322 let xml = rss::render_feed_custom(
314 323 &title,
315 - &format!("{}/feed/{}", state.config.host_url, user_id),
324 + &format!("{}/feed/{}", config.host_url, user_id),
316 325 "New content from creators and projects you follow",
317 326 &feed_items,
318 327 );
@@ -7,6 +7,7 @@ use axum::{
7 7 routing::get,
8 8 };
9 9 use rand::Rng;
10 + use sqlx::PgPool;
10 11 use tower_governor::GovernorLayer;
11 12 use tower_sessions::Session;
12 13
@@ -55,7 +56,7 @@ pub(super) async fn sandbox_page(session: Session) -> Result<impl IntoResponse>
55 56 /// POST /sandbox: create an ephemeral sandbox account and redirect to dashboard.
56 57 #[tracing::instrument(skip_all, name = "sandbox::create")]
57 58 pub(super) async fn create_sandbox(
58 - State(state): State<AppState>,
59 + State(db): State<PgPool>,
59 60 session: Session,
60 61 headers: HeaderMap,
61 62 ) -> Result<Response> {
@@ -68,7 +69,7 @@ pub(super) async fn create_sandbox(
68 69 // Uses a single connection for lock + count + unlock to avoid the pool
69 70 // connection mismatch bug with session-level advisory locks.
70 71 let lock_key = crate::helpers::ip_advisory_lock_key(&ip);
71 - let active = db::check_sandbox_cap(&state.db, lock_key, &ip).await?;
72 + let active = db::check_sandbox_cap(&db, lock_key, &ip).await?;
72 73 if active >= constants::SANDBOX_MAX_PER_IP {
73 74 return Err(AppError::BadRequest(
74 75 "Too many active sandboxes from this address".to_string(),
@@ -89,7 +90,7 @@ pub(super) async fn create_sandbox(
89 90
90 91 // Create the sandbox user
91 92 let user = db::users::create_sandbox_user(
92 - &state.db,
93 + &db,
93 94 &username,
94 95 &email,
95 96 &password_hash,
@@ -113,7 +114,7 @@ pub(super) async fn create_sandbox(
113 114 };
114 115
115 116 auth::login_user(&session, session_user).await?;
116 - auth::track_session(&session, &state.db, user.id, &headers).await?;
117 + auth::track_session(&session, &db, user.id, &headers).await?;
117 118
118 119 // Session ends when the browser closes; the scheduler handles DB cleanup
119 120 session.set_expiry(Some(tower_sessions::Expiry::OnSessionEnd));
@@ -121,18 +122,18 @@ pub(super) async fn create_sandbox(
121 122 tracing::info!(user_id = %user.id, event = "sandbox_created", "Sandbox account created");
122 123
123 124 // Pre-seed a demo project so the dashboard isn't empty
124 - seed_demo_content(&state, user.id).await;
125 + seed_demo_content(&db, user.id).await;
125 126
126 127 Ok(Redirect::to("/dashboard").into_response())
127 128 }
128 129
129 130 /// Create a demo project with a couple of items so the sandbox feels populated.
130 - async fn seed_demo_content(state: &AppState, user_id: db::UserId) {
131 + async fn seed_demo_content(db: &PgPool, user_id: db::UserId) {
131 132 let slug = db::Slug::from_trusted("my-demo-project".to_string());
132 133 let features = vec!["audio".to_string(), "downloads".to_string()];
133 134
134 135 let project = match db::projects::create_project(
135 - &state.db,
136 + db,
136 137 user_id,
137 138 &slug,
138 139 "My Demo Project",
@@ -154,7 +155,7 @@ async fn seed_demo_content(state: &AppState, user_id: db::UserId) {
154 155 ("Demo Plugin", db::PriceCents::from_db(1500), db::ItemType::Digital),
155 156 ] {
156 157 if let Err(e) = db::items::create_item(
157 - &state.db,
158 + db,
158 159 project.id,
159 160 title,
160 161 Some("Edit this item to see how content management works."),