max / makenotwork
5 files changed,
+53 insertions,
-40 deletions
| @@ -21,8 +21,9 @@ use crate::{ | |||
| 21 | 21 | error::{AppError, Result, ResultExt}, | |
| 22 | 22 | helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec, spawn_email}, | |
| 23 | 23 | templates::*, | |
| 24 | - | AppState, | |
| 24 | + | AppCaches, AppState, | |
| 25 | 25 | }; | |
| 26 | + | use sqlx::PgPool; | |
| 26 | 27 | use webauthn_rs::prelude::*; | |
| 27 | 28 | ||
| 28 | 29 | /// Pre-computed Argon2id hash for timing-safe user-not-found responses. | |
| @@ -292,7 +293,8 @@ async fn login_handler( | |||
| 292 | 293 | /// Log out the current user and redirect to the landing page. | |
| 293 | 294 | #[tracing::instrument(skip_all, name = "auth::logout")] | |
| 294 | 295 | async fn logout_handler( | |
| 295 | - | State(state): State<AppState>, | |
| 296 | + | State(db): State<PgPool>, | |
| 297 | + | State(caches): State<AppCaches>, | |
| 296 | 298 | session: Session, | |
| 297 | 299 | ) -> Result<impl IntoResponse> { | |
| 298 | 300 | // Clean up tracking row before flushing session. We require the | |
| @@ -303,10 +305,10 @@ async fn logout_handler( | |||
| 303 | 305 | let session_user = session.get::<crate::auth::SessionUser>("user").await.ok().flatten(); | |
| 304 | 306 | if let Ok(Some(tracking_id)) = session.get::<UserSessionId>(SESSION_TRACKING_KEY).await { | |
| 305 | 307 | if let Some(ref u) = session_user | |
| 306 | - | && let Err(e) = db::sessions::delete_session_by_id(&state.db, tracking_id, u.id).await { | |
| 308 | + | && let Err(e) = db::sessions::delete_session_by_id(&db, tracking_id, u.id).await { | |
| 307 | 309 | tracing::warn!(tracking_id = %tracking_id, error = ?e, "failed to delete session tracking row on logout"); | |
| 308 | 310 | } | |
| 309 | - | state.caches.session_cache.remove(&tracking_id); | |
| 311 | + | caches.session_cache.remove(&tracking_id); | |
| 310 | 312 | } | |
| 311 | 313 | logout_user(&session).await?; | |
| 312 | 314 | Ok(Redirect::to("/")) | |
| @@ -328,7 +330,7 @@ pub struct ValidateUsernameForm { | |||
| 328 | 330 | /// Check username availability and format, returning an HTMX status snippet. | |
| 329 | 331 | #[tracing::instrument(skip_all, name = "auth::validate_username")] | |
| 330 | 332 | async fn validate_username( | |
| 331 | - | State(state): State<AppState>, | |
| 333 | + | State(db): State<PgPool>, | |
| 332 | 334 | Form(form): Form<ValidateUsernameForm>, | |
| 333 | 335 | ) -> crate::error::Result<Html<String>> { | |
| 334 | 336 | // Count characters, not bytes — Username::new uses chars().count() too, | |
| @@ -373,7 +375,7 @@ async fn validate_username( | |||
| 373 | 375 | // Treat a DB error as "unavailable, retry" rather than "available". Failing | |
| 374 | 376 | // open here previously let users proceed past a transient lookup error and | |
| 375 | 377 | // hit a confusing signup-side rejection or race. | |
| 376 | - | match db::users::get_user_by_username(&state.db, &username).await { | |
| 378 | + | match db::users::get_user_by_username(&db, &username).await { | |
| 377 | 379 | Ok(Some(_)) => Ok(Html(UsernameStatusTemplate { available: false }.render_string()?)), | |
| 378 | 380 | Ok(None) => Ok(Html(UsernameStatusTemplate { available: true }.render_string()?)), | |
| 379 | 381 | Err(e) => { |
| @@ -8,10 +8,11 @@ use axum::{ | |||
| 8 | 8 | use serde::Deserialize; | |
| 9 | 9 | ||
| 10 | 10 | use crate::{ | |
| 11 | + | config::Config, | |
| 11 | 12 | db::{self, ItemId}, | |
| 12 | 13 | error::{AppError, Result}, | |
| 13 | - | AppState, | |
| 14 | 14 | }; | |
| 15 | + | use sqlx::PgPool; | |
| 15 | 16 | ||
| 16 | 17 | /// Shared context fetched for all item embeds. | |
| 17 | 18 | struct ItemEmbedContext { | |
| @@ -29,8 +30,8 @@ struct ItemEmbedContext { | |||
| 29 | 30 | has_audio: bool, | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | - | async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<ItemEmbedContext> { | |
| 33 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 33 | + | async fn fetch_item_embed_context(db: &PgPool, config: &Config, item_id: ItemId) -> Result<ItemEmbedContext> { | |
| 34 | + | let item = db::items::get_item_by_id(db, item_id) | |
| 34 | 35 | .await? | |
| 35 | 36 | .ok_or(AppError::NotFound)?; | |
| 36 | 37 | ||
| @@ -38,11 +39,11 @@ async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<I | |||
| 38 | 39 | return Err(AppError::NotFound); | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | - | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 42 | + | let project = db::projects::get_project_by_id(db, item.project_id) | |
| 42 | 43 | .await? | |
| 43 | 44 | .ok_or(AppError::NotFound)?; | |
| 44 | 45 | ||
| 45 | - | let user = db::users::get_user_by_id(&state.db, project.user_id) | |
| 46 | + | let user = db::users::get_user_by_id(db, project.user_id) | |
| 46 | 47 | .await? | |
| 47 | 48 | .ok_or(AppError::NotFound)?; | |
| 48 | 49 | ||
| @@ -61,7 +62,7 @@ async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<I | |||
| 61 | 62 | (crate::formatting::format_price(item.price_cents), "Buy".to_string()) | |
| 62 | 63 | }; | |
| 63 | 64 | ||
| 64 | - | let purchase_url = format!("{}/buy/{}", state.config.host_url, item_id); | |
| 65 | + | let purchase_url = format!("{}/buy/{}", config.host_url, item_id); | |
| 65 | 66 | ||
| 66 | 67 | let description_excerpt = item.description.as_deref() | |
| 67 | 68 | .unwrap_or("") | |
| @@ -100,10 +101,11 @@ pub(super) fn set_embed_headers(response: &mut Response) { | |||
| 100 | 101 | #[tracing::instrument(skip_all, name = "embed::item_button")] | |
| 101 | 102 | /// GET /embed/i/{item_id}/button | |
| 102 | 103 | pub(super) async fn item_button( | |
| 103 | - | State(state): State<AppState>, | |
| 104 | + | State(db): State<PgPool>, | |
| 105 | + | State(config): State<Config>, | |
| 104 | 106 | Path(item_id): Path<ItemId>, | |
| 105 | 107 | ) -> Result<Response> { | |
| 106 | - | let ctx = fetch_item_embed_context(&state, item_id).await?; | |
| 108 | + | let ctx = fetch_item_embed_context(&db, &config, item_id).await?; | |
| 107 | 109 | ||
| 108 | 110 | let mut response = crate::templates::EmbedItemButtonTemplate { | |
| 109 | 111 | title: ctx.title, | |
| @@ -127,15 +129,16 @@ pub(super) struct CardQuery { | |||
| 127 | 129 | #[tracing::instrument(skip_all, name = "embed::item_card")] | |
| 128 | 130 | /// GET /embed/i/{item_id}/card | |
| 129 | 131 | pub(super) async fn item_card( | |
| 130 | - | State(state): State<AppState>, | |
| 132 | + | State(db): State<PgPool>, | |
| 133 | + | State(config): State<Config>, | |
| 131 | 134 | Path(item_id): Path<ItemId>, | |
| 132 | 135 | Query(query): Query<CardQuery>, | |
| 133 | 136 | ) -> Result<Response> { | |
| 134 | - | let ctx = fetch_item_embed_context(&state, item_id).await?; | |
| 137 | + | let ctx = fetch_item_embed_context(&db, &config, item_id).await?; | |
| 135 | 138 | let layout = query.layout.as_deref().unwrap_or("vertical"); | |
| 136 | 139 | let is_horizontal = layout == "horizontal"; | |
| 137 | 140 | ||
| 138 | - | let profile_url = format!("{}/u/{}", state.config.host_url, ctx.creator_username); | |
| 141 | + | let profile_url = format!("{}/u/{}", config.host_url, ctx.creator_username); | |
| 139 | 142 | ||
| 140 | 143 | let mut response = crate::templates::EmbedItemCardTemplate { | |
| 141 | 144 | title: ctx.title, | |
| @@ -160,10 +163,11 @@ pub(super) async fn item_card( | |||
| 160 | 163 | /// Audio preview player embed. Returns 404 for non-audio items. | |
| 161 | 164 | #[tracing::instrument(skip_all, name = "embed::item_player")] | |
| 162 | 165 | pub(super) async fn item_player( | |
| 163 | - | State(state): State<AppState>, | |
| 166 | + | State(db): State<PgPool>, | |
| 167 | + | State(config): State<Config>, | |
| 164 | 168 | Path(item_id): Path<ItemId>, | |
| 165 | 169 | ) -> Result<Response> { | |
| 166 | - | let ctx = fetch_item_embed_context(&state, item_id).await?; | |
| 170 | + | let ctx = fetch_item_embed_context(&db, &config, item_id).await?; | |
| 167 | 171 | ||
| 168 | 172 | if !ctx.has_audio { | |
| 169 | 173 | return Err(AppError::NotFound); | |
| @@ -171,7 +175,7 @@ pub(super) async fn item_player( | |||
| 171 | 175 | ||
| 172 | 176 | // Preview URL would be /embed/i/{item_id}/preview.mp3 once ffmpeg generation is built. | |
| 173 | 177 | // For now, link to the item page — the player embed is a stub until preview generation ships. | |
| 174 | - | let preview_url = format!("{}/api/stream/{}", state.config.host_url, item_id); | |
| 178 | + | let preview_url = format!("{}/api/stream/{}", config.host_url, item_id); | |
| 175 | 179 | ||
| 176 | 180 | let mut response = crate::templates::EmbedItemPlayerTemplate { | |
| 177 | 181 | title: ctx.title, |
| @@ -6,24 +6,26 @@ use axum::{ | |||
| 6 | 6 | }; | |
| 7 | 7 | ||
| 8 | 8 | use crate::{ | |
| 9 | + | config::Config, | |
| 9 | 10 | db::{self}, | |
| 10 | 11 | error::{AppError, Result}, | |
| 11 | - | AppState, | |
| 12 | 12 | }; | |
| 13 | + | use sqlx::PgPool; | |
| 13 | 14 | ||
| 14 | 15 | use super::item::set_embed_headers; | |
| 15 | 16 | ||
| 16 | 17 | #[tracing::instrument(skip_all, name = "embed::project_card")] | |
| 17 | 18 | /// GET /embed/p/{project_slug}/card | |
| 18 | 19 | pub(super) async fn project_card( | |
| 19 | - | State(state): State<AppState>, | |
| 20 | + | State(db): State<PgPool>, | |
| 21 | + | State(config): State<Config>, | |
| 20 | 22 | Path(project_slug): Path<String>, | |
| 21 | 23 | ) -> Result<Response> { | |
| 22 | - | let project = db::projects::get_public_project_by_slug_str(&state.db, &project_slug) | |
| 24 | + | let project = db::projects::get_public_project_by_slug_str(&db, &project_slug) | |
| 23 | 25 | .await? | |
| 24 | 26 | .ok_or(AppError::NotFound)?; | |
| 25 | 27 | ||
| 26 | - | let user = db::users::get_user_by_id(&state.db, project.user_id) | |
| 28 | + | let user = db::users::get_user_by_id(&db, project.user_id) | |
| 27 | 29 | .await? | |
| 28 | 30 | .ok_or(AppError::NotFound)?; | |
| 29 | 31 | ||
| @@ -31,7 +33,7 @@ pub(super) async fn project_card( | |||
| 31 | 33 | return Err(AppError::NotFound); | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | - | let items = db::items::get_public_items_by_project(&state.db, project.id).await?; | |
| 36 | + | let items = db::items::get_public_items_by_project(&db, project.id).await?; | |
| 35 | 37 | let item_count = items.len(); | |
| 36 | 38 | ||
| 37 | 39 | let description_excerpt: String = project.description.as_deref() | |
| @@ -40,8 +42,8 @@ pub(super) async fn project_card( | |||
| 40 | 42 | .take(150) | |
| 41 | 43 | .collect(); | |
| 42 | 44 | ||
| 43 | - | let project_url = format!("{}/p/{}", state.config.host_url, project_slug); | |
| 44 | - | let profile_url = format!("{}/u/{}", state.config.host_url, user.username); | |
| 45 | + | let project_url = format!("{}/p/{}", config.host_url, project_slug); | |
| 46 | + | let profile_url = format!("{}/u/{}", config.host_url, user.username); | |
| 45 | 47 | let creator_name = user.display_name.as_deref().unwrap_or(&user.username); | |
| 46 | 48 | let category_label = project.project_type.label(); | |
| 47 | 49 |
| @@ -6,21 +6,23 @@ use axum::{ | |||
| 6 | 6 | }; | |
| 7 | 7 | ||
| 8 | 8 | use crate::{ | |
| 9 | + | config::Config, | |
| 9 | 10 | db::{self, Username}, | |
| 10 | 11 | error::{AppError, Result}, | |
| 11 | - | AppState, | |
| 12 | 12 | }; | |
| 13 | + | use sqlx::PgPool; | |
| 13 | 14 | ||
| 14 | 15 | use super::item::set_embed_headers; | |
| 15 | 16 | ||
| 16 | 17 | #[tracing::instrument(skip_all, name = "embed::tip_button")] | |
| 17 | 18 | /// GET /embed/u/{username}/tip | |
| 18 | 19 | pub(super) async fn tip_button( | |
| 19 | - | State(state): State<AppState>, | |
| 20 | + | State(db): State<PgPool>, | |
| 21 | + | State(config): State<Config>, | |
| 20 | 22 | Path(username): Path<String>, | |
| 21 | 23 | ) -> Result<Response> { | |
| 22 | 24 | let username = Username::new(&username).map_err(|_| AppError::NotFound)?; | |
| 23 | - | let user = db::users::get_user_by_username(&state.db, &username) | |
| 25 | + | let user = db::users::get_user_by_username(&db, &username) | |
| 24 | 26 | .await? | |
| 25 | 27 | .ok_or(AppError::NotFound)?; | |
| 26 | 28 | ||
| @@ -29,7 +31,7 @@ pub(super) async fn tip_button( | |||
| 29 | 31 | } | |
| 30 | 32 | ||
| 31 | 33 | let display_name = user.display_name.as_deref().unwrap_or(&user.username).to_string(); | |
| 32 | - | let tip_url = format!("{}/u/{}/tip", state.config.host_url, user.username); | |
| 34 | + | let tip_url = format!("{}/u/{}/tip", config.host_url, user.username); | |
| 33 | 35 | ||
| 34 | 36 | let mut response = crate::templates::EmbedTipButtonTemplate { | |
| 35 | 37 | display_name, |
| @@ -26,10 +26,12 @@ use tower_sessions::Session; | |||
| 26 | 26 | ||
| 27 | 27 | use crate::{ | |
| 28 | 28 | auth::{login_user, track_session, SessionUser}, | |
| 29 | + | config::Config, | |
| 29 | 30 | db::{self, UserId}, | |
| 30 | 31 | error::{AppError, Result}, | |
| 31 | 32 | AppState, | |
| 32 | 33 | }; | |
| 34 | + | use sqlx::PgPool; | |
| 33 | 35 | ||
| 34 | 36 | const SSO_STATE_KEY: &str = "sso_state"; | |
| 35 | 37 | const SSO_VERIFIER_KEY: &str = "sso_pkce_verifier"; | |
| @@ -43,8 +45,8 @@ fn b64url(data: &[u8]) -> String { | |||
| 43 | 45 | /// Generates PKCE + state, stashes them in the session, and redirects to the | |
| 44 | 46 | /// provider's authorize endpoint. No-op (404-ish redirect home) when SSO is off. | |
| 45 | 47 | #[tracing::instrument(skip_all, name = "sso::login")] | |
| 46 | - | async fn sso_login(State(state): State<AppState>, session: Session) -> Result<Response> { | |
| 47 | - | let Some(sso) = state.config.sso.as_ref() else { | |
| 48 | + | async fn sso_login(State(config): State<Config>, session: Session) -> Result<Response> { | |
| 49 | + | let Some(sso) = config.sso.as_ref() else { | |
| 48 | 50 | // SSO not configured — nothing to delegate to. | |
| 49 | 51 | return Ok(Redirect::to("/login").into_response()); | |
| 50 | 52 | }; | |
| @@ -63,7 +65,7 @@ async fn sso_login(State(state): State<AppState>, session: Session) -> Result<Re | |||
| 63 | 65 | session.insert(SSO_STATE_KEY, &state_param).await.map_err(|e| AppError::Internal(e.into()))?; | |
| 64 | 66 | session.insert(SSO_VERIFIER_KEY, &verifier).await.map_err(|e| AppError::Internal(e.into()))?; | |
| 65 | 67 | ||
| 66 | - | let redirect_uri = format!("{}/sso/callback", state.config.host_url); | |
| 68 | + | let redirect_uri = format!("{}/sso/callback", config.host_url); | |
| 67 | 69 | let authorize = format!( | |
| 68 | 70 | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", | |
| 69 | 71 | sso.provider_url, | |
| @@ -91,12 +93,13 @@ struct TokenResponse { | |||
| 91 | 93 | /// GET /sso/callback — provider redirected back with `code` + `state`. | |
| 92 | 94 | #[tracing::instrument(skip_all, name = "sso::callback")] | |
| 93 | 95 | async fn sso_callback( | |
| 94 | - | State(state): State<AppState>, | |
| 96 | + | State(db): State<PgPool>, | |
| 97 | + | State(config): State<Config>, | |
| 95 | 98 | session: Session, | |
| 96 | 99 | headers: HeaderMap, | |
| 97 | 100 | Query(q): Query<CallbackQuery>, | |
| 98 | 101 | ) -> Result<Response> { | |
| 99 | - | let Some(sso) = state.config.sso.as_ref() else { | |
| 102 | + | let Some(sso) = config.sso.as_ref() else { | |
| 100 | 103 | return Ok(Redirect::to("/login").into_response()); | |
| 101 | 104 | }; | |
| 102 | 105 | ||
| @@ -125,7 +128,7 @@ async fn sso_callback( | |||
| 125 | 128 | } | |
| 126 | 129 | ||
| 127 | 130 | // Exchange the code at the provider's token endpoint. | |
| 128 | - | let redirect_uri = format!("{}/sso/callback", state.config.host_url); | |
| 131 | + | let redirect_uri = format!("{}/sso/callback", config.host_url); | |
| 129 | 132 | let resp = crate::helpers::HTTP_CLIENT | |
| 130 | 133 | .post(format!("{}/oauth/token", sso.provider_url)) | |
| 131 | 134 | .timeout(std::time::Duration::from_secs(10)) | |
| @@ -161,7 +164,7 @@ async fn sso_callback( | |||
| 161 | 164 | }; | |
| 162 | 165 | ||
| 163 | 166 | // Map the verified provider user id onto our mirrored account. | |
| 164 | - | let db_user = match db::users::get_user_by_id(&state.db, token.user_id).await? { | |
| 167 | + | let db_user = match db::users::get_user_by_id(&db, token.user_id).await? { | |
| 165 | 168 | Some(u) => u, | |
| 166 | 169 | None => return fail("This account can't sign in here."), | |
| 167 | 170 | }; | |
| @@ -170,9 +173,9 @@ async fn sso_callback( | |||
| 170 | 173 | } | |
| 171 | 174 | ||
| 172 | 175 | let user_id = db_user.id; | |
| 173 | - | let session_user = SessionUser::from_db_user(db_user, &state.db, state.config.admin_user_id).await; | |
| 176 | + | let session_user = SessionUser::from_db_user(db_user, &db, config.admin_user_id).await; | |
| 174 | 177 | login_user(&session, session_user).await?; | |
| 175 | - | track_session(&session, &state.db, user_id, &headers).await?; | |
| 178 | + | track_session(&session, &db, user_id, &headers).await?; | |
| 176 | 179 | ||
| 177 | 180 | Ok(Redirect::to("/").into_response()) | |
| 178 | 181 | } |