max / makenotwork
15 files changed,
+331 insertions,
-262 deletions
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use tower_sessions::Session; | |
| 5 | 5 | ||
| 6 | - | use crate::AppState; | |
| 6 | + | use crate::config::Config; | |
| 7 | 7 | ||
| 8 | 8 | /// Get or create a CSRF token for the session, returning `None` on failure. | |
| 9 | 9 | /// | |
| @@ -15,7 +15,8 @@ pub async fn get_csrf_token(session: &Session) -> Option<String> { | |||
| 15 | 15 | /// Fetch MT discussion thread stats (URL + post count) for a linked thread. | |
| 16 | 16 | /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread. | |
| 17 | 17 | pub async fn fetch_discussion_info( | |
| 18 | - | state: &AppState, | |
| 18 | + | integrations: &crate::Integrations, | |
| 19 | + | config: &Config, | |
| 19 | 20 | mt_thread_id: Option<crate::db::MtThreadId>, | |
| 20 | 21 | project_slug: &str, | |
| 21 | 22 | category_slug: &str, | |
| @@ -23,10 +24,10 @@ pub async fn fetch_discussion_info( | |||
| 23 | 24 | let Some(thread_id) = mt_thread_id else { | |
| 24 | 25 | return (None, None); | |
| 25 | 26 | }; | |
| 26 | - | let Some(ref mt) = state.mt_client else { | |
| 27 | + | let Some(ref mt) = integrations.mt_client else { | |
| 27 | 28 | return (None, None); | |
| 28 | 29 | }; | |
| 29 | - | let Some(ref mt_base_url) = state.config.integrations.mt_base_url else { | |
| 30 | + | let Some(ref mt_base_url) = config.integrations.mt_base_url else { | |
| 30 | 31 | return (None, None); | |
| 31 | 32 | }; | |
| 32 | 33 |
| @@ -10,6 +10,7 @@ use tower_sessions::Session; | |||
| 10 | 10 | ||
| 11 | 11 | use crate::{ | |
| 12 | 12 | auth::MaybeUserUnverified, | |
| 13 | + | config::Config, | |
| 13 | 14 | constants, | |
| 14 | 15 | csrf::CsrfRouter, | |
| 15 | 16 | db::{self, Slug}, | |
| @@ -17,7 +18,7 @@ use crate::{ | |||
| 17 | 18 | helpers::{fetch_discussion_info, get_csrf_token, get_initials}, | |
| 18 | 19 | templates::*, | |
| 19 | 20 | types::*, | |
| 20 | - | AppState, | |
| 21 | + | AppState, Integrations, | |
| 21 | 22 | }; | |
| 22 | 23 | ||
| 23 | 24 | /// Register blog page routes. All GET-only; returned as a `CsrfRouter` so the | |
| @@ -68,7 +69,9 @@ async fn project_blog_page( | |||
| 68 | 69 | /// Public blog post reader page. | |
| 69 | 70 | #[tracing::instrument(skip_all, name = "blog_pages::blog_post_page")] | |
| 70 | 71 | async fn blog_post_page( | |
| 71 | - | State(state): State<AppState>, | |
| 72 | + | State(db): State<PgPool>, | |
| 73 | + | State(config): State<Config>, | |
| 74 | + | State(integrations): State<Integrations>, | |
| 72 | 75 | session: Session, | |
| 73 | 76 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 74 | 77 | Path((slug, post_slug)): Path<(String, String)>, | |
| @@ -77,15 +80,15 @@ async fn blog_post_page( | |||
| 77 | 80 | ||
| 78 | 81 | let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; | |
| 79 | 82 | let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?; | |
| 80 | - | let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) | |
| 83 | + | let db_project = db::projects::get_public_project_by_slug(&db, &slug) | |
| 81 | 84 | .await? | |
| 82 | 85 | .ok_or(AppError::NotFound)?; | |
| 83 | 86 | ||
| 84 | - | let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) | |
| 87 | + | let db_user = db::users::get_user_by_id(&db, db_project.user_id) | |
| 85 | 88 | .await? | |
| 86 | 89 | .ok_or(AppError::NotFound)?; | |
| 87 | 90 | ||
| 88 | - | let db_post = db::blog_posts::get_blog_post_by_slug(&state.db, db_project.id, &post_slug) | |
| 91 | + | let db_post = db::blog_posts::get_blog_post_by_slug(&db, db_project.id, &post_slug) | |
| 89 | 92 | .await? | |
| 90 | 93 | .ok_or(AppError::NotFound)?; | |
| 91 | 94 | ||
| @@ -102,7 +105,7 @@ async fn blog_post_page( | |||
| 102 | 105 | ||
| 103 | 106 | let project_slug_str = db_project.slug.to_string(); | |
| 104 | 107 | let (discussion_url, discussion_count) = | |
| 105 | - | fetch_discussion_info(&state, db_post.mt_thread_id, &project_slug_str, "blog").await; | |
| 108 | + | fetch_discussion_info(&integrations, &config, db_post.mt_thread_id, &project_slug_str, "blog").await; | |
| 106 | 109 | ||
| 107 | 110 | Ok(BlogPostTemplate { | |
| 108 | 111 | csrf_token, | |
| @@ -120,7 +123,7 @@ async fn blog_post_page( | |||
| 120 | 123 | project_title_json, | |
| 121 | 124 | project_slug: project_slug_str, | |
| 122 | 125 | post_slug: post_slug.to_string(), | |
| 123 | - | host_url: state.config.host_url.clone(), | |
| 126 | + | host_url: config.host_url.clone(), | |
| 124 | 127 | project_cover_image_url: db_project.cover_image_url, | |
| 125 | 128 | discussion_url, | |
| 126 | 129 | discussion_count, | |
| @@ -170,7 +173,9 @@ async fn changelog_index( | |||
| 170 | 173 | /// Platform changelog post (alias for a "changelog" project blog post). | |
| 171 | 174 | #[tracing::instrument(skip_all, name = "blog_pages::changelog_post")] | |
| 172 | 175 | async fn changelog_post( | |
| 173 | - | State(state): State<AppState>, | |
| 176 | + | State(db): State<PgPool>, | |
| 177 | + | State(config): State<Config>, | |
| 178 | + | State(integrations): State<Integrations>, | |
| 174 | 179 | session: Session, | |
| 175 | 180 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 176 | 181 | Path(post_slug): Path<String>, | |
| @@ -183,15 +188,15 @@ async fn changelog_post( | |||
| 183 | 188 | // justification. | |
| 184 | 189 | let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned()); | |
| 185 | 190 | let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?; | |
| 186 | - | let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) | |
| 191 | + | let db_project = db::projects::get_public_project_by_slug(&db, &slug) | |
| 187 | 192 | .await? | |
| 188 | 193 | .ok_or(AppError::NotFound)?; | |
| 189 | 194 | ||
| 190 | - | let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) | |
| 195 | + | let db_user = db::users::get_user_by_id(&db, db_project.user_id) | |
| 191 | 196 | .await? | |
| 192 | 197 | .ok_or(AppError::NotFound)?; | |
| 193 | 198 | ||
| 194 | - | let db_post = db::blog_posts::get_blog_post_by_slug(&state.db, db_project.id, &post_slug) | |
| 199 | + | let db_post = db::blog_posts::get_blog_post_by_slug(&db, db_project.id, &post_slug) | |
| 195 | 200 | .await? | |
| 196 | 201 | .ok_or(AppError::NotFound)?; | |
| 197 | 202 | ||
| @@ -211,7 +216,7 @@ async fn changelog_post( | |||
| 211 | 216 | ||
| 212 | 217 | let project_slug_str = db_project.slug.to_string(); | |
| 213 | 218 | let (discussion_url, discussion_count) = | |
| 214 | - | fetch_discussion_info(&state, db_post.mt_thread_id, &project_slug_str, "blog").await; | |
| 219 | + | fetch_discussion_info(&integrations, &config, db_post.mt_thread_id, &project_slug_str, "blog").await; | |
| 215 | 220 | ||
| 216 | 221 | Ok(BlogPostTemplate { | |
| 217 | 222 | csrf_token, | |
| @@ -230,7 +235,7 @@ async fn changelog_post( | |||
| 230 | 235 | project_title_json, | |
| 231 | 236 | project_slug: project_slug_str, | |
| 232 | 237 | post_slug: post_slug.to_string(), | |
| 233 | - | host_url: state.config.host_url.clone(), | |
| 238 | + | host_url: config.host_url.clone(), | |
| 234 | 239 | project_cover_image_url: db_project.cover_image_url, | |
| 235 | 240 | discussion_url, | |
| 236 | 241 | discussion_count, |
| @@ -1,9 +1,10 @@ | |||
| 1 | 1 | //! Public item detail page handler. | |
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | - | extract::{Path, State}, | |
| 4 | + | extract::{FromRef, Path, State}, | |
| 5 | 5 | response::{IntoResponse, Response}, | |
| 6 | 6 | }; | |
| 7 | + | use sqlx::PgPool; | |
| 7 | 8 | use tower_sessions::Session; | |
| 8 | 9 | ||
| 9 | 10 | use crate::{ | |
| @@ -14,7 +15,7 @@ use crate::{ | |||
| 14 | 15 | pricing, | |
| 15 | 16 | templates::*, | |
| 16 | 17 | types::*, | |
| 17 | - | AppState, | |
| 18 | + | AppState, AppStorage, | |
| 18 | 19 | }; | |
| 19 | 20 | ||
| 20 | 21 | /// Render a public item detail page (text reader, audio player, or download). | |
| @@ -66,6 +67,10 @@ pub(crate) async fn render_item_page( | |||
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work"); | |
| 70 | + | // render_item_page keeps `&AppState` (it is shared with the custom-domain | |
| 71 | + | // fallback in `custom_domain.rs`), so project the narrowed integration slice | |
| 72 | + | // here for the now-slimmed `fetch_discussion_info`. | |
| 73 | + | let integrations = crate::Integrations::from_ref(state); | |
| 69 | 74 | // Store page never renders the full article body — that lives on /l/{id}. | |
| 70 | 75 | // Compute a short plain-text excerpt from the raw markdown for the deck. | |
| 71 | 76 | let (excerpt, reading_time) = match db_item.content() { | |
| @@ -139,7 +144,7 @@ pub(crate) async fn render_item_page( | |||
| 139 | 144 | get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); | |
| 140 | 145 | let project_slug_str = db_project.slug.to_string(); | |
| 141 | 146 | let (discussion_url, discussion_count) = | |
| 142 | - | fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 147 | + | fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 143 | 148 | return Ok(TextReaderTemplate { | |
| 144 | 149 | csrf_token: csrf_token.clone(), | |
| 145 | 150 | session_user: maybe_user, | |
| @@ -166,7 +171,7 @@ pub(crate) async fn render_item_page( | |||
| 166 | 171 | get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); | |
| 167 | 172 | let project_slug_str = db_project.slug.to_string(); | |
| 168 | 173 | let (discussion_url, discussion_count) = | |
| 169 | - | fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 174 | + | fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 170 | 175 | return Ok(AudioPlayerTemplate { | |
| 171 | 176 | csrf_token: csrf_token.clone(), | |
| 172 | 177 | session_user: maybe_user, | |
| @@ -191,7 +196,7 @@ pub(crate) async fn render_item_page( | |||
| 191 | 196 | get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); | |
| 192 | 197 | let project_slug_str = db_project.slug.to_string(); | |
| 193 | 198 | let (discussion_url, discussion_count) = | |
| 194 | - | fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 199 | + | fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 195 | 200 | return Ok(VideoPlayerTemplate { | |
| 196 | 201 | csrf_token: csrf_token.clone(), | |
| 197 | 202 | session_user: maybe_user, | |
| @@ -213,7 +218,7 @@ pub(crate) async fn render_item_page( | |||
| 213 | 218 | ||
| 214 | 219 | let project_slug_str = db_project.slug.to_string(); | |
| 215 | 220 | let (discussion_url, discussion_count) = | |
| 216 | - | fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 221 | + | fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 217 | 222 | ||
| 218 | 223 | // Convert bundle child items to view models | |
| 219 | 224 | let bundle_item_views: Vec<Item> = bundle_child_items | |
| @@ -309,13 +314,14 @@ struct PlayerSegment { | |||
| 309 | 314 | ||
| 310 | 315 | /// Build the segments JSON for the media player. Returns "null" if no insertions. | |
| 311 | 316 | pub(super) async fn build_segments_json( | |
| 312 | - | state: &AppState, | |
| 317 | + | db: &PgPool, | |
| 318 | + | storage: &AppStorage, | |
| 313 | 319 | item_id: ItemId, | |
| 314 | 320 | media_url: &Option<String>, | |
| 315 | 321 | db_item: &db::DbItem, | |
| 316 | 322 | ) -> String { | |
| 317 | 323 | let placements = | |
| 318 | - | match db::content_insertions::list_playable_placements_for_item(&state.db, item_id).await { | |
| 324 | + | match db::content_insertions::list_playable_placements_for_item(db, item_id).await { | |
| 319 | 325 | Ok(p) => p, | |
| 320 | 326 | Err(_) => return "null".to_string(), | |
| 321 | 327 | }; | |
| @@ -324,7 +330,7 @@ pub(super) async fn build_segments_json( | |||
| 324 | 330 | return "null".to_string(); | |
| 325 | 331 | } | |
| 326 | 332 | ||
| 327 | - | let s3 = match &state.storage.s3 { | |
| 333 | + | let s3 = match &storage.s3 { | |
| 328 | 334 | Some(s3) => s3, | |
| 329 | 335 | None => return "null".to_string(), | |
| 330 | 336 | }; |
| @@ -7,10 +7,12 @@ use axum::{ | |||
| 7 | 7 | extract::{Path, State}, | |
| 8 | 8 | response::{IntoResponse, Response}, | |
| 9 | 9 | }; | |
| 10 | + | use sqlx::PgPool; | |
| 10 | 11 | use tower_sessions::Session; | |
| 11 | 12 | ||
| 12 | 13 | use crate::{ | |
| 13 | 14 | auth::MaybeUserVerified, | |
| 15 | + | config::Config, | |
| 14 | 16 | constants, | |
| 15 | 17 | db::{self, ContentData, ItemId, ItemType}, | |
| 16 | 18 | error::{AppError, Result}, | |
| @@ -18,13 +20,18 @@ use crate::{ | |||
| 18 | 20 | pricing, | |
| 19 | 21 | templates::*, | |
| 20 | 22 | types::*, | |
| 21 | - | AppState, | |
| 23 | + | AppStorage, Integrations, | |
| 22 | 24 | }; | |
| 23 | 25 | ||
| 24 | 26 | /// `GET /l/{item_id}`: render the library (consumption) view. | |
| 27 | + | #[allow(clippy::too_many_arguments)] | |
| 25 | 28 | #[tracing::instrument(skip_all, name = "content::library_page")] | |
| 26 | 29 | pub(in crate::routes::pages::public) async fn library_page( | |
| 27 | - | State(state): State<AppState>, | |
| 30 | + | State(db): State<PgPool>, | |
| 31 | + | State(storage): State<AppStorage>, | |
| 32 | + | State(config): State<Config>, | |
| 33 | + | State(integrations): State<Integrations>, | |
| 34 | + | State(page_view_tx): State<crate::db::page_views::PageViewTx>, | |
| 28 | 35 | session: Session, | |
| 29 | 36 | headers: axum::http::HeaderMap, | |
| 30 | 37 | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| @@ -33,13 +40,13 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 33 | 40 | let csrf_token = get_csrf_token(&session).await; | |
| 34 | 41 | let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?; | |
| 35 | 42 | ||
| 36 | - | let db_item = db::items::get_item_by_id(&state.db, id) | |
| 43 | + | let db_item = db::items::get_item_by_id(&db, id) | |
| 37 | 44 | .await? | |
| 38 | 45 | .ok_or(AppError::NotFound)?; | |
| 39 | - | let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id) | |
| 46 | + | let db_project = db::projects::get_project_by_id(&db, db_item.project_id) | |
| 40 | 47 | .await? | |
| 41 | 48 | .ok_or(AppError::NotFound)?; | |
| 42 | - | let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) | |
| 49 | + | let db_user = db::users::get_user_by_id(&db, db_project.user_id) | |
| 43 | 50 | .await? | |
| 44 | 51 | .ok_or(AppError::NotFound)?; | |
| 45 | 52 | if db_user.is_sandbox { | |
| @@ -59,12 +66,12 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 59 | 66 | // Compute access using the same logic as item_page. | |
| 60 | 67 | let item_pricing = pricing::for_item(&db_item); | |
| 61 | 68 | let in_library = if let Some(ref user) = maybe_user { | |
| 62 | - | db::transactions::has_purchased_item(&state.db, user.id, db_item.id).await? | |
| 69 | + | db::transactions::has_purchased_item(&db, user.id, db_item.id).await? | |
| 63 | 70 | } else { | |
| 64 | 71 | false | |
| 65 | 72 | }; | |
| 66 | 73 | let item_sub = if let Some(ref user) = maybe_user { | |
| 67 | - | db::subscriptions::SubscriptionGate::check(&state.db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id)).await? | |
| 74 | + | db::subscriptions::SubscriptionGate::check(&db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id)).await? | |
| 68 | 75 | } else { | |
| 69 | 76 | None | |
| 70 | 77 | }; | |
| @@ -76,12 +83,12 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 76 | 83 | let mut has_access = item_pricing.can_access(&ctx); | |
| 77 | 84 | if !has_access | |
| 78 | 85 | && let Some(ref user) = maybe_user | |
| 79 | - | && db::bundles::has_access_via_bundle(&state.db, user.id, db_item.id).await? | |
| 86 | + | && db::bundles::has_access_via_bundle(&db, user.id, db_item.id).await? | |
| 80 | 87 | { | |
| 81 | 88 | has_access = true; | |
| 82 | 89 | } | |
| 83 | 90 | ||
| 84 | - | let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?; | |
| 91 | + | let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?; | |
| 85 | 92 | let is_free = item_pricing.is_free(); | |
| 86 | 93 | let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, has_access); | |
| 87 | 94 | ||
| @@ -89,11 +96,11 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 89 | 96 | // Render 403 with link back to /i/{id}. For unlisted items, list containing bundles. | |
| 90 | 97 | let containing_bundles: Vec<Item> = if !db_item.listed { | |
| 91 | 98 | let bundle_ids = | |
| 92 | - | db::bundles::get_bundles_containing_item(&state.db, db_item.id).await?; | |
| 99 | + | db::bundles::get_bundles_containing_item(&db, db_item.id).await?; | |
| 93 | 100 | // Batch-fetch the public bundles in one query (Perf-MIN N+1); | |
| 94 | 101 | // get_public_items_by_ids already filters to is_public, matching the | |
| 95 | 102 | // per-item check the old loop did. | |
| 96 | - | db::items::get_public_items_by_ids(&state.db, &bundle_ids) | |
| 103 | + | db::items::get_public_items_by_ids(&db, &bundle_ids) | |
| 97 | 104 | .await? | |
| 98 | 105 | .iter() | |
| 99 | 106 | .map(|b| { | |
| @@ -113,7 +120,7 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 113 | 120 | session_user: maybe_user, | |
| 114 | 121 | item, | |
| 115 | 122 | creator_username: db_user.username.to_string(), | |
| 116 | - | host_url: state.config.host_url.clone(), | |
| 123 | + | host_url: config.host_url.clone(), | |
| 117 | 124 | containing_bundles, | |
| 118 | 125 | is_logged_in, | |
| 119 | 126 | }, | |
| @@ -127,20 +134,22 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 127 | 134 | .and_then(|v| v.to_str().ok()) | |
| 128 | 135 | .unwrap_or(""); | |
| 129 | 136 | if !super::is_bot(ua) { | |
| 130 | - | super::track_view(&state, "item", *db_item.id); | |
| 137 | + | super::track_view(&page_view_tx, "item", *db_item.id); | |
| 131 | 138 | } | |
| 132 | 139 | ||
| 133 | - | let db_versions = db::versions::get_versions_by_item(&state.db, db_item.id).await?; | |
| 140 | + | let db_versions = db::versions::get_versions_by_item(&db, db_item.id).await?; | |
| 134 | 141 | let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect(); | |
| 135 | 142 | ||
| 136 | 143 | let project_slug_str = db_project.slug.to_string(); | |
| 137 | 144 | let (discussion_url, discussion_count) = | |
| 138 | - | fetch_discussion_info(&state, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 145 | + | fetch_discussion_info(&integrations, &config, db_item.mt_thread_id, &project_slug_str, "items").await; | |
| 139 | 146 | ||
| 140 | 147 | // Phase 2: audio items get their own player template. | |
| 141 | 148 | if db_item.item_type == ItemType::Audio { | |
| 142 | 149 | return render_audio_library( | |
| 143 | - | &state, | |
| 150 | + | &db, | |
| 151 | + | &storage, | |
| 152 | + | &config, | |
| 144 | 153 | &db_item, | |
| 145 | 154 | &db_user, | |
| 146 | 155 | &db_project, | |
| @@ -158,7 +167,7 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 158 | 167 | // Phase 4: text items get their own reader template. | |
| 159 | 168 | if db_item.item_type == ItemType::Text { | |
| 160 | 169 | return render_text_library( | |
| 161 | - | &state, | |
| 170 | + | &config, | |
| 162 | 171 | &db_item, | |
| 163 | 172 | &db_user, | |
| 164 | 173 | &db_project, | |
| @@ -175,7 +184,9 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 175 | 184 | // Phase 3: video items get their own player template. | |
| 176 | 185 | if db_item.item_type == ItemType::Video { | |
| 177 | 186 | return render_video_library( | |
| 178 | - | &state, | |
| 187 | + | &db, | |
| 188 | + | &storage, | |
| 189 | + | &config, | |
| 179 | 190 | &db_item, | |
| 180 | 191 | &db_user, | |
| 181 | 192 | &db_project, | |
| @@ -193,7 +204,7 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 193 | 204 | // Phase 1: downloads / bundle / other items render here. Audio, video, and | |
| 194 | 205 | // text branches above handle their own templates. | |
| 195 | 206 | let bundle_child_items = if db_item.item_type == ItemType::Bundle { | |
| 196 | - | db::bundles::get_bundle_items(&state.db, db_item.id).await? | |
| 207 | + | db::bundles::get_bundle_items(&db, db_item.id).await? | |
| 197 | 208 | } else { | |
| 198 | 209 | Vec::new() | |
| 199 | 210 | }; | |
| @@ -205,12 +216,11 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 205 | 216 | }) | |
| 206 | 217 | .collect(); | |
| 207 | 218 | ||
| 208 | - | let cdn_base = state | |
| 209 | - | .config | |
| 219 | + | let cdn_base = config | |
| 210 | 220 | .cdn_base_url | |
| 211 | 221 | .as_deref() | |
| 212 | 222 | .unwrap_or("https://cdn.makenot.work"); | |
| 213 | - | let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?; | |
| 223 | + | let db_sections = db::item_sections::list_by_item(&db, db_item.id).await?; | |
| 214 | 224 | let sections: Vec<ItemSection> = db_sections | |
| 215 | 225 | .iter() | |
| 216 | 226 | .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base)) | |
| @@ -223,7 +233,7 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 223 | 233 | creator_username: db_user.username.to_string(), | |
| 224 | 234 | project_title: db_project.title.clone(), | |
| 225 | 235 | project_slug: project_slug_str, | |
| 226 | - | host_url: state.config.host_url.clone(), | |
| 236 | + | host_url: config.host_url.clone(), | |
| 227 | 237 | versions, | |
| 228 | 238 | bundle_items, | |
| 229 | 239 | sections, | |
| @@ -236,7 +246,9 @@ pub(in crate::routes::pages::public) async fn library_page( | |||
| 236 | 246 | ||
| 237 | 247 | #[allow(clippy::too_many_arguments)] | |
| 238 | 248 | async fn render_audio_library( | |
| 239 | - | state: &AppState, | |
| 249 | + | db: &PgPool, | |
| 250 | + | storage: &AppStorage, | |
| 251 | + | config: &Config, | |
| 240 | 252 | db_item: &db::DbItem, | |
| 241 | 253 | db_user: &db::DbUser, | |
| 242 | 254 | db_project: &db::DbProject, | |
| @@ -250,7 +262,7 @@ async fn render_audio_library( | |||
| 250 | 262 | ) -> Result<Response> { | |
| 251 | 263 | let avatar_initials = | |
| 252 | 264 | get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); | |
| 253 | - | let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?; | |
| 265 | + | let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?; | |
| 254 | 266 | let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect(); | |
| 255 | 267 | ||
| 256 | 268 | let audio_url = match db_item.content() { | |
| @@ -260,7 +272,7 @@ async fn render_audio_library( | |||
| 260 | 272 | audio_url, | |
| 261 | 273 | .. | |
| 262 | 274 | } => { | |
| 263 | - | if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &state.storage.s3) { | |
| 275 | + | if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &storage.s3) { | |
| 264 | 276 | let expiry_secs = match duration_seconds { | |
| 265 | 277 | Some(duration) => ((duration as u64) * 2) | |
| 266 | 278 | .clamp(3600, constants::STREAMING_CACHE_MAX_SECS), | |
| @@ -281,7 +293,7 @@ async fn render_audio_library( | |||
| 281 | 293 | }; | |
| 282 | 294 | ||
| 283 | 295 | let segments_json = | |
| 284 | - | super::item::build_segments_json(state, db_item.id, &audio_url, db_item).await; | |
| 296 | + | super::item::build_segments_json(db, storage, db_item.id, &audio_url, db_item).await; | |
| 285 | 297 | ||
| 286 | 298 | Ok(LibraryAudioTemplate { | |
| 287 | 299 | csrf_token, | |
| @@ -296,7 +308,7 @@ async fn render_audio_library( | |||
| 296 | 308 | chapters, | |
| 297 | 309 | segments_json, | |
| 298 | 310 | versions, | |
| 299 | - | host_url: state.config.host_url.clone(), | |
| 311 | + | host_url: config.host_url.clone(), | |
| 300 | 312 | discussion_url, | |
| 301 | 313 | discussion_count, | |
| 302 | 314 | is_owner, | |
| @@ -306,7 +318,9 @@ async fn render_audio_library( | |||
| 306 | 318 | ||
| 307 | 319 | #[allow(clippy::too_many_arguments)] | |
| 308 | 320 | async fn render_video_library( | |
| 309 | - | state: &AppState, | |
| 321 | + | db: &PgPool, | |
| 322 | + | storage: &AppStorage, | |
| 323 | + | config: &Config, | |
| 310 | 324 | db_item: &db::DbItem, | |
| 311 | 325 | db_user: &db::DbUser, | |
| 312 | 326 | db_project: &db::DbProject, | |
| @@ -320,7 +334,7 @@ async fn render_video_library( | |||
| 320 | 334 | ) -> Result<Response> { | |
| 321 | 335 | let avatar_initials = | |
| 322 | 336 | get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); | |
| 323 | - | let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?; | |
| 337 | + | let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?; | |
| 324 | 338 | let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect(); | |
| 325 | 339 | ||
| 326 | 340 | let video_url = match db_item.content() { | |
| @@ -329,7 +343,7 @@ async fn render_video_library( | |||
| 329 | 343 | duration_seconds, | |
| 330 | 344 | .. | |
| 331 | 345 | } => { | |
| 332 | - | if let (Some(s3_key), Some(s3)) = (&video_s3_key, &state.storage.s3) { | |
| 346 | + | if let (Some(s3_key), Some(s3)) = (&video_s3_key, &storage.s3) { | |
| 333 | 347 | let expiry_secs = match duration_seconds { | |
| 334 | 348 | Some(duration) => ((duration as u64) * 2) | |
| 335 | 349 | .clamp(3600, constants::STREAMING_CACHE_MAX_SECS), | |
| @@ -350,7 +364,7 @@ async fn render_video_library( | |||
| 350 | 364 | }; | |
| 351 | 365 | ||
| 352 | 366 | let segments_json = | |
| 353 | - | super::item::build_segments_json(state, db_item.id, &video_url, db_item).await; | |
| 367 | + | super::item::build_segments_json(db, storage, db_item.id, &video_url, db_item).await; | |
| 354 | 368 | ||
| 355 | 369 | Ok(LibraryVideoTemplate { | |
| 356 | 370 | csrf_token, | |
| @@ -365,7 +379,7 @@ async fn render_video_library( | |||
| 365 | 379 | chapters, | |
| 366 | 380 | segments_json, | |
| 367 | 381 | versions, | |
| 368 | - | host_url: state.config.host_url.clone(), | |
| 382 | + | host_url: config.host_url.clone(), | |
| 369 | 383 | discussion_url, | |
| 370 | 384 | discussion_count, | |
| 371 | 385 | is_owner, | |
| @@ -375,7 +389,7 @@ async fn render_video_library( | |||
| 375 | 389 | ||
| 376 | 390 | #[allow(clippy::too_many_arguments)] | |
| 377 | 391 | async fn render_text_library( | |
| 378 | - | state: &AppState, | |
| 392 | + | config: &Config, | |
| 379 | 393 | db_item: &db::DbItem, | |
| 380 | 394 | db_user: &db::DbUser, | |
| 381 | 395 | db_project: &db::DbProject, | |
| @@ -388,8 +402,7 @@ async fn render_text_library( | |||
| 388 | 402 | ) -> Result<Response> { | |
| 389 | 403 | let avatar_initials = | |
| 390 | 404 | get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); | |
| 391 | - | let cdn_base = state | |
| 392 | - | .config | |
| 405 | + | let cdn_base = config | |
| 393 | 406 | .cdn_base_url | |
| 394 | 407 | .as_deref() | |
| 395 | 408 | .unwrap_or("https://cdn.makenot.work"); | |
| @@ -417,7 +430,7 @@ async fn render_text_library( | |||
| 417 | 430 | project_slug: db_project.slug.to_string(), | |
| 418 | 431 | body_html, | |
| 419 | 432 | reading_time, | |
| 420 | - | host_url: state.config.host_url.clone(), | |
| 433 | + | host_url: config.host_url.clone(), | |
| 421 | 434 | discussion_url, | |
| 422 | 435 | discussion_count, | |
| 423 | 436 | is_owner, |
| @@ -15,10 +15,12 @@ use axum::{ | |||
| 15 | 15 | response::{IntoResponse, Redirect, Response}, | |
| 16 | 16 | }; | |
| 17 | 17 | use serde::Deserialize; | |
| 18 | + | use sqlx::PgPool; | |
| 18 | 19 | use tower_sessions::Session; | |
| 19 | 20 | ||
| 20 | 21 | use crate::{ | |
| 21 | 22 | auth::{MaybeUserVerified, SessionUser}, | |
| 23 | + | config::Config, | |
| 22 | 24 | db::{self, FollowTargetType, ItemId, Username}, | |
| 23 | 25 | error::{AppError, Result}, | |
| 24 | 26 | helpers::get_csrf_token, | |
| @@ -32,8 +34,12 @@ use crate::{ | |||
| 32 | 34 | /// Routes through the bounded `PageViewTx` batcher (single bg flush task, | |
| 33 | 35 | /// bulk UPSERT every 500ms) — the prior per-request `tokio::spawn` pattern | |
| 34 | 36 | /// saturated the DB pool under any view burst. | |
| 35 | - | pub(crate) fn track_view(state: &crate::AppState, target_type: &'static str, target_id: uuid::Uuid) { | |
| 36 | - | state.page_view_tx.try_record(target_type, target_id); | |
| 37 | + | pub(crate) fn track_view( | |
| 38 | + | page_view_tx: &crate::db::page_views::PageViewTx, | |
| 39 | + | target_type: &'static str, | |
| 40 | + | target_id: uuid::Uuid, | |
| 41 | + | ) { | |
| 42 | + | page_view_tx.try_record(target_type, target_id); | |
| 37 | 43 | } | |
| 38 | 44 | ||
| 39 | 45 | /// Returns true if the User-Agent looks like a bot/crawler. | |
| @@ -81,7 +87,7 @@ pub(super) async fn user_page( | |||
| 81 | 87 | .and_then(|v| v.to_str().ok()) | |
| 82 | 88 | .unwrap_or(""); | |
| 83 | 89 | if !is_bot(ua) { | |
| 84 | - | track_view(&state, "user", *db_user.id); | |
| 90 | + | track_view(&state.page_view_tx, "user", *db_user.id); | |
| 85 | 91 | } | |
| 86 | 92 | Ok(response) | |
| 87 | 93 | } | |
| @@ -147,7 +153,7 @@ pub(crate) async fn render_user_profile( | |||
| 147 | 153 | /// Render the purchase confirmation page with fee breakdown. | |
| 148 | 154 | #[tracing::instrument(skip_all, name = "content::purchase_page")] | |
| 149 | 155 | pub(super) async fn purchase_page( | |
| 150 | - | State(state): State<AppState>, | |
| 156 | + | State(db): State<PgPool>, | |
| 151 | 157 | session: Session, | |
| 152 | 158 | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 153 | 159 | Path(item_id): Path<String>, | |
| @@ -157,15 +163,15 @@ pub(super) async fn purchase_page( | |||
| 157 | 163 | let is_logged_in = maybe_user.is_some(); | |
| 158 | 164 | let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?; | |
| 159 | 165 | ||
| 160 | - | let db_item = db::items::get_item_by_id(&state.db, id) | |
| 166 | + | let db_item = db::items::get_item_by_id(&db, id) | |
| 161 | 167 | .await? | |
| 162 | 168 | .ok_or(AppError::NotFound)?; | |
| 163 | 169 | ||
| 164 | - | let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id) | |
| 170 | + | let db_project = db::projects::get_project_by_id(&db, db_item.project_id) | |
| 165 | 171 | .await? | |
| 166 | 172 | .ok_or(AppError::NotFound)?; | |
| 167 | 173 | ||
| 168 | - | let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) | |
| 174 | + | let db_user = db::users::get_user_by_id(&db, db_project.user_id) | |
| 169 | 175 | .await? | |
| 170 | 176 | .ok_or(AppError::NotFound)?; | |
| 171 | 177 | ||
| @@ -198,7 +204,7 @@ pub(super) async fn purchase_page( | |||
| 198 | 204 | let stripe_fee = crate::formatting::format_dollars_plain(stripe_fee_cents); | |
| 199 | 205 | let creator_receives = crate::formatting::format_dollars_plain(creator_receives_cents); | |
| 200 | 206 | ||
| 201 | - | let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?; | |
| 207 | + | let purchase_tags = db::tags::get_tags_for_item(&db, id).await?; | |
| 202 | 208 | let item = Item::from_db_list(&db_item, &purchase_tags, price_cents == 0, false); | |
| 203 | 209 | ||
| 204 | 210 | let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents); | |
| @@ -206,7 +212,7 @@ pub(super) async fn purchase_page( | |||
| 206 | 212 | let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min); | |
| 207 | 213 | ||
| 208 | 214 | let pending_started = if let Some(ref u) = maybe_user { | |
| 209 | - | match db::transactions::get_pending_item_purchase(&state.db, u.id, id).await? { | |
| 215 | + | match db::transactions::get_pending_item_purchase(&db, u.id, id).await? { | |
| 210 | 216 | Some((_, created_at)) => format_relative_ago(created_at), | |
| 211 | 217 | None => String::new(), | |
| 212 | 218 | } | |
| @@ -252,7 +258,7 @@ fn format_relative_ago(ts: chrono::DateTime<chrono::Utc>) -> String { | |||
| 252 | 258 | /// Render a purchase receipt page. | |
| 253 | 259 | #[tracing::instrument(skip_all, name = "content::receipt_page")] | |
| 254 | 260 | pub(super) async fn receipt_page( | |
| 255 | - | State(state): State<AppState>, | |
| 261 | + | State(db): State<PgPool>, | |
| 256 | 262 | session: Session, | |
| 257 | 263 | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 258 | 264 | Path(transaction_id): Path<String>, | |
| @@ -260,7 +266,7 @@ pub(super) async fn receipt_page( | |||
| 260 | 266 | let csrf_token = get_csrf_token(&session).await; | |
| 261 | 267 | let tx_id: db::TransactionId = transaction_id.parse().map_err(|_| AppError::NotFound)?; | |
| 262 | 268 | ||
| 263 | - | let tx = db::transactions::get_transaction_by_id(&state.db, tx_id) | |
| 269 | + | let tx = db::transactions::get_transaction_by_id(&db, tx_id) | |
| 264 | 270 | .await? | |
| 265 | 271 | .ok_or(AppError::NotFound)?; | |
| 266 | 272 | ||
| @@ -312,20 +318,20 @@ pub(super) async fn receipt_page( | |||
| 312 | 318 | /// Render a public collection page. | |
| 313 | 319 | #[tracing::instrument(skip_all, name = "content::collection_page")] | |
| 314 | 320 | pub(super) async fn collection_page( | |
| 315 | - | State(state): State<AppState>, | |
| 321 | + | State(db): State<PgPool>, | |
| 316 | 322 | session: Session, | |
| 317 | 323 | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 318 | 324 | Path((username, slug)): Path<(String, String)>, | |
| 319 | 325 | ) -> Result<impl IntoResponse> { | |
| 320 | 326 | let csrf_token = get_csrf_token(&session).await; | |
| 321 | 327 | let username = Username::new(&username).map_err(|_| AppError::NotFound)?; | |
| 322 | - | let db_user = db::users::get_user_by_username(&state.db, &username) | |
| 328 | + | let db_user = db::users::get_user_by_username(&db, &username) | |
| 323 | 329 | .await? | |
| 324 | 330 | .ok_or(AppError::NotFound)?; | |
| 325 | 331 | ||
| 326 | 332 | let slug = db::Slug::new(&slug).map_err(|_| AppError::NotFound)?; | |
| 327 | 333 | let collection = | |
| 328 | - | db::collections::get_collection_by_user_and_slug(&state.db, db_user.id, &slug) | |
| 334 | + | db::collections::get_collection_by_user_and_slug(&db, db_user.id, &slug) | |
| 329 | 335 | .await? | |
| 330 | 336 | .ok_or(AppError::NotFound)?; | |
| 331 | 337 | ||
| @@ -335,7 +341,7 @@ pub(super) async fn collection_page( | |||
| 335 | 341 | return Err(AppError::NotFound); | |
| 336 | 342 | } | |
| 337 | 343 | ||
| 338 | - | let db_items = db::collections::get_collection_items(&state.db, collection.id).await?; | |
| 344 | + | let db_items = db::collections::get_collection_items(&db, collection.id).await?; | |
| 339 | 345 | let items: Vec<CollectionItem> = db_items.iter().map(CollectionItem::from).collect(); | |
| 340 | 346 | ||
| 341 | 347 | let item_count = items.len() as i64; | |
| @@ -363,12 +369,13 @@ pub(super) async fn collection_page( | |||
| 363 | 369 | /// and social media sharing. Shows item summary + guest checkout button. | |
| 364 | 370 | #[tracing::instrument(skip_all, name = "content::buy_page")] | |
| 365 | 371 | pub(super) async fn buy_page( | |
| 366 | - | State(state): State<AppState>, | |
| 372 | + | State(db): State<PgPool>, | |
| 373 | + | State(config): State<Config>, | |
| 367 | 374 | Path(item_id): Path<String>, | |
| 368 | 375 | ) -> Result<impl IntoResponse> { | |
| 369 | 376 | let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?; | |
| 370 | 377 | ||
| 371 | - | let db_item = db::items::get_item_by_id(&state.db, id) | |
| 378 | + | let db_item = db::items::get_item_by_id(&db, id) | |
| 372 | 379 | .await? | |
| 373 | 380 | .ok_or(AppError::NotFound)?; | |
| 374 | 381 | ||
| @@ -376,15 +383,15 @@ pub(super) async fn buy_page( | |||
| 376 | 383 | return Err(AppError::NotFound); | |
| 377 | 384 | } | |
| 378 | 385 | ||
| 379 | - | let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id) | |
| 386 | + | let db_project = db::projects::get_project_by_id(&db, db_item.project_id) | |
| 380 | 387 | .await? | |
| 381 | 388 | .ok_or(AppError::NotFound)?; | |
| 382 | 389 | ||
| 383 | - | let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) | |
| 390 | + | let db_user = db::users::get_user_by_id(&db, db_project.user_id) | |
| 384 | 391 | .await? | |
| 385 | 392 | .ok_or(AppError::NotFound)?; | |
| 386 | 393 | ||
| 387 | - | let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?; | |
| 394 | + | let purchase_tags = db::tags::get_tags_for_item(&db, id).await?; | |
| 388 | 395 | let item = Item::from_db_list(&db_item, &purchase_tags, db_item.price_cents == 0, false); | |
| 389 | 396 | ||
| 390 | 397 | let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents); | |
| @@ -398,6 +405,6 @@ pub(super) async fn buy_page( | |||
| 398 | 405 | pwyw_enabled: db_item.pwyw_enabled, | |
| 399 | 406 | pwyw_min_dollars, | |
| 400 | 407 | suggested_price, | |
| 401 | - | host_url: state.config.host_url.clone(), | |
| 408 | + | host_url: config.host_url.clone(), | |
| 402 | 409 | }) | |
| 403 | 410 | } |
| @@ -36,7 +36,7 @@ pub(in crate::routes::pages::public) async fn project_page( | |||
| 36 | 36 | .and_then(|v| v.to_str().ok()) | |
| 37 | 37 | .unwrap_or(""); | |
| 38 | 38 | if !super::is_bot(ua) { | |
| 39 | - | super::track_view(&state, "project", *db_project.id); | |
| 39 | + | super::track_view(&state.page_view_tx, "project", *db_project.id); | |
| 40 | 40 | } | |
| 41 | 41 | Ok(response) | |
| 42 | 42 | } |
| @@ -19,7 +19,6 @@ use crate::{ | |||
| 19 | 19 | helpers::get_csrf_token, | |
| 20 | 20 | templates::*, | |
| 21 | 21 | types::*, | |
| 22 | - | AppState, | |
| 23 | 22 | }; | |
| 24 | 23 | ||
| 25 | 24 | /// The four viewer-independent discover facet results (item-type, tag, ai-tier, and | |
| @@ -311,7 +310,7 @@ pub struct TagTreeQuery { | |||
| 311 | 310 | /// Browse the tag hierarchy with breadcrumb navigation. | |
| 312 | 311 | #[tracing::instrument(skip_all, name = "discover::tag_tree")] | |
| 313 | 312 | pub(super) async fn tag_tree( | |
| 314 | - | State(state): State<AppState>, | |
| 313 | + | State(db): State<PgPool>, | |
| 315 | 314 | session: Session, | |
| 316 | 315 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 317 | 316 | Query(query): Query<TagTreeQuery>, | |
| @@ -320,7 +319,7 @@ pub(super) async fn tag_tree( | |||
| 320 | 319 | ||
| 321 | 320 | // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre") | |
| 322 | 321 | let parent_tag = if let Some(ref slug) = query.parent { | |
| 323 | - | db::tags::get_tag_by_slug(&state.db, slug).await? | |
| 322 | + | db::tags::get_tag_by_slug(&db, slug).await? | |
| 324 | 323 | } else { | |
| 325 | 324 | None | |
| 326 | 325 | }; | |
| @@ -328,13 +327,13 @@ pub(super) async fn tag_tree( | |||
| 328 | 327 | let parent_id = parent_tag.as_ref().map(|t| t.id); | |
| 329 | 328 | ||
| 330 | 329 | // Fetch children at this level | |
| 331 | - | let children = db::tags::get_child_tags(&state.db, parent_id).await?; | |
| 330 | + | let children = db::tags::get_child_tags(&db, parent_id).await?; | |
| 332 | 331 | ||
| 333 | 332 | // Item counts + child counts, both scoped to just this level's children | |
| 334 | 333 | // rather than aggregating over every tag in the catalog (fuzz 2026-07-06 C5-2). | |
| 335 | 334 | let child_ids: Vec<_> = children.iter().map(|c| c.id).collect(); | |
| 336 | - | let tag_counts = db::tags::item_counts_by_tag(&state.db, &child_ids).await?; | |
| 337 | - | let grandchild_counts = db::tags::count_children_by_parents(&state.db, &child_ids).await?; | |
| 335 | + | let tag_counts = db::tags::item_counts_by_tag(&db, &child_ids).await?; | |
| 336 | + | let grandchild_counts = db::tags::count_children_by_parents(&db, &child_ids).await?; | |
| 338 | 337 | ||
| 339 | 338 | let categories: Vec<TagTreeNode> = children.iter().map(|child| { | |
| 340 | 339 | TagTreeNode { | |
| @@ -347,7 +346,7 @@ pub(super) async fn tag_tree( | |||
| 347 | 346 | ||
| 348 | 347 | // Build breadcrumbs from ancestor chain | |
| 349 | 348 | let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag { | |
| 350 | - | let ancestors = db::tags::get_tag_ancestors(&state.db, pt.id).await?; | |
| 349 | + | let ancestors = db::tags::get_tag_ancestors(&db, pt.id).await?; | |
| 351 | 350 | // ancestors includes the tag itself as the last element. | |
| 352 | 351 | // We want all ancestors except the current tag as breadcrumbs, | |
| 353 | 352 | // and the current tag as current_tag. | |
| @@ -380,7 +379,7 @@ pub(super) async fn tag_tree( | |||
| 380 | 379 | /// Render the discover page with filterable, paginated items or projects. | |
| 381 | 380 | #[tracing::instrument(skip_all, name = "discover::discover")] | |
| 382 | 381 | pub(super) async fn discover( | |
| 383 | - | State(state): State<AppState>, | |
| 382 | + | State(db): State<PgPool>, | |
| 384 | 383 | session: Session, | |
| 385 | 384 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 386 | 385 | Query(query): Query<DiscoverQuery>, | |
| @@ -391,7 +390,7 @@ pub(super) async fn discover( | |||
| 391 | 390 | let tag_filter = f.tag; | |
| 392 | 391 | let item_type_filter = f.item_type; | |
| 393 | 392 | let has_source_code = f.has_source_code; | |
| 394 | - | let data = fetch_discover_data(&state.db, &query).await?; | |
| 393 | + | let data = fetch_discover_data(&db, &query).await?; | |
| 395 | 394 | ||
| 396 | 395 | // Build type and tag filters (items mode only) | |
| 397 | 396 | let category_filter = f.category; | |
| @@ -399,7 +398,7 @@ pub(super) async fn discover( | |||
| 399 | 398 | // Build category filters (projects mode only) | |
| 400 | 399 | let category_filters = if data.mode == "projects" { | |
| 401 | 400 | let cat_counts = db::categories::get_category_counts( | |
| 402 | - | &state.db, | |
| 401 | + | &db, | |
| 403 | 402 | search_filter, | |
| 404 | 403 | ) | |
| 405 | 404 | .await?; | |
| @@ -433,7 +432,7 @@ pub(super) async fn discover( | |||
| 433 | 432 | // per-viewer, so it is computed fresh (and only when logged in). | |
| 434 | 433 | let viewer_id = maybe_user.as_ref().map(|u| u.id); | |
| 435 | 434 | let (type_counts, tag_counts, ai_counts, price_counts) = cached_facets( | |
| 436 | - | &state.db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price, | |
| 435 | + | &db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price, | |
| 437 | 436 | ).await?; | |
| 438 | 437 | // Only the first ~10 tag facets are rendered (see `.take(10)` below), so | |
| 439 | 438 | // test follow-membership against just those rather than fetching the | |
| @@ -441,7 +440,7 @@ pub(super) async fn discover( | |||
| 441 | 440 | let followed_tag_ids = if let Some(uid) = viewer_id { | |
| 442 | 441 | let rendered_tag_ids: Vec<_> = | |
| 443 | 442 | tag_counts.iter().take(10).map(|tc| tc.tag_id).collect(); | |
| 444 | - | db::follows::following_subset(&state.db, uid, &rendered_tag_ids).await? | |
| 443 | + | db::follows::following_subset(&db, uid, &rendered_tag_ids).await? | |
| 445 | 444 | } else { | |
| 446 | 445 | std::collections::HashSet::new() | |
| 447 | 446 | }; | |
| @@ -588,11 +587,11 @@ pub(super) async fn discover( | |||
| 588 | 587 | /// Return discover results as an HTMX partial for filtering and pagination. | |
| 589 | 588 | #[tracing::instrument(skip_all, name = "discover::discover_results")] | |
| 590 | 589 | pub(super) async fn discover_results( | |
| 591 | - | State(state): State<AppState>, | |
| 590 | + | State(db): State<PgPool>, | |
| 592 | 591 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 593 | 592 | Query(query): Query<DiscoverQuery>, | |
| 594 | 593 | ) -> Result<impl IntoResponse> { | |
| 595 | - | let data = fetch_discover_data(&state.db, &query).await?; | |
| 594 | + | let data = fetch_discover_data(&db, &query).await?; | |
| 596 | 595 | ||
| 597 | 596 | Ok(DiscoverResultsTemplate { | |
| 598 | 597 | items: data.items, | |
| @@ -626,11 +625,11 @@ pub struct SearchSuggestion { | |||
| 626 | 625 | /// Return search suggestions (tags, projects, creators) as JSON. | |
| 627 | 626 | #[tracing::instrument(skip_all, name = "discover::search_suggestions")] | |
| 628 | 627 | pub(super) async fn search_suggestions_handler( | |
| 629 | - | State(state): State<AppState>, | |
| 628 | + | State(db): State<PgPool>, | |
| 630 | 629 | Query(query): Query<SuggestionsQuery>, | |
| 631 | 630 | ) -> Result<impl IntoResponse> { | |
| 632 | 631 | let q = query.q.unwrap_or_default(); | |
| 633 | - | let rows = db::discover::search_suggestions(&state.db, &q).await?; | |
| 632 | + | let rows = db::discover::search_suggestions(&db, &q).await?; | |
| 634 | 633 | let suggestions: Vec<SearchSuggestion> = rows | |
| 635 | 634 | .into_iter() | |
| 636 | 635 | .map(|r| SearchSuggestion { |
| @@ -12,13 +12,12 @@ use crate::{ | |||
| 12 | 12 | error::{AppError, Result}, | |
| 13 | 13 | helpers::get_csrf_token, | |
| 14 | 14 | templates::{DocIndexTemplate, DocSection, DocSectionEntry, DocSubsection, DocTemplate}, | |
| 15 | - | AppState, | |
| 16 | 15 | }; | |
| 17 | 16 | ||
| 18 | 17 | /// GET /docs: index page listing all docs grouped by section. | |
| 19 | 18 | #[tracing::instrument(skip_all, name = "docs::docs_index")] | |
| 20 | 19 | pub async fn docs_index( | |
| 21 | - | State(state): State<AppState>, | |
| 20 | + | State(docs): State<std::sync::Arc<docengine::DocLoader>>, | |
| 22 | 21 | session: Session, | |
| 23 | 22 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 24 | 23 | ) -> Result<impl IntoResponse> { | |
| @@ -26,7 +25,7 @@ pub async fn docs_index( | |||
| 26 | 25 | ||
| 27 | 26 | // Group index entries by section, preserving load order. | |
| 28 | 27 | let mut sections: Vec<DocSection> = Vec::new(); | |
| 29 | - | for entry in state.docs.index() { | |
| 28 | + | for entry in docs.index() { | |
| 30 | 29 | let section = sections | |
| 31 | 30 | .iter_mut() | |
| 32 | 31 | .find(|s| s.name == entry.section); | |
| @@ -89,20 +88,20 @@ pub async fn docs_index( | |||
| 89 | 88 | /// GET /docs/search.json: full-text search index for client-side filtering. | |
| 90 | 89 | #[tracing::instrument(skip_all, name = "docs::docs_search_index")] | |
| 91 | 90 | pub async fn docs_search_index( | |
| 92 | - | State(state): State<AppState>, | |
| 91 | + | State(docs): State<std::sync::Arc<docengine::DocLoader>>, | |
| 93 | 92 | ) -> Json<Vec<docengine::DocSearchEntry>> { | |
| 94 | - | Json(state.docs.search_index()) | |
| 93 | + | Json(docs.search_index()) | |
| 95 | 94 | } | |
| 96 | 95 | ||
| 97 | 96 | /// GET /docs/{slug}: individual doc page. | |
| 98 | 97 | #[tracing::instrument(skip_all, name = "docs::doc_page")] | |
| 99 | 98 | pub async fn doc_page( | |
| 100 | - | State(state): State<AppState>, | |
| 99 | + | State(docs): State<std::sync::Arc<docengine::DocLoader>>, | |
| 101 | 100 | session: Session, | |
| 102 | 101 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 103 | 102 | Path(slug): Path<String>, | |
| 104 | 103 | ) -> Result<impl IntoResponse> { | |
| 105 | - | let page = state.docs.get(&slug).ok_or(AppError::NotFound)?; | |
| 104 | + | let page = docs.get(&slug).ok_or(AppError::NotFound)?; | |
| 106 | 105 | let csrf_token = get_csrf_token(&session).await; | |
| 107 | 106 | ||
| 108 | 107 | Ok(DocTemplate { |
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | use axum::extract::{Query, State}; | |
| 4 | 4 | use axum::response::IntoResponse; | |
| 5 | 5 | use serde::Deserialize; | |
| 6 | + | use sqlx::PgPool; | |
| 6 | 7 | use tower_sessions::Session; | |
| 7 | 8 | ||
| 8 | 9 | use crate::{ | |
| @@ -13,7 +14,6 @@ use crate::{ | |||
| 13 | 14 | helpers::get_csrf_token, | |
| 14 | 15 | templates::FeedTemplate, | |
| 15 | 16 | types::DiscoverItem, | |
| 16 | - | AppState, | |
| 17 | 17 | }; | |
| 18 | 18 | ||
| 19 | 19 | /// Query parameters for the feed page. | |
| @@ -25,7 +25,7 @@ pub struct FeedQuery { | |||
| 25 | 25 | /// GET /feed: paginated feed of items from followed users, projects, and tags. | |
| 26 | 26 | #[tracing::instrument(skip_all, name = "feed::feed_page")] | |
| 27 | 27 | pub(super) async fn feed_page( | |
| 28 | - | State(state): State<AppState>, | |
| 28 | + | State(db): State<PgPool>, | |
| 29 | 29 | session: Session, | |
| 30 | 30 | AuthUser(user): AuthUser, | |
| 31 | 31 | Query(query): Query<FeedQuery>, | |
| @@ -41,11 +41,11 @@ pub(super) async fn feed_page( | |||
| 41 | 41 | // overflows for a large `?page=` (garbage offset in release, panic in debug). | |
| 42 | 42 | let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64; | |
| 43 | 43 | ||
| 44 | - | let total_items = db::follows::count_followed_feed_items(&state.db, user.id).await? as u32; | |
| 44 | + | let total_items = db::follows::count_followed_feed_items(&db, user.id).await? as u32; | |
| 45 | 45 | let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1); | |
| 46 | 46 | ||
| 47 | 47 | let db_items = db::follows::get_followed_feed_items( | |
| 48 | - | &state.db, | |
| 48 | + | &db, | |
| 49 | 49 | user.id, | |
| 50 | 50 | constants::FEED_PAGE_SIZE as i64, | |
| 51 | 51 | offset, |
| @@ -19,12 +19,15 @@ use axum::response::IntoResponse; | |||
| 19 | 19 | use axum::Json; | |
| 20 | 20 | use tower_sessions::Session; | |
| 21 | 21 | ||
| 22 | + | use sqlx::PgPool; | |
| 23 | + | ||
| 22 | 24 | use crate::{ | |
| 25 | + | config::Config, | |
| 23 | 26 | db, | |
| 24 | 27 | error::Result, | |
| 25 | 28 | helpers::get_csrf_token, | |
| 26 | 29 | templates::*, | |
| 27 | - | AppState, | |
| 30 | + | AppStorage, Billing, Ops, | |
| 28 | 31 | }; | |
| 29 | 32 | ||
| 30 | 33 | /// Per-subsystem ceiling for the outbound probes in the admin health fan-out, | |
| @@ -225,7 +228,13 @@ fn format_privacy_jobs( | |||
| 225 | 228 | ||
| 226 | 229 | /// Run all health checks and return computed data. | |
| 227 | 230 | /// Used by the HTML dashboard; runs live probes (DB, S3, HTTP self-tests). | |
| 228 | - | async fn collect_health(state: &AppState) -> HealthData { | |
| 231 | + | async fn collect_health( | |
| 232 | + | db: &PgPool, | |
| 233 | + | storage: &AppStorage, | |
| 234 | + | payments: &Billing, | |
| 235 | + | ops: &Ops, | |
| 236 | + | config: &Config, | |
| 237 | + | ) -> HealthData { | |
| 229 | 238 | use std::time::Instant; | |
| 230 | 239 | ||
| 231 | 240 | let check_start = Instant::now(); | |
| @@ -251,25 +260,25 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 251 | 260 | let (db_test_users, db_test_projects, db_test_items, db_test_transactions) = tokio::join!( | |
| 252 | 261 | run_test("Count users", || async { | |
| 253 | 262 | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users") | |
| 254 | - | .fetch_one(&state.db) | |
| 263 | + | .fetch_one(db) | |
| 255 | 264 | .await | |
| 256 | 265 | .is_ok() | |
| 257 | 266 | }), | |
| 258 | 267 | run_test("Count projects", || async { | |
| 259 | 268 | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM projects") | |
| 260 | - | .fetch_one(&state.db) | |
| 269 | + | .fetch_one(db) | |
| 261 | 270 | .await | |
| 262 | 271 | .is_ok() | |
| 263 | 272 | }), | |
| 264 | 273 | run_test("Count items", || async { | |
| 265 | 274 | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items") | |
| 266 | - | .fetch_one(&state.db) | |
| 275 | + | .fetch_one(db) | |
| 267 | 276 | .await | |
| 268 | 277 | .is_ok() | |
| 269 | 278 | }), | |
| 270 | 279 | run_test("Count transactions", || async { | |
| 271 | 280 | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM transactions") | |
| 272 | - | .fetch_one(&state.db) | |
| 281 | + | .fetch_one(db) | |
| 273 | 282 | .await | |
| 274 | 283 | .is_ok() | |
| 275 | 284 | }), | |
| @@ -279,7 +288,7 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 279 | 288 | // On failure the counts render as zeros, but that must not be SILENT — log it | |
| 280 | 289 | // so a stats-query failure is visible in the ops logs (the DB probes above are | |
| 281 | 290 | // the authoritative liveness signal; a real DB outage turns them red too). | |
| 282 | - | let stats = db::health::get_health_stats(&state.db).await.unwrap_or_else(|e| { | |
| 291 | + | let stats = db::health::get_health_stats(db).await.unwrap_or_else(|e| { | |
| 283 | 292 | tracing::error!(error = ?e, "health: DB stats query failed; rendering zero counts (see DB probes for liveness)"); | |
| 284 | 293 | db::health::DbHealthStats { | |
| 285 | 294 | user_count: 0, | |
| @@ -301,8 +310,8 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 301 | 310 | let db_status_class = if db_ok { "status-ok" } else { "status-error" }; | |
| 302 | 311 | ||
| 303 | 312 | // Pool info | |
| 304 | - | let pool_max = state.db.size(); | |
| 305 | - | let pool_idle = state.db.num_idle(); | |
| 313 | + | let pool_max = db.size(); | |
| 314 | + | let pool_idle = db.num_idle(); | |
| 306 | 315 | let pool_active = pool_max.saturating_sub(pool_idle as u32); | |
| 307 | 316 | ||
| 308 | 317 | // S3 storage status with connectivity check. Bound the one outbound network | |
| @@ -310,8 +319,8 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 310 | 319 | // a few seconds rather than stalling the admin dashboard on the client-level | |
| 311 | 320 | // 60s operation timeout (audit Run 22). The DB probes are already bounded by | |
| 312 | 321 | // the pooled-connection `statement_timeout`, and the PoM fetch has its own. | |
| 313 | - | let storage_configured = state.storage.s3.is_some(); | |
| 314 | - | let s3_reachable = if let Some(ref s3) = state.storage.s3 { | |
| 322 | + | let storage_configured = storage.s3.is_some(); | |
| 323 | + | let s3_reachable = if let Some(ref s3) = storage.s3 { | |
| 315 | 324 | matches!( | |
| 316 | 325 | tokio::time::timeout(HEALTH_PROBE_TIMEOUT, s3.check_connectivity()).await, | |
| 317 | 326 | Ok(res) if res.is_ok() | |
| @@ -326,18 +335,18 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 326 | 335 | } else { | |
| 327 | 336 | ("Not configured", "status-warn") | |
| 328 | 337 | }; | |
| 329 | - | let (storage_bucket, storage_region) = if let Some(ref storage) = state.config.storage { | |
| 338 | + | let (storage_bucket, storage_region) = if let Some(ref storage) = config.storage { | |
| 330 | 339 | (storage.bucket.clone(), storage.region.clone()) | |
| 331 | 340 | } else { | |
| 332 | 341 | (String::new(), String::new()) | |
| 333 | 342 | }; | |
| 334 | 343 | ||
| 335 | 344 | // Stripe status | |
| 336 | - | let stripe_configured = state.stripe.is_some(); | |
| 345 | + | let stripe_configured = payments.stripe.is_some(); | |
| 337 | 346 | let stripe_status = if stripe_configured { "Configured" } else { "Not configured" }; | |
| 338 | 347 | let stripe_status_class = if stripe_configured { "status-ok" } else { "status-warn" }; | |
| 339 | 348 | let stripe_mode = if stripe_configured { | |
| 340 | - | if state.config.stripe.as_ref().map(|s| s.secret_key.starts_with("sk_live")).unwrap_or(false) { | |
| 349 | + | if config.stripe.as_ref().map(|s| s.secret_key.starts_with("sk_live")).unwrap_or(false) { | |
| 341 | 350 | "Live" | |
| 342 | 351 | } else { | |
| 343 | 352 | "Test" | |
| @@ -358,19 +367,19 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 358 | 367 | // `active_session_count > 0` check false-alarmed the whole dashboard to | |
| 359 | 368 | // "Issues detected" on an idle instance (Run 15). Matches monitor.rs. | |
| 360 | 369 | let session_ok = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM tower_sessions.session)") | |
| 361 | - | .fetch_one(&state.db) | |
| 370 | + | .fetch_one(db) | |
| 362 | 371 | .await | |
| 363 | 372 | .is_ok(); | |
| 364 | 373 | let session_status = if session_ok { "Active" } else { "Error" }; | |
| 365 | 374 | let session_status_class = if session_ok { "status-ok" } else { "status-error" }; | |
| 366 | 375 | ||
| 367 | 376 | // SyncKit status | |
| 368 | - | let synckit_configured = state.config.synckit_jwt_secret.is_some(); | |
| 377 | + | let synckit_configured = config.synckit_jwt_secret.is_some(); | |
| 369 | 378 | let synckit_status = if synckit_configured { "Configured" } else { "Not configured" }; | |
| 370 | 379 | let synckit_status_class = if synckit_configured { "status-ok" } else { "status-warn" }; | |
| 371 | 380 | ||
| 372 | 381 | // Security & Monitoring | |
| 373 | - | let admin_configured = state.config.admin_user_id.is_some(); | |
| 382 | + | let admin_configured = config.admin_user_id.is_some(); | |
| 374 | 383 | ||
| 375 | 384 | // Overall tri-state status | |
| 376 | 385 | let overall = if !db_ok || !session_ok { | |
| @@ -383,11 +392,11 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 383 | 392 | ||
| 384 | 393 | // Environment | |
| 385 | 394 | let environment = if cfg!(debug_assertions) { "Development" } else { "Production" }; | |
| 386 | - | let host = state.config.host_url.clone(); | |
| 395 | + | let host = config.host_url.clone(); | |
| 387 | 396 | ||
| 388 | 397 | // Real uptime from AppState | |
| 389 | - | let uptime = format_uptime(state.start_instant.elapsed()); | |
| 390 | - | let started_at = state.started_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(); | |
| 398 | + | let uptime = format_uptime(ops.start_instant.elapsed()); | |
| 399 | + | let started_at = ops.started_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(); | |
| 391 | 400 | ||
| 392 | 401 | // Version with git hash | |
| 393 | 402 | let version = match option_env!("GIT_HASH") { | |
| @@ -452,17 +461,17 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 452 | 461 | ||
| 453 | 462 | let alerts_configured = std::env::var("ALERT_EMAIL").is_ok(); | |
| 454 | 463 | ||
| 455 | - | let uptime_24h = db::monitor::get_health_uptime_percent(&state.db, 24) | |
| 464 | + | let uptime_24h = db::monitor::get_health_uptime_percent(db, 24) | |
| 456 | 465 | .await | |
| 457 | 466 | .unwrap_or(None); | |
| 458 | - | let uptime_7d = db::monitor::get_health_uptime_percent(&state.db, 168) | |
| 467 | + | let uptime_7d = db::monitor::get_health_uptime_percent(db, 168) | |
| 459 | 468 | .await | |
| 460 | 469 | .unwrap_or(None); | |
| 461 | - | let last_incident = db::monitor::get_last_incident(&state.db) | |
| 470 | + | let last_incident = db::monitor::get_last_incident(db) | |
| 462 | 471 | .await | |
| 463 | 472 | .unwrap_or(None) | |
| 464 | 473 | .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()); | |
| 465 | - | let recent_snapshots = db::monitor::get_recent_health_history(&state.db, 10) | |
| 474 | + | let recent_snapshots = db::monitor::get_recent_health_history(db, 10) | |
| 466 | 475 | .await | |
| 467 | 476 | .unwrap_or_default(); | |
| 468 | 477 | ||
| @@ -525,7 +534,7 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 525 | 534 | pom_routes_total, | |
| 526 | 535 | pom_routes_ok, | |
| 527 | 536 | pom_routes_failed, | |
| 528 | - | privacy_jobs: db::scheduler_jobs::get_job_runs(&state.db).await.unwrap_or_default(), | |
| 537 | + | privacy_jobs: db::scheduler_jobs::get_job_runs(db).await.unwrap_or_default(), | |
| 529 | 538 | } | |
| 530 | 539 | } | |
| 531 | 540 | ||
| @@ -545,14 +554,18 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 545 | 554 | /// non-admin branch now reads the same cached snapshot `/api/health` does. | |
| 546 | 555 | #[tracing::instrument(skip_all, name = "health::health")] | |
| 547 | 556 | pub(super) async fn health( | |
| 548 | - | State(state): State<AppState>, | |
| 557 | + | State(db): State<PgPool>, | |
| 558 | + | State(storage): State<AppStorage>, | |
| 559 | + | State(payments): State<Billing>, | |
| 560 | + | State(ops): State<Ops>, | |
| 561 | + | State(config): State<Config>, | |
| 549 | 562 | session: Session, | |
| 550 | 563 | crate::auth::MaybeUserVerified(maybe_user): crate::auth::MaybeUserVerified, | |
| 551 | 564 | ) -> Result<axum::response::Response> { | |
| 552 | 565 | let is_admin = maybe_user.as_ref().is_some_and(|u| u.is_admin); | |
| 553 | 566 | if !is_admin { | |
| 554 | 567 | // Cheap cached status only — no live fan-out for unauthenticated callers. | |
| 555 | - | let (overall, _db_ok) = cached_health_status(&state).await; | |
| 568 | + | let (overall, _db_ok) = cached_health_status(&db).await; | |
| 556 | 569 | // `label()` is a controlled &'static str from OverallStatus — no user input. | |
| 557 | 570 | let body = axum::response::Html(format!( | |
| 558 | 571 | "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\ | |
| @@ -565,7 +578,7 @@ pub(super) async fn health( | |||
| 565 | 578 | return Ok(body.into_response()); | |
| 566 | 579 | } | |
| 567 | 580 | ||
| 568 | - | let data = collect_health(&state).await; | |
| 581 | + | let data = collect_health(&db, &storage, &payments, &ops, &config).await; | |
| 569 | 582 | ||
| 570 | 583 | let now = chrono::Utc::now(); | |
| 571 | 584 | ||
| @@ -697,8 +710,8 @@ pub(super) async fn health( | |||
| 697 | 710 | /// This is the cheap read shared by `GET /api/health` and the anonymous | |
| 698 | 711 | /// `/health` HTML page, so both agree on status without either running the live | |
| 699 | 712 | /// `collect_health` subsystem fan-out. Returns `(overall, db_ok)`. | |
| 700 | - | async fn cached_health_status(state: &AppState) -> (OverallStatus, bool) { | |
| 701 | - | let latest = db::monitor::get_recent_health_history(&state.db, 1) | |
| 713 | + | async fn cached_health_status(db: &PgPool) -> (OverallStatus, bool) { | |
| 714 | + | let latest = db::monitor::get_recent_health_history(db, 1) | |
| 702 | 715 | .await | |
| 703 | 716 | .unwrap_or_default(); | |
| 704 | 717 | if let Some(snap) = latest.first() { | |
| @@ -712,7 +725,7 @@ async fn cached_health_status(state: &AppState) -> (OverallStatus, bool) { | |||
| 712 | 725 | } else { | |
| 713 | 726 | // No monitor data yet — single minimal probe. | |
| 714 | 727 | let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1") | |
| 715 | - | .fetch_one(&state.db) | |
| 728 | + | .fetch_one(db) | |
| 716 | 729 | .await | |
| 717 | 730 | .is_ok(); | |
| 718 | 731 | let status = if db_ok { OverallStatus::Operational } else { OverallStatus::Error }; | |
| @@ -726,11 +739,11 @@ async fn cached_health_status(state: &AppState) -> (OverallStatus, bool) { | |||
| 726 | 739 | /// running live probes. Returns 200 if operational or degraded, 503 if error. | |
| 727 | 740 | #[tracing::instrument(skip_all, name = "health::health_json")] | |
| 728 | 741 | pub(super) async fn health_json( | |
| 729 | - | State(state): State<AppState>, | |
| 742 | + | State(db): State<PgPool>, | |
| 730 | 743 | ) -> impl IntoResponse { | |
| 731 | 744 | // Cached monitor data only — no live probes (fast <10ms as documented), | |
| 732 | 745 | // with a single-`SELECT 1` fallback before the first monitor tick. | |
| 733 | - | let (overall, db_ok) = cached_health_status(&state).await; | |
| 746 | + | let (overall, db_ok) = cached_health_status(&db).await; | |
| 734 | 747 | ||
| 735 | 748 | let http_status = if overall == OverallStatus::Error { | |
| 736 | 749 | StatusCode::SERVICE_UNAVAILABLE |
| @@ -11,17 +11,19 @@ use axum::{ | |||
| 11 | 11 | Form, | |
| 12 | 12 | }; | |
| 13 | 13 | use serde::Deserialize; | |
| 14 | + | use sqlx::PgPool; | |
| 14 | 15 | use tower_sessions::Session; | |
| 15 | 16 | ||
| 16 | 17 | use crate::{ | |
| 17 | 18 | auth::{hash_password_async, login_user, track_session, AuthUser, MaybeUserVerified, SessionUser}, | |
| 19 | + | background::BackgroundTx, | |
| 20 | + | config::Config, | |
| 18 | 21 | db::{self}, | |
| 19 | - | email, | |
| 22 | + | email::{self, EmailClient}, | |
| 20 | 23 | error::{AppError, Result}, | |
| 21 | 24 | helpers::{get_csrf_token, is_htmx_request}, | |
| 22 | 25 | routes::pages::dashboard::wizards::build_step_nav, | |
| 23 | 26 | templates::*, | |
| 24 | - | AppState, | |
| 25 | 27 | }; | |
| 26 | 28 | ||
| 27 | 29 | const JOIN_STEPS: &[&str] = &["account", "profile", "complete"]; | |
| @@ -68,7 +70,10 @@ pub struct AccountForm { | |||
| 68 | 70 | /// POST `/join/step/account`: create account and log in, then return step 2. | |
| 69 | 71 | #[tracing::instrument(skip_all, name = "join_wizard::account_create")] | |
| 70 | 72 | pub async fn step_account_create( | |
| 71 | - | State(state): State<AppState>, | |
| 73 | + | State(db): State<PgPool>, | |
| 74 | + | State(config): State<Config>, | |
| 75 | + | State(mailer): State<EmailClient>, | |
| 76 | + | State(bg): State<BackgroundTx>, | |
| 72 | 77 | headers: HeaderMap, | |
| 73 | 78 | session: Session, | |
| 74 | 79 | Form(form): Form<AccountForm>, | |
| @@ -120,10 +125,10 @@ pub async fn step_account_create( | |||
| 120 | 125 | }; | |
| 121 | 126 | ||
| 122 | 127 | // Check uniqueness | |
| 123 | - | let username_taken = db::users::get_user_by_username(&state.db, &username) | |
| 128 | + | let username_taken = db::users::get_user_by_username(&db, &username) | |
| 124 | 129 | .await? | |
| 125 | 130 | .is_some(); | |
| 126 | - | let email_taken = db::users::get_user_by_email(&state.db, &email) | |
| 131 | + | let email_taken = db::users::get_user_by_email(&db, &email) | |
| 127 | 132 | .await? | |
| 128 | 133 | .is_some(); | |
| 129 | 134 | // Username collisions are safe to reveal — usernames are public handles | |
| @@ -140,11 +145,11 @@ pub async fn step_account_create( | |||
| 140 | 145 | return return_error(Some("username"), "This username is already taken"); | |
| 141 | 146 | } | |
| 142 | 147 | if email_taken { | |
| 143 | - | let login_url = format!("{}/login", state.config.host_url); | |
| 144 | - | let reset_url = format!("{}/forgot-password", state.config.host_url); | |
| 145 | - | let email_client = state.email.clone(); | |
| 148 | + | let login_url = format!("{}/login", config.host_url); | |
| 149 | + | let reset_url = format!("{}/forgot-password", config.host_url); | |
| 150 | + | let email_client = mailer.clone(); | |
| 146 | 151 | let to_email = email.to_string(); | |
| 147 | - | state.bg.spawn("account-exists notice", async move { | |
| 152 | + | bg.spawn("account-exists notice", async move { | |
| 148 | 153 | if let Err(e) = email_client | |
| 149 | 154 | .send_account_exists(&to_email, &login_url, &reset_url) | |
| 150 | 155 | .await | |
| @@ -184,7 +189,7 @@ pub async fn step_account_create( | |||
| 184 | 189 | // surface as a validation error so the user sees a friendly message | |
| 185 | 190 | // (with their typed values preserved) instead of a 500. | |
| 186 | 191 | let password_hash = hash_password_async(form.password.clone()).await?; | |
| 187 | - | let user = match db::users::create_user(&state.db, &username, &email, &password_hash).await { | |
| 192 | + | let user = match db::users::create_user(&db, &username, &email, &password_hash).await { | |
| 188 | 193 | Ok(u) => u, | |
| 189 | 194 | Err(AppError::Database(sqlx::Error::Database(ref db_err))) | |
| 190 | 195 | if db_err.code().as_deref() == Some("23505") => | |
| @@ -206,21 +211,21 @@ pub async fn step_account_create( | |||
| 206 | 211 | if let Some(ref code_raw) = form.invite_code { | |
| 207 | 212 | let code = code_raw.replace('-', "").trim().to_uppercase(); | |
| 208 | 213 | if !code.is_empty() | |
| 209 | - | && let Some(invite) = db::invites::get_valid_invite_code(&state.db, &code).await? | |
| 214 | + | && let Some(invite) = db::invites::get_valid_invite_code(&db, &code).await? | |
| 210 | 215 | // Atomic claim: if a concurrent signup redeemed the same code first, | |
| 211 | 216 | // this returns false and we skip the invite side-effects (the signup | |
| 212 | 217 | // itself already succeeded). | |
| 213 | - | && db::invites::redeem_invite_code(&state.db, invite.id, user.id).await? | |
| 218 | + | && db::invites::redeem_invite_code(&db, invite.id, user.id).await? | |
| 214 | 219 | { | |
| 215 | - | db::waitlist::create_invited_waitlist_entry(&state.db, user.id, invite.creator_id) | |
| 220 | + | db::waitlist::create_invited_waitlist_entry(&db, user.id, invite.creator_id) | |
| 216 | 221 | .await?; | |
| 217 | 222 | ||
| 218 | 223 | // Fire-and-forget: notify the inviter | |
| 219 | 224 | let inviter_id = invite.creator_id; | |
| 220 | 225 | let invitee_username = user.username.to_string(); | |
| 221 | - | let email_client = state.email.clone(); | |
| 222 | - | let db_pool = state.db.clone(); | |
| 223 | - | state.bg.spawn("invite-redeemed notification", async move { | |
| 226 | + | let email_client = mailer.clone(); | |
| 227 | + | let db_pool = db.clone(); | |
| 228 | + | bg.spawn("invite-redeemed notification", async move { | |
| 224 | 229 | if let Ok(Some(inviter)) = db::users::get_user_by_id(&db_pool, inviter_id).await { | |
| 225 | 230 | let _ = email_client | |
| 226 | 231 | .send_invite_redeemed( | |
| @@ -254,19 +259,19 @@ pub async fn step_account_create( | |||
| 254 | 259 | is_sandbox: false, | |
| 255 | 260 | }; | |
| 256 | 261 | login_user(&session, session_user).await?; | |
| 257 | - | track_session(&session, &state.db, user_id, &headers).await?; | |
| 262 | + | track_session(&session, &db, user_id, &headers).await?; | |
| 258 | 263 | ||
| 259 | 264 | // Send verification + welcome emails (async) | |
| 260 | 265 | let verify_url = email::generate_verification_url( | |
| 261 | - | &state.config.host_url, | |
| 266 | + | &config.host_url, | |
| 262 | 267 | user_id, | |
| 263 | 268 | &user_email, | |
| 264 | - | &state.config.signing_secret, | |
| 269 | + | &config.signing_secret, | |
| 265 | 270 | ); | |
| 266 | - | let email_client = state.email.clone(); | |
| 267 | - | let welcome_host_url = state.config.host_url.clone(); | |
| 268 | - | let welcome_db = state.db.clone(); | |
| 269 | - | state.bg.spawn("signup verification + welcome emails", async move { | |
| 271 | + | let email_client = mailer.clone(); | |
| 272 | + | let welcome_host_url = config.host_url.clone(); | |
| 273 | + | let welcome_db = db.clone(); | |
| 274 | + | bg.spawn("signup verification + welcome emails", async move { | |
| 270 | 275 | if let Err(e) = email_client | |
| 271 | 276 | .send_verification(&user_email, user_display_name.as_deref(), &verify_url) | |
| 272 | 277 | .await | |
| @@ -295,19 +300,19 @@ pub async fn step_account_create( | |||
| 295 | 300 | /// GET `/join/step/{step}`: load a step partial (for back navigation). | |
| 296 | 301 | #[tracing::instrument(skip_all, name = "join_wizard::step_load")] | |
| 297 | 302 | pub async fn step_load( | |
| 298 | - | State(state): State<AppState>, | |
| 303 | + | State(db): State<PgPool>, | |
| 299 | 304 | AuthUser(user): AuthUser, | |
| 300 | 305 | session: Session, | |
| 301 | 306 | Path(step): Path<String>, | |
| 302 | 307 | ) -> Result<Response> { | |
| 303 | 308 | let csrf_token = get_csrf_token(&session).await; | |
| 304 | - | render_step(&step, &state, user.id, csrf_token).await | |
| 309 | + | render_step(&step, &db, user.id, csrf_token).await | |
| 305 | 310 | } | |
| 306 | 311 | ||
| 307 | 312 | /// POST `/join/step/{step}`: save and return next step. | |
| 308 | 313 | #[tracing::instrument(skip_all, name = "join_wizard::step_save")] | |
| 309 | 314 | pub async fn step_save( | |
| 310 | - | State(state): State<AppState>, | |
| 315 | + | State(db): State<PgPool>, | |
| 311 | 316 | AuthUser(user): AuthUser, | |
| 312 | 317 | Path(step): Path<String>, | |
| 313 | 318 | Form(form_data): Form<std::collections::HashMap<String, String>>, | |
| @@ -320,14 +325,14 @@ pub async fn step_save( | |||
| 320 | 325 | let has_bio = bio.as_ref().is_some_and(|s: &String| !s.is_empty()); | |
| 321 | 326 | if has_display_name || has_bio { | |
| 322 | 327 | db::users::update_user_profile( | |
| 323 | - | &state.db, | |
| 328 | + | &db, | |
| 324 | 329 | user.id, | |
| 325 | 330 | display_name.as_ref().filter(|s: &&String| !s.is_empty()).map(|s| s.as_str()), | |
| 326 | 331 | bio.as_ref().filter(|s: &&String| !s.is_empty()).map(|s| s.as_str()), | |
| 327 | 332 | ) | |
| 328 | 333 | .await?; | |
| 329 | 334 | } | |
| 330 | - | render_step("complete", &state, user.id, None).await | |
| 335 | + | render_step("complete", &db, user.id, None).await | |
| 331 | 336 | } | |
| 332 | 337 | _ => Err(AppError::NotFound), | |
| 333 | 338 | } | |
| @@ -342,7 +347,7 @@ fn render_step_profile() -> Response { | |||
| 342 | 347 | } | |
| 343 | 348 | ||
| 344 | 349 | /// Render a step partial with the sidebar nav. | |
| 345 | - | async fn render_step(step: &str, state: &AppState, user_id: db::UserId, csrf_token: Option<String>) -> Result<Response> { | |
| 350 | + | async fn render_step(step: &str, db: &PgPool, user_id: db::UserId, csrf_token: Option<String>) -> Result<Response> { | |
| 346 | 351 | match step { | |
| 347 | 352 | "account" => { | |
| 348 | 353 | // Thread a real CSRF token so a back-nav to the account step renders a | |
| @@ -360,10 +365,10 @@ async fn render_step(step: &str, state: &AppState, user_id: db::UserId, csrf_tok | |||
| 360 | 365 | } | |
| 361 | 366 | "profile" => Ok(render_step_profile()), | |
| 362 | 367 | "complete" => { | |
| 363 | - | let user = db::users::get_user_by_id(&state.db, user_id) | |
| 368 | + | let user = db::users::get_user_by_id(db, user_id) | |
| 364 | 369 | .await? | |
| 365 | 370 | .ok_or(AppError::NotFound)?; | |
| 366 | - | let has_invite = db::waitlist::get_waitlist_entry_by_user(&state.db, user_id) | |
| 371 | + | let has_invite = db::waitlist::get_waitlist_entry_by_user(db, user_id) | |
| 367 | 372 | .await? | |
| 368 | 373 | .is_some(); | |
| 369 | 374 | Ok(WizardJoinCompleteTemplate { |
| @@ -6,10 +6,12 @@ use axum::{ | |||
| 6 | 6 | response::{IntoResponse, Redirect, Response}, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Deserialize; | |
| 9 | + | use sqlx::PgPool; | |
| 9 | 10 | use tower_sessions::Session; | |
| 10 | 11 | ||
| 11 | 12 | use crate::{ | |
| 12 | 13 | auth::{AuthUser, MaybeUserUnverified}, | |
| 14 | + | config::Config, | |
| 13 | 15 | constants, | |
| 14 | 16 | db, | |
| 15 | 17 | error::{AppError, Result}, | |
| @@ -17,7 +19,7 @@ use crate::{ | |||
| 17 | 19 | routes::custom_domain, | |
| 18 | 20 | templates::*, | |
| 19 | 21 | types::*, | |
| 20 | - | AppState, | |
| 22 | + | AppState, Billing, | |
| 21 | 23 | }; | |
| 22 | 24 | ||
| 23 | 25 | /// Render the landing page, or redirect authenticated users to the library. | |
| @@ -114,14 +116,15 @@ pub(super) async fn index( | |||
| 114 | 116 | /// Render the authenticated user's library with inline purchases tab. | |
| 115 | 117 | #[tracing::instrument(skip_all, name = "landing::library")] | |
| 116 | 118 | pub(super) async fn library( | |
| 117 | - | State(state): State<AppState>, | |
| 119 | + | State(db): State<PgPool>, | |
| 120 | + | State(config): State<Config>, | |
| 118 | 121 | session: Session, | |
| 119 | 122 | AuthUser(user): AuthUser, | |
| 120 | 123 | ) -> Result<impl IntoResponse> { | |
| 121 | - | let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?; | |
| 122 | - | let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?; | |
| 124 | + | let purchases = db::transactions::get_user_purchases(&db, user.id).await?; | |
| 125 | + | let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?; | |
| 123 | 126 | let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect(); | |
| 124 | - | let has_mt_memberships = state.config.integrations.mt_base_url.is_some(); | |
| 127 | + | let has_mt_memberships = config.integrations.mt_base_url.is_some(); | |
| 125 | 128 | Ok(LibraryTemplate { | |
| 126 | 129 | csrf_token: get_csrf_token(&session).await, | |
| 127 | 130 | session_user: Some(user), | |
| @@ -140,7 +143,7 @@ pub(super) struct CartQuery { | |||
| 140 | 143 | /// Render the shopping cart page with items grouped by seller. | |
| 141 | 144 | #[tracing::instrument(skip_all, name = "landing::cart_page")] | |
| 142 | 145 | pub(super) async fn cart_page( | |
| 143 | - | State(state): State<AppState>, | |
| 146 | + | State(db): State<PgPool>, | |
| 144 | 147 | session: Session, | |
| 145 | 148 | AuthUser(user): AuthUser, | |
| 146 | 149 | Query(query): Query<CartQuery>, | |
| @@ -148,7 +151,7 @@ pub(super) async fn cart_page( | |||
| 148 | 151 | use std::collections::BTreeMap; | |
| 149 | 152 | use crate::templates::CartSellerGroup; | |
| 150 | 153 | ||
| 151 | - | let cart_items = db::cart::get_cart_items(&state.db, user.id).await?; | |
| 154 | + | let cart_items = db::cart::get_cart_items(&db, user.id).await?; | |
| 152 | 155 | ||
| 153 | 156 | // Group by seller | |
| 154 | 157 | let mut groups: BTreeMap<String, Vec<db::cart::CartItem>> = BTreeMap::new(); | |
| @@ -187,7 +190,7 @@ pub(super) async fn cart_page( | |||
| 187 | 190 | let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum(); | |
| 188 | 191 | ||
| 189 | 192 | // Wishlist suggestions: items in wishlist but not in cart | |
| 190 | - | let wishlist = db::wishlists::get_wishlist(&state.db, user.id).await?; | |
| 193 | + | let wishlist = db::wishlists::get_wishlist(&db, user.id).await?; | |
| 191 | 194 | let cart_item_ids: std::collections::HashSet<_> = cart_items.iter().map(|i| i.item_id).collect(); | |
| 192 | 195 | let wishlist_suggestions: Vec<_> = wishlist | |
| 193 | 196 | .into_iter() | |
| @@ -208,11 +211,11 @@ pub(super) async fn cart_page( | |||
| 208 | 211 | /// HTMX partial: library purchases tab (includes subscriptions). | |
| 209 | 212 | #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")] | |
| 210 | 213 | pub(super) async fn library_tab_purchases( | |
| 211 | - | State(state): State<AppState>, | |
| 214 | + | State(db): State<PgPool>, | |
| 212 | 215 | AuthUser(user): AuthUser, | |
| 213 | 216 | ) -> Result<impl IntoResponse> { | |
| 214 | - | let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?; | |
| 215 | - | let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?; | |
| 217 | + | let purchases = db::transactions::get_user_purchases(&db, user.id).await?; | |
| 218 | + | let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?; | |
| 216 | 219 | let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect(); | |
| 217 | 220 | Ok(LibraryPurchasesTabTemplate { purchases, subscriptions }) | |
| 218 | 221 | } | |
| @@ -220,7 +223,7 @@ pub(super) async fn library_tab_purchases( | |||
| 220 | 223 | /// HTMX partial: library feed tab. | |
| 221 | 224 | #[tracing::instrument(skip_all, name = "landing::library_tab_feed")] | |
| 222 | 225 | pub(super) async fn library_tab_feed( | |
| 223 | - | State(state): State<AppState>, | |
| 226 | + | State(db): State<PgPool>, | |
| 224 | 227 | AuthUser(user): AuthUser, | |
| 225 | 228 | Query(query): Query<super::feed::FeedQuery>, | |
| 226 | 229 | ) -> Result<impl IntoResponse> { | |
| @@ -232,11 +235,11 @@ pub(super) async fn library_tab_feed( | |||
| 232 | 235 | let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000); | |
| 233 | 236 | let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64; | |
| 234 | 237 | ||
| 235 | - | let total_items = db::follows::count_followed_feed_items(&state.db, user.id).await? as u32; | |
| 238 | + | let total_items = db::follows::count_followed_feed_items(&db, user.id).await? as u32; | |
| 236 | 239 | let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1); | |
| 237 | 240 | ||
| 238 | 241 | let db_items = db::follows::get_followed_feed_items( | |
| 239 | - | &state.db, | |
| 242 | + | &db, | |
| 240 | 243 | user.id, | |
| 241 | 244 | constants::FEED_PAGE_SIZE as i64, | |
| 242 | 245 | offset, | |
| @@ -272,12 +275,12 @@ pub(super) async fn library_tab_feed( | |||
| 272 | 275 | /// HTMX partial: library collections tab (includes wishlists). | |
| 273 | 276 | #[tracing::instrument(skip_all, name = "landing::library_tab_collections")] | |
| 274 | 277 | pub(super) async fn library_tab_collections( | |
| 275 | - | State(state): State<AppState>, | |
| 278 | + | State(db): State<PgPool>, | |
| 276 | 279 | AuthUser(user): AuthUser, | |
| 277 | 280 | ) -> Result<impl IntoResponse> { | |
| 278 | - | let db_collections = db::collections::get_collections_by_user(&state.db, user.id).await?; | |
| 281 | + | let db_collections = db::collections::get_collections_by_user(&db, user.id).await?; | |
| 279 | 282 | let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect(); | |
| 280 | - | let wishlists = db::wishlists::get_wishlist(&state.db, user.id).await?; | |
| 283 | + | let wishlists = db::wishlists::get_wishlist(&db, user.id).await?; | |
| 281 | 284 | Ok(LibraryCollectionsTabTemplate { | |
| 282 | 285 | collections, | |
| 283 | 286 | username: user.username.to_string(), | |
| @@ -288,17 +291,17 @@ pub(super) async fn library_tab_collections( | |||
| 288 | 291 | /// HTMX partial: library contacts tab. | |
| 289 | 292 | #[tracing::instrument(skip_all, name = "landing::library_tab_contacts")] | |
| 290 | 293 | pub(super) async fn library_tab_contacts( | |
| 291 | - | State(state): State<AppState>, | |
| 294 | + | State(db): State<PgPool>, | |
| 292 | 295 | AuthUser(user): AuthUser, | |
| 293 | 296 | ) -> Result<impl IntoResponse> { | |
| 294 | - | let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?; | |
| 297 | + | let shared_creators = db::transactions::get_shared_creators(&db, user.id).await?; | |
| 295 | 298 | ||
| 296 | 299 | // Fetch seller contacts (buyers who shared their email) if user is a creator | |
| 297 | - | let db_user = db::users::get_user_by_id(&state.db, user.id) | |
| 300 | + | let db_user = db::users::get_user_by_id(&db, user.id) | |
| 298 | 301 | .await? | |
| 299 | 302 | .ok_or(AppError::NotFound)?; | |
| 300 | 303 | let db_contacts = if db_user.can_create_projects { | |
| 301 | - | db::transactions::get_seller_contacts(&state.db, user.id).await? | |
| 304 | + | db::transactions::get_seller_contacts(&db, user.id).await? | |
| 302 | 305 | } else { | |
| 303 | 306 | vec![] | |
| 304 | 307 | }; | |
| @@ -320,10 +323,10 @@ pub(super) async fn library_tab_contacts( | |||
| 320 | 323 | /// HTMX partial: library communities tab (Multithreaded forum memberships). | |
| 321 | 324 | #[tracing::instrument(skip_all, name = "landing::library_tab_communities")] | |
| 322 | 325 | pub(super) async fn library_tab_communities( | |
| 323 | - | State(state): State<AppState>, | |
| 326 | + | State(config): State<Config>, | |
| 324 | 327 | AuthUser(user): AuthUser, | |
| 325 | 328 | ) -> Result<axum::response::Response> { | |
| 326 | - | let mt_base_url = match state.config.integrations.mt_base_url.as_ref() { | |
| 329 | + | let mt_base_url = match config.integrations.mt_base_url.as_ref() { | |
| 327 | 330 | Some(url) => url, | |
| 328 | 331 | None => { | |
| 329 | 332 | return Ok(LibraryCommunitiesTabTemplate { | |
| @@ -404,11 +407,11 @@ pub(crate) struct LoginQuery { | |||
| 404 | 407 | /// Render the login page. | |
| 405 | 408 | #[tracing::instrument(skip_all, name = "landing::login_page")] | |
| 406 | 409 | pub(crate) async fn login_page( | |
| 407 | - | State(state): State<AppState>, | |
| 410 | + | State(config): State<Config>, | |
| 408 | 411 | session: Session, | |
| 409 | 412 | Query(query): Query<LoginQuery>, | |
| 410 | 413 | ) -> impl IntoResponse { | |
| 411 | - | let sso_enabled = state.config.sso.is_some(); | |
| 414 | + | let sso_enabled = config.sso.is_some(); | |
| 412 | 415 | let notice = match query.gate.as_deref() { | |
| 413 | 416 | Some("fan_plus_or_creator") => Some( | |
| 414 | 417 | "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue." | |
| @@ -428,15 +431,15 @@ pub(crate) async fn login_page( | |||
| 428 | 431 | /// Render the interactive pricing calculator page. | |
| 429 | 432 | #[tracing::instrument(skip_all, name = "landing::pricing_page")] | |
| 430 | 433 | pub(super) async fn pricing_page( | |
| 431 | - | State(state): State<AppState>, | |
| 434 | + | State(payments): State<Billing>, | |
| 432 | 435 | session: Session, | |
| 433 | 436 | ) -> impl IntoResponse { | |
| 434 | - | let comparison = state | |
| 437 | + | let comparison = payments | |
| 435 | 438 | .pricing_comparison | |
| 436 | - | .compute(PRICING_DEFAULT_REVENUE, state.tier_prices.basic_std as f64); | |
| 439 | + | .compute(PRICING_DEFAULT_REVENUE, payments.tier_prices.basic_std as f64); | |
| 437 | 440 | PricingTemplate { | |
| 438 | 441 | csrf_token: get_csrf_token(&session).await, | |
| 439 | - | tier_prices: state.tier_prices.clone(), | |
| 442 | + | tier_prices: payments.tier_prices.clone(), | |
| 440 | 443 | comparison, | |
| 441 | 444 | } | |
| 442 | 445 | } | |
| @@ -458,7 +461,7 @@ pub(super) struct PricingCompareQuery { | |||
| 458 | 461 | /// Pure computation, no auth, no state change — a GET so no CSRF is needed. | |
| 459 | 462 | #[tracing::instrument(skip_all, name = "landing::pricing_compare")] | |
| 460 | 463 | pub(super) async fn pricing_compare( | |
| 461 | - | State(state): State<AppState>, | |
| 464 | + | State(payments): State<Billing>, | |
| 462 | 465 | Query(q): Query<PricingCompareQuery>, | |
| 463 | 466 | ) -> impl IntoResponse { | |
| 464 | 467 | let parse = |s: Option<String>| { | |
| @@ -468,9 +471,9 @@ pub(super) async fn pricing_compare( | |||
| 468 | 471 | let revenue = parse(q.revenue).unwrap_or(0.0).clamp(0.0, 999_999.0); | |
| 469 | 472 | let tier = parse(q.tier) | |
| 470 | 473 | .filter(|v| *v >= 0.0) | |
| 471 | - | .unwrap_or(state.tier_prices.basic_std as f64); | |
| 474 | + | .unwrap_or(payments.tier_prices.basic_std as f64); | |
| 472 | 475 | PricingComparisonPartial { | |
| 473 | - | comparison: state.pricing_comparison.compute(revenue, tier), | |
| 476 | + | comparison: payments.pricing_comparison.compute(revenue, tier), | |
| 474 | 477 | } | |
| 475 | 478 | } | |
| 476 | 479 | ||
| @@ -484,18 +487,19 @@ pub(super) async fn pricing_compare( | |||
| 484 | 487 | /// no caching needed at current load. | |
| 485 | 488 | #[tracing::instrument(skip_all, name = "landing::economics_page")] | |
| 486 | 489 | pub(super) async fn economics_page( | |
| 487 | - | State(state): State<AppState>, | |
| 490 | + | State(db): State<PgPool>, | |
| 491 | + | State(payments): State<Billing>, | |
| 488 | 492 | session: Session, | |
| 489 | 493 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 490 | 494 | ) -> Result<impl IntoResponse> { | |
| 491 | 495 | let paying_creators = | |
| 492 | - | crate::db::creator_tiers::count_active_paying(&state.db).await?; | |
| 496 | + | crate::db::creator_tiers::count_active_paying(&db).await?; | |
| 493 | 497 | let trialing_or_grace = | |
| 494 | - | crate::db::creator_tiers::count_trialing_or_grace(&state.db).await?; | |
| 498 | + | crate::db::creator_tiers::count_trialing_or_grace(&db).await?; | |
| 495 | 499 | Ok(EconomicsTemplate { | |
| 496 | 500 | csrf_token: get_csrf_token(&session).await, | |
| 497 | 501 | session_user: maybe_user, | |
| 498 | - | runway_config: state.runway_config.clone(), | |
| 502 | + | runway_config: payments.runway_config.clone(), | |
| 499 | 503 | paying_creators, | |
| 500 | 504 | trialing_or_grace, | |
| 501 | 505 | }) | |
| @@ -532,14 +536,14 @@ pub(super) async fn checkout_complete() -> impl IntoResponse { | |||
| 532 | 536 | /// Render the use cases page. | |
| 533 | 537 | #[tracing::instrument(skip_all, name = "landing::use_cases_page")] | |
| 534 | 538 | pub(super) async fn use_cases_page( | |
| 535 | - | State(state): State<AppState>, | |
| 539 | + | State(payments): State<Billing>, | |
| 536 | 540 | session: Session, | |
| 537 | 541 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 538 | 542 | ) -> impl IntoResponse { | |
| 539 | 543 | UseCasesTemplate { | |
| 540 | 544 | csrf_token: get_csrf_token(&session).await, | |
| 541 | 545 | session_user: maybe_user, | |
| 542 | - | tier_prices: state.tier_prices.clone(), | |
| 546 | + | tier_prices: payments.tier_prices.clone(), | |
| 543 | 547 | } | |
| 544 | 548 | } | |
| 545 | 549 | ||
| @@ -578,7 +582,7 @@ pub(super) struct FanPlusQuery { | |||
| 578 | 582 | /// Render the Fan+ subscription page. | |
| 579 | 583 | #[tracing::instrument(skip_all, name = "landing::fan_plus_page")] | |
| 580 | 584 | pub(super) async fn fan_plus_page( | |
| 581 | - | State(state): State<AppState>, | |
| 585 | + | State(db): State<PgPool>, | |
| 582 | 586 | session: Session, | |
| 583 | 587 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 584 | 588 | Query(query): Query<FanPlusQuery>, | |
| @@ -586,7 +590,7 @@ pub(super) async fn fan_plus_page( | |||
| 586 | 590 | let csrf_token = get_csrf_token(&session).await; | |
| 587 | 591 | ||
| 588 | 592 | let (is_subscribed, period_end) = if let Some(ref user) = maybe_user { | |
| 589 | - | let fan_sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id).await?; | |
| 593 | + | let fan_sub = db::fan_plus::get_fan_plus_by_user(&db, user.id).await?; | |
| 590 | 594 | match fan_sub { | |
| 591 | 595 | Some(sub) if sub.status == "active" => { | |
| 592 | 596 | let end = sub.current_period_end.map(|d| d.format("%B %-d, %Y").to_string()); |
| @@ -16,6 +16,7 @@ use axum::{ | |||
| 16 | 16 | response::{IntoResponse, Redirect}, | |
| 17 | 17 | routing::get, | |
| 18 | 18 | }; | |
| 19 | + | use sqlx::PgPool; | |
| 19 | 20 | use tower_sessions::Session; | |
| 20 | 21 | ||
| 21 | 22 | use crate::{ | |
| @@ -27,7 +28,7 @@ use crate::{ | |||
| 27 | 28 | helpers::get_csrf_token, | |
| 28 | 29 | templates::*, | |
| 29 | 30 | types::*, | |
| 30 | - | AppState, | |
| 31 | + | AppState, Billing, | |
| 31 | 32 | }; | |
| 32 | 33 | ||
| 33 | 34 | use tower_governor::GovernorLayer; | |
| @@ -128,15 +129,16 @@ pub fn public_routes() -> CsrfRouter<AppState> { | |||
| 128 | 129 | /// Render the public creators page showing invite waves and waitlist stats. | |
| 129 | 130 | #[tracing::instrument(skip_all, name = "pages::creators_page")] | |
| 130 | 131 | async fn creators_page( | |
| 131 | - | State(state): State<AppState>, | |
| 132 | + | State(db): State<PgPool>, | |
| 133 | + | State(payments): State<Billing>, | |
| 132 | 134 | session: Session, | |
| 133 | 135 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| 134 | 136 | ) -> Result<impl IntoResponse> { | |
| 135 | 137 | let csrf_token = get_csrf_token(&session).await; | |
| 136 | 138 | ||
| 137 | - | let waves = db::waitlist::get_all_waves(&state.db).await?; | |
| 138 | - | let total_creators = db::waitlist::count_active_creators(&state.db).await? as u32; | |
| 139 | - | let waitlist_pending = db::waitlist::count_waitlist_pending(&state.db).await? as u32; | |
| 139 | + | let waves = db::waitlist::get_all_waves(&db).await?; | |
| 140 | + | let total_creators = db::waitlist::count_active_creators(&db).await? as u32; | |
| 141 | + | let waitlist_pending = db::waitlist::count_waitlist_pending(&db).await? as u32; | |
| 140 | 142 | ||
| 141 | 143 | let is_creator = maybe_user.as_ref().map(|u| u.can_create_projects).unwrap_or(false); | |
| 142 | 144 | ||
| @@ -149,6 +151,6 @@ async fn creators_page( | |||
| 149 | 151 | total_creators, | |
| 150 | 152 | waitlist_pending, | |
| 151 | 153 | is_creator, | |
| 152 | - | tier_prices: state.tier_prices.clone(), | |
| 154 | + | tier_prices: payments.tier_prices.clone(), | |
| 153 | 155 | }) | |
| 154 | 156 | } |
| @@ -9,8 +9,9 @@ use axum::{ | |||
| 9 | 9 | http::header, | |
| 10 | 10 | }; | |
| 11 | 11 | use chrono::{DateTime, Utc}; | |
| 12 | + | use sqlx::PgPool; | |
| 12 | 13 | ||
| 13 | - | use crate::{error::Result, AppState}; | |
| 14 | + | use crate::{config::Config, error::Result}; | |
| 14 | 15 | ||
| 15 | 16 | /// In-memory cache for the rendered sitemap XML. Crawlers hit this rarely | |
| 16 | 17 | /// and the same response is fine for ~10 min; without the cache an attacker | |
| @@ -22,8 +23,8 @@ static SITEMAP_CACHE: OnceLock<Mutex<Option<(Instant, String)>>> = OnceLock::new | |||
| 22 | 23 | /// `/robots.txt`. Permits indexing the public surface; blocks the dashboard, | |
| 23 | 24 | /// API, admin tooling, and authentication paths where indexed URLs would | |
| 24 | 25 | /// be noise or actively harmful (login-page leakage, stale checkout URLs). | |
| 25 | - | pub(super) async fn robots_txt(State(state): State<AppState>) -> impl IntoResponse { | |
| 26 | - | let host = &state.config.host_url; | |
| 26 | + | pub(super) async fn robots_txt(State(config): State<Config>) -> impl IntoResponse { | |
| 27 | + | let host = &config.host_url; | |
| 27 | 28 | let body = format!( | |
| 28 | 29 | "User-agent: *\n\ | |
| 29 | 30 | Disallow: /dashboard\n\ | |
| @@ -55,7 +56,10 @@ pub(super) async fn robots_txt(State(state): State<AppState>) -> impl IntoRespon | |||
| 55 | 56 | /// profiles and their public items. Capped to keep the response bounded and | |
| 56 | 57 | /// cached for SITEMAP_CACHE_TTL to absorb crawler-or-attacker hammering | |
| 57 | 58 | /// without firing two large `fetch_all` queries per request. | |
| 58 | - | pub(super) async fn sitemap_xml(State(state): State<AppState>) -> Result<Response> { | |
| 59 | + | pub(super) async fn sitemap_xml( | |
| 60 | + | State(db): State<PgPool>, | |
| 61 | + | State(config): State<Config>, | |
| 62 | + | ) -> Result<Response> { | |
| 59 | 63 | let cache = SITEMAP_CACHE.get_or_init(|| Mutex::new(None)); | |
| 60 | 64 | if let Ok(guard) = cache.lock() | |
| 61 | 65 | && let Some((generated_at, cached_xml)) = guard.as_ref() | |
| @@ -68,7 +72,7 @@ pub(super) async fn sitemap_xml(State(state): State<AppState>) -> Result<Respons | |||
| 68 | 72 | .into_response()); | |
| 69 | 73 | } | |
| 70 | 74 | ||
| 71 | - | let host = state.config.host_url.trim_end_matches('/').to_string(); | |
| 75 | + | let host = config.host_url.trim_end_matches('/').to_string(); | |
| 72 | 76 | ||
| 73 | 77 | // Active creator usernames (anyone with at least one public, listed, | |
| 74 | 78 | // non-deleted item under a public project). | |
| @@ -86,7 +90,7 @@ pub(super) async fn sitemap_xml(State(state): State<AppState>) -> Result<Respons | |||
| 86 | 90 | LIMIT 5000 | |
| 87 | 91 | "#, | |
| 88 | 92 | ) | |
| 89 | - | .fetch_all(&state.db) | |
| 93 | + | .fetch_all(&db) | |
| 90 | 94 | .await?; | |
| 91 | 95 | ||
| 92 | 96 | // Public items (id + updated_at for <lastmod>). | |
| @@ -103,7 +107,7 @@ pub(super) async fn sitemap_xml(State(state): State<AppState>) -> Result<Respons | |||
| 103 | 107 | LIMIT 20000 | |
| 104 | 108 | "#, | |
| 105 | 109 | ) | |
| 106 | - | .fetch_all(&state.db) | |
| 110 | + | .fetch_all(&db) | |
| 107 | 111 | .await?; | |
| 108 | 112 | ||
| 109 | 113 | let mut xml = String::with_capacity(64 * 1024); |
| @@ -12,13 +12,15 @@ use tower_sessions::{Expiry, Session}; | |||
| 12 | 12 | ||
| 13 | 13 | use crate::{ | |
| 14 | 14 | auth::{login_user, track_session, SessionUser}, | |
| 15 | + | background::BackgroundTx, | |
| 16 | + | config::Config, | |
| 15 | 17 | constants, | |
| 16 | 18 | db::{self, UserId}, | |
| 19 | + | email::EmailClient, | |
| 17 | 20 | error::{AppError, Result, ResultExt}, | |
| 18 | - | helpers::{get_csrf_token, is_htmx_request, spawn_email}, | |
| 21 | + | helpers::{get_csrf_token, is_htmx_request}, | |
| 19 | 22 | routes::api::totp::{build_totp, find_matching_step}, | |
| 20 | 23 | templates::*, | |
| 21 | - | AppState, | |
| 22 | 24 | }; | |
| 23 | 25 | ||
| 24 | 26 | /// Session key for the pending 2FA user ID. | |
| @@ -68,7 +70,7 @@ async fn pending_2fa_expired(session: &Session) -> bool { | |||
| 68 | 70 | /// Render the 2FA verification page (GET /auth/2fa). | |
| 69 | 71 | #[tracing::instrument(skip_all, name = "two_factor::two_factor_page")] | |
| 70 | 72 | pub(super) async fn two_factor_page( | |
| 71 | - | State(state): State<AppState>, | |
| 73 | + | State(db): State<PgPool>, | |
| 72 | 74 | session: Session, | |
| 73 | 75 | ) -> Result<Response> { | |
| 74 | 76 | // Verify the user is in a valid 2FA flow | |
| @@ -79,7 +81,7 @@ pub(super) async fn two_factor_page( | |||
| 79 | 81 | .ok_or_else(|| AppError::BadRequest("No pending 2FA session".to_string()))?; | |
| 80 | 82 | ||
| 81 | 83 | if pending_2fa_expired(&session).await { | |
| 82 | - | clear_pending_2fa(&session, &state.db).await; | |
| 84 | + | clear_pending_2fa(&session, &db).await; | |
| 83 | 85 | return Err(AppError::BadRequest( | |
| 84 | 86 | "Your 2FA session expired. Please log in again.".to_string(), | |
| 85 | 87 | )); | |
| @@ -92,9 +94,9 @@ pub(super) async fn two_factor_page( | |||
| 92 | 94 | .await | |
| 93 | 95 | .ok() | |
| 94 | 96 | .flatten() | |
| 95 | - | && !db::sessions::pending_2fa_session_exists(&state.db, tracking_id, user_id).await? | |
| 97 | + | && !db::sessions::pending_2fa_session_exists(&db, tracking_id, user_id).await? | |
| 96 | 98 | { | |
| 97 | - | clear_pending_2fa(&session, &state.db).await; | |
| 99 | + | clear_pending_2fa(&session, &db).await; | |
| 98 | 100 | return Err(AppError::BadRequest( | |
| 99 | 101 | "Your session was revoked. Please log in again.".to_string(), | |
| 100 | 102 | )); | |
| @@ -119,7 +121,10 @@ pub struct VerifyTwoFactorForm { | |||
| 119 | 121 | /// Verify the TOTP or backup code and complete login (POST /auth/verify-2fa). | |
| 120 | 122 | #[tracing::instrument(skip_all, name = "two_factor::verify_two_factor")] | |
| 121 | 123 | pub(super) async fn verify_two_factor( | |
| 122 | - | State(state): State<AppState>, | |
| 124 | + | State(db): State<PgPool>, | |
| 125 | + | State(config): State<Config>, | |
| 126 | + | State(email): State<EmailClient>, | |
| 127 | + | State(bg): State<BackgroundTx>, | |
| 123 | 128 | headers: HeaderMap, | |
| 124 | 129 | session: Session, | |
| 125 | 130 | Form(form): Form<VerifyTwoFactorForm>, | |
| @@ -133,7 +138,7 @@ pub(super) async fn verify_two_factor( | |||
| 133 | 138 | .ok_or_else(|| AppError::BadRequest("No pending 2FA session".to_string()))?; | |
| 134 | 139 | ||
| 135 | 140 | if pending_2fa_expired(&session).await { | |
| 136 | - | clear_pending_2fa(&session, &state.db).await; | |
| 141 | + | clear_pending_2fa(&session, &db).await; | |
| 137 | 142 | return Err(AppError::BadRequest( | |
| 138 | 143 | "Your 2FA session expired. Please log in again.".to_string(), | |
| 139 | 144 | )); | |
| @@ -148,15 +153,15 @@ pub(super) async fn verify_two_factor( | |||
| 148 | 153 | .await | |
| 149 | 154 | .ok() | |
| 150 | 155 | .flatten() | |
| 151 | - | && !db::sessions::pending_2fa_session_exists(&state.db, tracking_id, user_id).await? | |
| 156 | + | && !db::sessions::pending_2fa_session_exists(&db, tracking_id, user_id).await? | |
| 152 | 157 | { | |
| 153 | - | clear_pending_2fa(&session, &state.db).await; | |
| 158 | + | clear_pending_2fa(&session, &db).await; | |
| 154 | 159 | return Err(AppError::BadRequest( | |
| 155 | 160 | "Your session was revoked. Please log in again.".to_string(), | |
| 156 | 161 | )); | |
| 157 | 162 | } | |
| 158 | 163 | ||
| 159 | - | let user = db::users::get_user_by_id(&state.db, user_id) | |
| 164 | + | let user = db::users::get_user_by_id(&db, user_id) | |
| 160 | 165 | .await? | |
| 161 | 166 | .ok_or(AppError::Unauthorized)?; | |
| 162 | 167 | ||
| @@ -165,7 +170,7 @@ pub(super) async fn verify_two_factor( | |||
| 165 | 170 | if let Some(locked_until) = user.locked_until | |
| 166 | 171 | && locked_until > chrono::Utc::now() | |
| 167 | 172 | { | |
| 168 | - | clear_pending_2fa(&session, &state.db).await; | |
| 173 | + | clear_pending_2fa(&session, &db).await; | |
| 169 | 174 | let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1; | |
| 170 | 175 | let csrf_token = get_csrf_token(&session).await; | |
| 171 | 176 | return Ok(TwoFactorTemplate { | |
| @@ -189,7 +194,7 @@ pub(super) async fn verify_two_factor( | |||
| 189 | 194 | // the whole login — that would also skip the backup-code path below and lock | |
| 190 | 195 | // the user out entirely. Log it and fall through to backup codes instead. | |
| 191 | 196 | if let Some(ref stored_secret) = user.totp_secret { | |
| 192 | - | match crate::crypto::decrypt_totp_secret(stored_secret, &state.config.signing_secret) { | |
| 197 | + | match crate::crypto::decrypt_totp_secret(stored_secret, &config.signing_secret) { | |
| 193 | 198 | Ok(secret) => { | |
| 194 | 199 | let totp = build_totp(&secret, &user.email)?; | |
| 195 | 200 | // Find the actual step that matched (not just wall-clock step) to | |
| @@ -202,7 +207,7 @@ pub(super) async fn verify_two_factor( | |||
| 202 | 207 | // concurrent submission of the same code that already advanced | |
| 203 | 208 | // the step returns false here and falls through to backup | |
| 204 | 209 | // codes instead of being accepted twice. | |
| 205 | - | verified = db::totp::set_totp_last_used_step(&state.db, user_id, step).await?; | |
| 210 | + | verified = db::totp::set_totp_last_used_step(&db, user_id, step).await?; | |
| 206 | 211 | } | |
| 207 | 212 | } | |
| 208 | 213 | Err(e) => { | |
| @@ -223,10 +228,10 @@ pub(super) async fn verify_two_factor( | |||
| 223 | 228 | // `verify_and_consume_backup_code` per row. | |
| 224 | 229 | if !verified { | |
| 225 | 230 | let legacy_hmac = crate::routes::api::totp::legacy_hmac_backup_code( | |
| 226 | - | &code, &state.config.signing_secret, | |
| 231 | + | &code, &config.signing_secret, | |
| 227 | 232 | ); | |
| 228 | 233 | if db::totp::verify_and_consume_backup_code( | |
| 229 | - | &state.db, user_id, &code, &legacy_hmac, | |
| 234 | + | &db, user_id, &code, &legacy_hmac, | |
| 230 | 235 | ).await? { | |
| 231 | 236 | verified = true; | |
| 232 | 237 | } | |
| @@ -236,7 +241,7 @@ pub(super) async fn verify_two_factor( | |||
| 236 | 241 | // Track failed 2FA attempts toward account lockout (same counter as | |
| 237 | 242 | // failed password attempts — prevents brute-forcing 6-digit TOTP codes) | |
| 238 | 243 | db::auth::increment_failed_login( | |
| 239 | - | &state.db, | |
| 244 | + | &db, | |
| 240 | 245 | user_id, | |
| 241 | 246 | constants::MAX_LOGIN_ATTEMPTS, | |
| 242 | 247 | constants::LOCKOUT_MINUTES, | |
| @@ -244,13 +249,13 @@ pub(super) async fn verify_two_factor( | |||
| 244 | 249 | .await?; | |
| 245 | 250 | ||
| 246 | 251 | // Check if this attempt triggered a lockout | |
| 247 | - | let user_after = db::users::get_user_by_id(&state.db, user_id).await?; | |
| 252 | + | let user_after = db::users::get_user_by_id(&db, user_id).await?; | |
| 248 | 253 | if let Some(ref u) = user_after | |
| 249 | 254 | && let Some(locked_until) = u.locked_until | |
| 250 | 255 | && locked_until > chrono::Utc::now() | |
| 251 | 256 | { | |
| 252 | 257 | // Clear the 2FA flow — account is now locked | |
| 253 | - | clear_pending_2fa(&session, &state.db).await; | |
| 258 | + | clear_pending_2fa(&session, &db).await; | |
| 254 | 259 | let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1; | |
| 255 | 260 | let csrf_token = get_csrf_token(&session).await; | |
| 256 | 261 | return Ok(TwoFactorTemplate { | |
| @@ -274,7 +279,7 @@ pub(super) async fn verify_two_factor( | |||
| 274 | 279 | } | |
| 275 | 280 | ||
| 276 | 281 | // Successful 2FA — reset failed login counter | |
| 277 | - | db::auth::reset_failed_login(&state.db, user_id).await?; | |
| 282 | + | db::auth::reset_failed_login(&db, user_id).await?; | |
| 278 | 283 | ||
| 279 | 284 | // Retrieve stored notification info | |
| 280 | 285 | let notify_enabled: bool = session | |
| @@ -303,23 +308,23 @@ pub(super) async fn verify_two_factor( | |||
| 303 | 308 | .unwrap_or(false); | |
| 304 | 309 | ||
| 305 | 310 | // Clear pending 2FA state | |
| 306 | - | clear_pending_2fa(&session, &state.db).await; | |
| 311 | + | clear_pending_2fa(&session, &db).await; | |
| 307 | 312 | ||
| 308 | 313 | // Complete login | |
| 309 | - | let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await; | |
| 314 | + | let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await; | |
| 310 | 315 | ||
| 311 | 316 | login_user(&session, session_user).await?; | |
| 312 | 317 | if !remember { | |
| 313 | 318 | session.set_expiry(Some(Expiry::OnSessionEnd)); | |
| 314 | 319 | } | |
| 315 | - | track_session(&session, &state.db, user_id, &headers).await?; | |
| 320 | + | track_session(&session, &db, user_id, &headers).await?; | |
| 316 | 321 | tracing::info!(user_id = %user_id, event = "login_2fa_success", "User completed 2FA login"); | |
| 317 | 322 | ||
| 318 | 323 | // Send login notification (same as in auth.rs) | |
| 319 | 324 | if notify_enabled | |
| 320 | 325 | && let Some(email_addr) = notify_email | |
| 321 | 326 | { | |
| 322 | - | let session_count = match db::sessions::count_user_sessions(&state.db, user_id).await { | |
| 327 | + | let session_count = match db::sessions::count_user_sessions(&db, user_id).await { | |
| 323 | 328 | Ok(n) => n, | |
| 324 | 329 | Err(e) => { tracing::warn!("Failed to count sessions for login notification: {e}"); 0 } | |
| 325 | 330 | }; | |
| @@ -330,16 +335,22 @@ pub(super) async fn verify_two_factor( | |||
| 330 | 335 | .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>()); | |
| 331 | 336 | let ip = crate::helpers::extract_client_ip(&headers); | |
| 332 | 337 | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 333 | - | &state.config.host_url, user_id, crate::email::UnsubscribeAction::Login, &user_id.to_string(), &state.config.signing_secret, | |
| 338 | + | &config.host_url, user_id, crate::email::UnsubscribeAction::Login, &user_id.to_string(), &config.signing_secret, | |
| 334 | 339 | ); | |
| 335 | - | spawn_email!(state, "login notification", |email| { | |
| 336 | - | email.send_new_login_notification( | |
| 337 | - | &email_addr, | |
| 338 | - | notify_name.as_deref(), | |
| 339 | - | user_agent.as_deref(), | |
| 340 | - | ip.as_deref(), | |
| 341 | - | Some(&unsub_url), | |
| 342 | - | ) | |
| 340 | + | let email_client = email.clone(); | |
| 341 | + | bg.spawn("login notification", async move { | |
| 342 | + | if let Err(e) = email_client | |
| 343 | + | .send_new_login_notification( | |
| 344 | + | &email_addr, | |
| 345 | + | notify_name.as_deref(), | |
| 346 | + | user_agent.as_deref(), | |
| 347 | + | ip.as_deref(), | |
| 348 | + | Some(&unsub_url), | |
| 349 | + | ) | |
| 350 | + | .await | |
| 351 | + | { | |
| 352 | + | tracing::error!(error = ?e, "failed to send login notification"); | |
| 353 | + | } | |
| 343 | 354 | }); | |
| 344 | 355 | } | |
| 345 | 356 | } |