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