max / makenotwork
25 files changed,
+597 insertions,
-523 deletions
| @@ -7,6 +7,8 @@ use axum::{ | |||
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | 9 | ||
| 10 | + | use sqlx::PgPool; | |
| 11 | + | ||
| 10 | 12 | use crate::{ | |
| 11 | 13 | auth::AuthUser, | |
| 12 | 14 | db::{self, BlogPostId, ProjectId, Slug}, | |
| @@ -116,11 +118,11 @@ fn blog_post_response(post: &db::DbBlogPost) -> BlogPostResponse { | |||
| 116 | 118 | /// Get a single blog post for editing. | |
| 117 | 119 | #[tracing::instrument(skip_all, name = "blog::get_blog_post")] | |
| 118 | 120 | pub(super) async fn get_blog_post( | |
| 119 | - | State(state): State<AppState>, | |
| 121 | + | State(db): State<PgPool>, | |
| 120 | 122 | AuthUser(user): AuthUser, | |
| 121 | 123 | Path(blog_post_id): Path<BlogPostId>, | |
| 122 | 124 | ) -> Result<impl IntoResponse> { | |
| 123 | - | let post = verify_blog_post_ownership(&state.db, blog_post_id, user.id).await?; | |
| 125 | + | let post = verify_blog_post_ownership(&db, blog_post_id, user.id).await?; | |
| 124 | 126 | Ok(Json(blog_post_edit_response(&post))) | |
| 125 | 127 | } | |
| 126 | 128 | ||
| @@ -257,15 +259,15 @@ pub(super) async fn update_blog_post( | |||
| 257 | 259 | /// Delete a blog post. | |
| 258 | 260 | #[tracing::instrument(skip_all, name = "blog::delete_blog_post")] | |
| 259 | 261 | pub(super) async fn delete_blog_post( | |
| 260 | - | State(state): State<AppState>, | |
| 262 | + | State(db): State<PgPool>, | |
| 261 | 263 | AuthUser(user): AuthUser, | |
| 262 | 264 | Path(id): Path<BlogPostId>, | |
| 263 | 265 | ) -> Result<impl IntoResponse> { | |
| 264 | 266 | user.check_not_suspended()?; | |
| 265 | - | let post = verify_blog_post_ownership(&state.db, id, user.id).await?; | |
| 267 | + | let post = verify_blog_post_ownership(&db, id, user.id).await?; | |
| 266 | 268 | ||
| 267 | - | db::blog_posts::delete_blog_post(&state.db, id, user.id).await?; | |
| 268 | - | db::projects::bump_cache_generation(&state.db, post.project_id).await?; | |
| 269 | + | db::blog_posts::delete_blog_post(&db, id, user.id).await?; | |
| 270 | + | db::projects::bump_cache_generation(&db, post.project_id).await?; | |
| 269 | 271 | ||
| 270 | 272 | Ok(htmx_toast_response("Blog post deleted", "success")) | |
| 271 | 273 | } | |
| @@ -273,10 +275,10 @@ pub(super) async fn delete_blog_post( | |||
| 273 | 275 | /// List published blog posts for a project. | |
| 274 | 276 | #[tracing::instrument(skip_all, name = "blog::list_blog_posts")] | |
| 275 | 277 | pub(super) async fn list_blog_posts( | |
| 276 | - | State(state): State<AppState>, | |
| 278 | + | State(db): State<PgPool>, | |
| 277 | 279 | Path(project_id): Path<ProjectId>, | |
| 278 | 280 | ) -> Result<impl IntoResponse> { | |
| 279 | - | let posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, project_id).await?; | |
| 281 | + | let posts = db::blog_posts::get_published_blog_posts_by_project(&db, project_id).await?; | |
| 280 | 282 | ||
| 281 | 283 | let data: Vec<BlogPostResponse> = posts.iter().map(blog_post_response).collect(); | |
| 282 | 284 |
| @@ -4,22 +4,23 @@ use axum::extract::{Path, State}; | |||
| 4 | 4 | use axum::response::IntoResponse; | |
| 5 | 5 | use axum::Json; | |
| 6 | 6 | ||
| 7 | + | use sqlx::PgPool; | |
| 8 | + | ||
| 7 | 9 | use crate::{ | |
| 8 | 10 | auth::AuthUser, | |
| 9 | 11 | db::{self, ItemId}, | |
| 10 | 12 | error::{AppError, Result}, | |
| 11 | - | AppState, | |
| 12 | 13 | }; | |
| 13 | 14 | ||
| 14 | 15 | /// Toggle an item's cart status. Returns the new state. | |
| 15 | 16 | #[tracing::instrument(skip_all, name = "cart::toggle")] | |
| 16 | 17 | pub(super) async fn toggle_cart( | |
| 17 | - | State(state): State<AppState>, | |
| 18 | + | State(db): State<PgPool>, | |
| 18 | 19 | AuthUser(user): AuthUser, | |
| 19 | 20 | Path(item_id): Path<ItemId>, | |
| 20 | 21 | ) -> Result<impl IntoResponse> { | |
| 21 | 22 | // Single-query pre-flight: item existence, visibility, ownership, purchase, cart status | |
| 22 | - | let pf = db::cart::toggle_cart_preflight(&state.db, user.id, item_id) | |
| 23 | + | let pf = db::cart::toggle_cart_preflight(&db, user.id, item_id) | |
| 23 | 24 | .await? | |
| 24 | 25 | .ok_or(AppError::NotFound)?; | |
| 25 | 26 | ||
| @@ -46,9 +47,9 @@ pub(super) async fn toggle_cart( | |||
| 46 | 47 | } | |
| 47 | 48 | ||
| 48 | 49 | if pf.in_cart { | |
| 49 | - | db::cart::remove_from_cart(&state.db, user.id, item_id).await?; | |
| 50 | + | db::cart::remove_from_cart(&db, user.id, item_id).await?; | |
| 50 | 51 | } else { | |
| 51 | - | db::cart::add_to_cart(&state.db, user.id, item_id).await?; | |
| 52 | + | db::cart::add_to_cart(&db, user.id, item_id).await?; | |
| 52 | 53 | } | |
| 53 | 54 | ||
| 54 | 55 | Ok(Json(serde_json::json!({ "in_cart": !pf.in_cart }))) | |
| @@ -57,24 +58,24 @@ pub(super) async fn toggle_cart( | |||
| 57 | 58 | /// Remove an item from the cart explicitly. Returns 204. | |
| 58 | 59 | #[tracing::instrument(skip_all, name = "cart::remove")] | |
| 59 | 60 | pub(super) async fn remove_from_cart( | |
| 60 | - | State(state): State<AppState>, | |
| 61 | + | State(db): State<PgPool>, | |
| 61 | 62 | AuthUser(user): AuthUser, | |
| 62 | 63 | Path(item_id): Path<ItemId>, | |
| 63 | 64 | ) -> Result<impl IntoResponse> { | |
| 64 | - | db::cart::remove_from_cart(&state.db, user.id, item_id).await?; | |
| 65 | + | db::cart::remove_from_cart(&db, user.id, item_id).await?; | |
| 65 | 66 | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | /// Update the PWYW amount for a cart item. | |
| 69 | 70 | #[tracing::instrument(skip_all, name = "cart::update_amount")] | |
| 70 | 71 | pub(super) async fn update_cart_amount( | |
| 71 | - | State(state): State<AppState>, | |
| 72 | + | State(db): State<PgPool>, | |
| 72 | 73 | AuthUser(user): AuthUser, | |
| 73 | 74 | Path(item_id): Path<ItemId>, | |
| 74 | 75 | Json(body): Json<UpdateCartAmountRequest>, | |
| 75 | 76 | ) -> Result<impl IntoResponse> { | |
| 76 | 77 | // Verify item exists and is PWYW | |
| 77 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 78 | + | let item = db::items::get_item_by_id(&db, item_id) | |
| 78 | 79 | .await? | |
| 79 | 80 | .ok_or(AppError::NotFound)?; | |
| 80 | 81 | ||
| @@ -102,7 +103,7 @@ pub(super) async fn update_cart_amount( | |||
| 102 | 103 | } | |
| 103 | 104 | ||
| 104 | 105 | let updated = db::cart::update_cart_amount( | |
| 105 | - | &state.db, | |
| 106 | + | &db, | |
| 106 | 107 | user.id, | |
| 107 | 108 | item_id, | |
| 108 | 109 | Some(body.amount_cents), | |
| @@ -124,9 +125,9 @@ pub(super) struct UpdateCartAmountRequest { | |||
| 124 | 125 | /// Get the number of items in the cart (for nav badge). | |
| 125 | 126 | #[tracing::instrument(skip_all, name = "cart::count")] | |
| 126 | 127 | pub(super) async fn cart_count( | |
| 127 | - | State(state): State<AppState>, | |
| 128 | + | State(db): State<PgPool>, | |
| 128 | 129 | AuthUser(user): AuthUser, | |
| 129 | 130 | ) -> Result<impl IntoResponse> { | |
| 130 | - | let count = db::cart::get_cart_count(&state.db, user.id).await?; | |
| 131 | + | let count = db::cart::get_cart_count(&db, user.id).await?; | |
| 131 | 132 | Ok(Json(serde_json::json!({ "count": count }))) | |
| 132 | 133 | } |
| @@ -7,11 +7,12 @@ use axum::{ | |||
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | 9 | ||
| 10 | + | use sqlx::PgPool; | |
| 11 | + | ||
| 10 | 12 | use crate::{ | |
| 11 | 13 | auth::AuthUser, | |
| 12 | 14 | db, | |
| 13 | 15 | error::Result, | |
| 14 | - | AppState, | |
| 15 | 16 | }; | |
| 16 | 17 | ||
| 17 | 18 | /// Typeahead search query. | |
| @@ -30,7 +31,7 @@ pub struct CategorySearchResult { | |||
| 30 | 31 | /// Search categories for typeahead suggestions, returns JSON. | |
| 31 | 32 | #[tracing::instrument(skip_all, name = "categories::search_categories")] | |
| 32 | 33 | pub(super) async fn search_categories( | |
| 33 | - | State(state): State<AppState>, | |
| 34 | + | State(db): State<PgPool>, | |
| 34 | 35 | axum::extract::Query(query): axum::extract::Query<CategorySearchQuery>, | |
| 35 | 36 | ) -> Result<impl IntoResponse> { | |
| 36 | 37 | let q = query.q.unwrap_or_default(); | |
| @@ -38,7 +39,7 @@ pub(super) async fn search_categories( | |||
| 38 | 39 | return Ok(Json(Vec::<CategorySearchResult>::new())); | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | - | let categories = db::categories::search_categories(&state.db, &q, 10).await?; | |
| 42 | + | let categories = db::categories::search_categories(&db, &q, 10).await?; | |
| 42 | 43 | let results: Vec<CategorySearchResult> = categories | |
| 43 | 44 | .into_iter() | |
| 44 | 45 | .map(|c| CategorySearchResult { | |
| @@ -59,7 +60,7 @@ pub struct CreateCategoryRequest { | |||
| 59 | 60 | /// Create a new category (or return existing). Requires auth. | |
| 60 | 61 | #[tracing::instrument(skip_all, name = "categories::create_category")] | |
| 61 | 62 | pub(super) async fn create_category( | |
| 62 | - | State(state): State<AppState>, | |
| 63 | + | State(db): State<PgPool>, | |
| 63 | 64 | AuthUser(_user): AuthUser, | |
| 64 | 65 | Json(req): Json<CreateCategoryRequest>, | |
| 65 | 66 | ) -> Result<impl IntoResponse> { | |
| @@ -70,7 +71,7 @@ pub(super) async fn create_category( | |||
| 70 | 71 | )); | |
| 71 | 72 | } | |
| 72 | 73 | ||
| 73 | - | let category = db::categories::get_or_create_category(&state.db, name).await?; | |
| 74 | + | let category = db::categories::get_or_create_category(&db, name).await?; | |
| 74 | 75 | ||
| 75 | 76 | Ok(Json(CategorySearchResult { | |
| 76 | 77 | name: category.name, |
| @@ -8,13 +8,14 @@ use axum::{ | |||
| 8 | 8 | }; | |
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | ||
| 11 | + | use sqlx::PgPool; | |
| 12 | + | ||
| 11 | 13 | use crate::{ | |
| 12 | 14 | auth::AuthUser, | |
| 13 | 15 | constants, | |
| 14 | 16 | db::{self, CollectionId, ItemId, Slug}, | |
| 15 | 17 | error::{AppError, Result}, | |
| 16 | 18 | validation, | |
| 17 | - | AppState, | |
| 18 | 19 | }; | |
| 19 | 20 | ||
| 20 | 21 | // ── Request / response types ── | |
| @@ -61,11 +62,11 @@ pub struct CollectionForItemEntry { | |||
| 61 | 62 | ||
| 62 | 63 | /// Fetch a collection and verify the authenticated user owns it. | |
| 63 | 64 | async fn verify_collection_ownership( | |
| 64 | - | state: &AppState, | |
| 65 | + | db: &PgPool, | |
| 65 | 66 | collection_id: CollectionId, | |
| 66 | 67 | user_id: db::UserId, | |
| 67 | 68 | ) -> Result<db::DbCollection> { | |
| 68 | - | let collection = db::collections::get_collection_by_id(&state.db, collection_id) | |
| 69 | + | let collection = db::collections::get_collection_by_id(db, collection_id) | |
| 69 | 70 | .await? | |
| 70 | 71 | .ok_or(AppError::NotFound)?; | |
| 71 | 72 | ||
| @@ -81,7 +82,7 @@ async fn verify_collection_ownership( | |||
| 81 | 82 | /// Create a new collection. | |
| 82 | 83 | #[tracing::instrument(skip_all, name = "collections::create")] | |
| 83 | 84 | pub(super) async fn create_collection( | |
| 84 | - | State(state): State<AppState>, | |
| 85 | + | State(db): State<PgPool>, | |
| 85 | 86 | AuthUser(user): AuthUser, | |
| 86 | 87 | Json(req): Json<CreateCollectionRequest>, | |
| 87 | 88 | ) -> Result<impl IntoResponse> { | |
| @@ -98,7 +99,7 @@ pub(super) async fn create_collection( | |||
| 98 | 99 | let slug = Slug::new(slug_str)?; | |
| 99 | 100 | ||
| 100 | 101 | // Enforce per-user limit | |
| 101 | - | let count = db::collections::count_collections_by_user(&state.db, user.id).await?; | |
| 102 | + | let count = db::collections::count_collections_by_user(&db, user.id).await?; | |
| 102 | 103 | if count >= constants::MAX_COLLECTIONS_PER_USER { | |
| 103 | 104 | return Err(AppError::validation(format!( | |
| 104 | 105 | "You can create up to {} collections", | |
| @@ -109,7 +110,7 @@ pub(super) async fn create_collection( | |||
| 109 | 110 | // `create_collection` maps the per-user slug unique-violation to a clean | |
| 110 | 111 | // validation error (the seal lives in the db layer), so callers just `?`. | |
| 111 | 112 | let collection = db::collections::create_collection( | |
| 112 | - | &state.db, | |
| 113 | + | &db, | |
| 113 | 114 | user.id, | |
| 114 | 115 | &slug, | |
| 115 | 116 | title, | |
| @@ -133,13 +134,13 @@ pub(super) async fn create_collection( | |||
| 133 | 134 | /// Update a collection's title, description, and visibility. | |
| 134 | 135 | #[tracing::instrument(skip_all, name = "collections::update")] | |
| 135 | 136 | pub(super) async fn update_collection( | |
| 136 | - | State(state): State<AppState>, | |
| 137 | + | State(db): State<PgPool>, | |
| 137 | 138 | AuthUser(user): AuthUser, | |
| 138 | 139 | Path(id): Path<CollectionId>, | |
| 139 | 140 | Json(req): Json<UpdateCollectionRequest>, | |
| 140 | 141 | ) -> Result<impl IntoResponse> { | |
| 141 | 142 | user.check_not_suspended()?; | |
| 142 | - | verify_collection_ownership(&state, id, user.id).await?; | |
| 143 | + | verify_collection_ownership(&db, id, user.id).await?; | |
| 143 | 144 | ||
| 144 | 145 | let title = req.title.trim(); | |
| 145 | 146 | let description = req.description.as_deref().map(str::trim).filter(|s| !s.is_empty()); | |
| @@ -150,7 +151,7 @@ pub(super) async fn update_collection( | |||
| 150 | 151 | } | |
| 151 | 152 | ||
| 152 | 153 | let collection = db::collections::update_collection( | |
| 153 | - | &state.db, | |
| 154 | + | &db, | |
| 154 | 155 | id, | |
| 155 | 156 | title, | |
| 156 | 157 | description, | |
| @@ -170,14 +171,14 @@ pub(super) async fn update_collection( | |||
| 170 | 171 | /// Delete a collection. | |
| 171 | 172 | #[tracing::instrument(skip_all, name = "collections::delete")] | |
| 172 | 173 | pub(super) async fn delete_collection( | |
| 173 | - | State(state): State<AppState>, | |
| 174 | + | State(db): State<PgPool>, | |
| 174 | 175 | AuthUser(user): AuthUser, | |
| 175 | 176 | Path(id): Path<CollectionId>, | |
| 176 | 177 | ) -> Result<impl IntoResponse> { | |
| 177 | 178 | user.check_not_suspended()?; | |
| 178 | - | verify_collection_ownership(&state, id, user.id).await?; | |
| 179 | + | verify_collection_ownership(&db, id, user.id).await?; | |
| 179 | 180 | ||
| 180 | - | db::collections::delete_collection(&state.db, id, user.id).await?; | |
| 181 | + | db::collections::delete_collection(&db, id, user.id).await?; | |
| 181 | 182 | ||
| 182 | 183 | Ok(StatusCode::NO_CONTENT) | |
| 183 | 184 | } | |
| @@ -185,15 +186,15 @@ pub(super) async fn delete_collection( | |||
| 185 | 186 | /// Add an item to a collection. | |
| 186 | 187 | #[tracing::instrument(skip_all, name = "collections::add_item")] | |
| 187 | 188 | pub(super) async fn add_item( | |
| 188 | - | State(state): State<AppState>, | |
| 189 | + | State(db): State<PgPool>, | |
| 189 | 190 | AuthUser(user): AuthUser, | |
| 190 | 191 | Path((collection_id, item_id)): Path<(CollectionId, ItemId)>, | |
| 191 | 192 | ) -> Result<impl IntoResponse> { | |
| 192 | 193 | user.check_not_suspended()?; | |
| 193 | - | verify_collection_ownership(&state, collection_id, user.id).await?; | |
| 194 | + | verify_collection_ownership(&db, collection_id, user.id).await?; | |
| 194 | 195 | ||
| 195 | 196 | // Item must exist and be public | |
| 196 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 197 | + | let item = db::items::get_item_by_id(&db, item_id) | |
| 197 | 198 | .await? | |
| 198 | 199 | .ok_or(AppError::NotFound)?; | |
| 199 | 200 | if !item.is_public { | |
| @@ -203,7 +204,7 @@ pub(super) async fn add_item( | |||
| 203 | 204 | } | |
| 204 | 205 | ||
| 205 | 206 | // Enforce per-collection limit | |
| 206 | - | let count = db::collections::count_collection_items(&state.db, collection_id).await?; | |
| 207 | + | let count = db::collections::count_collection_items(&db, collection_id).await?; | |
| 207 | 208 | if count >= constants::MAX_ITEMS_PER_COLLECTION { | |
| 208 | 209 | return Err(AppError::validation(format!( | |
| 209 | 210 | "A collection can hold up to {} items", | |
| @@ -211,7 +212,7 @@ pub(super) async fn add_item( | |||
| 211 | 212 | ))); | |
| 212 | 213 | } | |
| 213 | 214 | ||
| 214 | - | db::collections::add_item_to_collection(&state.db, collection_id, item_id).await?; | |
| 215 | + | db::collections::add_item_to_collection(&db, collection_id, item_id).await?; | |
| 215 | 216 | ||
| 216 | 217 | Ok(StatusCode::NO_CONTENT) | |
| 217 | 218 | } | |
| @@ -219,14 +220,14 @@ pub(super) async fn add_item( | |||
| 219 | 220 | /// Remove an item from a collection. | |
| 220 | 221 | #[tracing::instrument(skip_all, name = "collections::remove_item")] | |
| 221 | 222 | pub(super) async fn remove_item( | |
| 222 | - | State(state): State<AppState>, | |
| 223 | + | State(db): State<PgPool>, | |
| 223 | 224 | AuthUser(user): AuthUser, | |
| 224 | 225 | Path((collection_id, item_id)): Path<(CollectionId, ItemId)>, | |
| 225 | 226 | ) -> Result<impl IntoResponse> { | |
| 226 | 227 | user.check_not_suspended()?; | |
| 227 | - | verify_collection_ownership(&state, collection_id, user.id).await?; | |
| 228 | + | verify_collection_ownership(&db, collection_id, user.id).await?; | |
| 228 | 229 | ||
| 229 | - | db::collections::remove_item_from_collection(&state.db, collection_id, item_id).await?; | |
| 230 | + | db::collections::remove_item_from_collection(&db, collection_id, item_id).await?; | |
| 230 | 231 | ||
| 231 | 232 | Ok(StatusCode::NO_CONTENT) | |
| 232 | 233 | } | |
| @@ -234,15 +235,15 @@ pub(super) async fn remove_item( | |||
| 234 | 235 | /// Reorder items in a collection. | |
| 235 | 236 | #[tracing::instrument(skip_all, name = "collections::reorder_items")] | |
| 236 | 237 | pub(super) async fn reorder_items( | |
| 237 | - | State(state): State<AppState>, | |
| 238 | + | State(db): State<PgPool>, | |
| 238 | 239 | AuthUser(user): AuthUser, | |
| 239 | 240 | Path(collection_id): Path<CollectionId>, | |
| 240 | 241 | Json(req): Json<ReorderItemsRequest>, | |
| 241 | 242 | ) -> Result<impl IntoResponse> { | |
| 242 | 243 | user.check_not_suspended()?; | |
| 243 | - | verify_collection_ownership(&state, collection_id, user.id).await?; | |
| 244 | + | verify_collection_ownership(&db, collection_id, user.id).await?; | |
| 244 | 245 | ||
| 245 | - | db::collections::reorder_collection_items(&state.db, collection_id, &req.item_ids).await?; | |
| 246 | + | db::collections::reorder_collection_items(&db, collection_id, &req.item_ids).await?; | |
| 246 | 247 | ||
| 247 | 248 | Ok(StatusCode::NO_CONTENT) | |
| 248 | 249 | } | |
| @@ -252,11 +253,11 @@ pub(super) async fn reorder_items( | |||
| 252 | 253 | /// Get the current user's collections with membership state for a specific item. | |
| 253 | 254 | #[tracing::instrument(skip_all, name = "collections::for_item")] | |
| 254 | 255 | pub(super) async fn collections_for_item( | |
| 255 | - | State(state): State<AppState>, | |
| 256 | + | State(db): State<PgPool>, | |
| 256 | 257 | AuthUser(user): AuthUser, | |
| 257 | 258 | Path(item_id): Path<ItemId>, | |
| 258 | 259 | ) -> Result<impl IntoResponse> { | |
| 259 | - | let rows = db::collections::get_user_collections_for_item(&state.db, user.id, item_id).await?; | |
| 260 | + | let rows = db::collections::get_user_collections_for_item(&db, user.id, item_id).await?; | |
| 260 | 261 | ||
| 261 | 262 | let entries: Vec<CollectionForItemEntry> = rows | |
| 262 | 263 | .into_iter() |
| @@ -7,6 +7,9 @@ use axum::{ | |||
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | 9 | ||
| 10 | + | use sqlx::PgPool; | |
| 11 | + | use crate::{AppStorage, Scanning}; | |
| 12 | + | ||
| 10 | 13 | use crate::{ | |
| 11 | 14 | auth::AuthUser, | |
| 12 | 15 | db::{self, ContentInsertionId, ContentInsertionPlacementId, InsertionPosition, ItemId}, | |
| @@ -15,7 +18,6 @@ use crate::{ | |||
| 15 | 18 | routes::storage::{commit_upload, CommitTarget}, | |
| 16 | 19 | storage::{FileType, S3Client}, | |
| 17 | 20 | templates::InsertionListTemplate, | |
| 18 | - | AppState, | |
| 19 | 21 | }; | |
| 20 | 22 | ||
| 21 | 23 | use super::verify_item_ownership; | |
| @@ -79,18 +81,19 @@ pub struct CreatePlacementRequest { | |||
| 79 | 81 | /// POST /api/users/me/insertions/presign | |
| 80 | 82 | #[tracing::instrument(skip_all, name = "insertions::presign")] | |
| 81 | 83 | pub(super) async fn presign_insertion( | |
| 82 | - | State(state): State<AppState>, | |
| 84 | + | State(db): State<PgPool>, | |
| 85 | + | State(storage): State<AppStorage>, | |
| 83 | 86 | AuthUser(user): AuthUser, | |
| 84 | 87 | Json(req): Json<InsertionPresignRequest>, | |
| 85 | 88 | ) -> Result<impl IntoResponse> { | |
| 86 | 89 | user.check_not_suspended()?; | |
| 87 | - | let s3 = state.require_s3()?; | |
| 90 | + | let s3 = storage.require_s3()?; | |
| 88 | 91 | ||
| 89 | 92 | S3Client::validate_content_type(FileType::Insertion, &req.content_type)?; | |
| 90 | 93 | S3Client::validate_extension(FileType::Insertion, &req.file_name)?; | |
| 91 | 94 | ||
| 92 | 95 | // Check storage quota before issuing presigned URL | |
| 93 | - | db::creator_tiers::check_presign_allowed(&state.db, user.id, FileType::Insertion).await?; | |
| 96 | + | db::creator_tiers::check_presign_allowed(&db, user.id, FileType::Insertion).await?; | |
| 94 | 97 | ||
| 95 | 98 | // Staging key (unserved); the scan worker promotes it to the content key on a | |
| 96 | 99 | // Clean verdict (C1). Insertion clips are served presigned from the stored | |
| @@ -98,7 +101,7 @@ pub(super) async fn presign_insertion( | |||
| 98 | 101 | let s3_key = S3Client::generate_staging_key(&req.file_name); | |
| 99 | 102 | ||
| 100 | 103 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 101 | - | db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?; | |
| 104 | + | db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?; | |
| 102 | 105 | ||
| 103 | 106 | let expires_in = 3600; | |
| 104 | 107 | let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(crate::storage::CACHE_CONTROL_IMMUTABLE), None) | |
| @@ -117,12 +120,14 @@ pub(super) async fn presign_insertion( | |||
| 117 | 120 | /// POST /api/users/me/insertions/confirm | |
| 118 | 121 | #[tracing::instrument(skip_all, name = "insertions::confirm")] | |
| 119 | 122 | pub(super) async fn confirm_insertion( | |
| 120 | - | State(state): State<AppState>, | |
| 123 | + | State(db): State<PgPool>, | |
| 124 | + | State(storage): State<AppStorage>, | |
| 125 | + | State(scanning): State<Scanning>, | |
| 121 | 126 | AuthUser(user): AuthUser, | |
| 122 | 127 | Json(req): Json<InsertionConfirmRequest>, | |
| 123 | 128 | ) -> Result<impl IntoResponse> { | |
| 124 | 129 | user.check_not_suspended()?; | |
| 125 | - | let s3 = state.require_s3()?; | |
| 130 | + | let s3 = storage.require_s3()?; | |
| 126 | 131 | ||
| 127 | 132 | // Ownership of the staging key is proved below (after the replay | |
| 128 | 133 | // short-circuit) via `pending_uploads` — a `staging/{uuid}` key carries no | |
| @@ -133,7 +138,7 @@ pub(super) async fn confirm_insertion( | |||
| 133 | 138 | // Return the already-created insertion (ultra-fuzz Run 12 Storage: confirm | |
| 134 | 139 | // idempotency). | |
| 135 | 140 | if let Some(existing) = | |
| 136 | - | db::content_insertions::get_insertion_by_storage_key(&state.db, user.id, &req.s3_key).await? | |
| 141 | + | db::content_insertions::get_insertion_by_storage_key(&db, user.id, &req.s3_key).await? | |
| 137 | 142 | { | |
| 138 | 143 | return Ok(Json(InsertionResponse { | |
| 139 | 144 | id: existing.id, | |
| @@ -149,7 +154,7 @@ pub(super) async fn confirm_insertion( | |||
| 149 | 154 | // replay short-circuit — which is authorized by the row already existing — | |
| 150 | 155 | // and before the size/tier reject paths, so an unowned (at most another | |
| 151 | 156 | // user's in-flight) staging object is never enqueued for deletion. | |
| 152 | - | if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? { | |
| 157 | + | if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? { | |
| 153 | 158 | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 154 | 159 | } | |
| 155 | 160 | ||
| @@ -160,7 +165,7 @@ pub(super) async fn confirm_insertion( | |||
| 160 | 165 | ||
| 161 | 166 | // Enforce static per-type size limit | |
| 162 | 167 | if file_size_bytes as u64 > FileType::Insertion.max_size() { | |
| 163 | - | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await; | |
| 168 | + | crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await; | |
| 164 | 169 | return Err(AppError::BadRequest(format!( | |
| 165 | 170 | "File exceeds maximum size of {} MB", | |
| 166 | 171 | FileType::Insertion.max_size() / (1024 * 1024) | |
| @@ -178,23 +183,23 @@ pub(super) async fn confirm_insertion( | |||
| 178 | 183 | } | |
| 179 | 184 | ||
| 180 | 185 | // Enforce tier-based limits (per-file + storage cap) | |
| 181 | - | let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Insertion, file_size_bytes).await { | |
| 186 | + | let max_storage = match db::creator_tiers::check_upload_allowed(&db, user.id, FileType::Insertion, file_size_bytes).await { | |
| 182 | 187 | Ok(max) => max, | |
| 183 | 188 | Err(e) => { | |
| 184 | - | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await; | |
| 189 | + | crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await; | |
| 185 | 190 | return Err(e); | |
| 186 | 191 | } | |
| 187 | 192 | }; | |
| 188 | 193 | ||
| 189 | 194 | // Atomically increment storage BEFORE writing the DB record. | |
| 190 | 195 | // Avoids orphaned unbilled file references. | |
| 191 | - | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { | |
| 192 | - | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await; | |
| 196 | + | if let Err(e) = db::creator_tiers::try_increment_storage(&db, user.id, file_size_bytes, max_storage).await { | |
| 197 | + | crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await; | |
| 193 | 198 | return Err(e); | |
| 194 | 199 | } | |
| 195 | 200 | ||
| 196 | 201 | // Clear the pending upload record now that the upload is confirmed | |
| 197 | - | db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?; | |
| 202 | + | db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?; | |
| 198 | 203 | ||
| 199 | 204 | // Persist the real media family (audio vs video) derived from the validated | |
| 200 | 205 | // MIME, rather than assuming audio. Drives the placement type-compat guard | |
| @@ -202,7 +207,7 @@ pub(super) async fn confirm_insertion( | |||
| 202 | 207 | let media_type = S3Client::insertion_media_type(&req.mime_type); | |
| 203 | 208 | ||
| 204 | 209 | let insertion = db::content_insertions::create_insertion( | |
| 205 | - | &state.db, | |
| 210 | + | &db, | |
| 206 | 211 | user.id, | |
| 207 | 212 | &req.title, | |
| 208 | 213 | media_type, | |
| @@ -219,8 +224,8 @@ pub(super) async fn confirm_insertion( | |||
| 219 | 224 | // worker flips it to 'clean'; on quarantine the row is purged and a WAM ticket | |
| 220 | 225 | // filed. The creator still sees the pending clip in their management library. | |
| 221 | 226 | commit_upload( | |
| 222 | - | &state.db, | |
| 223 | - | state.scanner.as_ref(), | |
| 227 | + | &db, | |
| 228 | + | scanning.scanner.as_ref(), | |
| 224 | 229 | CommitTarget::ContentInsertion(insertion.id), | |
| 225 | 230 | &req.s3_key, | |
| 226 | 231 | FileType::Insertion, | |
| @@ -249,10 +254,10 @@ pub(super) async fn confirm_insertion( | |||
| 249 | 254 | /// GET /api/users/me/insertions | |
| 250 | 255 | #[tracing::instrument(skip_all, name = "insertions::list")] | |
| 251 | 256 | pub(super) async fn list_insertions( | |
| 252 | - | State(state): State<AppState>, | |
| 257 | + | State(db): State<PgPool>, | |
| 253 | 258 | AuthUser(user): AuthUser, | |
| 254 | 259 | ) -> Result<impl IntoResponse> { | |
| 255 | - | let insertions = db::content_insertions::list_insertions(&state.db, user.id).await?; | |
| 260 | + | let insertions = db::content_insertions::list_insertions(&db, user.id).await?; | |
| 256 | 261 | ||
| 257 | 262 | let display: Vec<crate::templates::InsertionDisplay> = insertions | |
| 258 | 263 | .iter() | |
| @@ -275,7 +280,7 @@ pub(super) async fn list_insertions( | |||
| 275 | 280 | /// PUT /api/insertions/{id} | |
| 276 | 281 | #[tracing::instrument(skip_all, name = "insertions::rename")] | |
| 277 | 282 | pub(super) async fn rename_insertion( | |
| 278 | - | State(state): State<AppState>, | |
| 283 | + | State(db): State<PgPool>, | |
| 279 | 284 | AuthUser(user): AuthUser, | |
| 280 | 285 | Path(id): Path<ContentInsertionId>, | |
| 281 | 286 | Json(req): Json<RenameInsertionRequest>, | |
| @@ -286,7 +291,7 @@ pub(super) async fn rename_insertion( | |||
| 286 | 291 | } | |
| 287 | 292 | ||
| 288 | 293 | let updated = db::content_insertions::update_insertion_title( | |
| 289 | - | &state.db, | |
| 294 | + | &db, | |
| 290 | 295 | id, | |
| 291 | 296 | user.id, | |
| 292 | 297 | &req.title, | |
| @@ -305,13 +310,13 @@ pub(super) async fn rename_insertion( | |||
| 305 | 310 | /// DELETE /api/insertions/{id} | |
| 306 | 311 | #[tracing::instrument(skip_all, name = "insertions::delete")] | |
| 307 | 312 | pub(super) async fn delete_insertion( | |
| 308 | - | State(state): State<AppState>, | |
| 313 | + | State(db): State<PgPool>, | |
| 309 | 314 | AuthUser(user): AuthUser, | |
| 310 | 315 | Path(id): Path<ContentInsertionId>, | |
| 311 | 316 | ) -> Result<impl IntoResponse> { | |
| 312 | 317 | user.check_not_suspended()?; | |
| 313 | 318 | // Look up the insertion for S3 cleanup and storage decrement | |
| 314 | - | let insertion = db::content_insertions::get_insertion(&state.db, id, user.id).await?; | |
| 319 | + | let insertion = db::content_insertions::get_insertion(&db, id, user.id).await?; | |
| 315 | 320 | let file_size = insertion.as_ref().map(|i| i.file_size).unwrap_or(0); | |
| 316 | 321 | ||
| 317 | 322 | // Enqueue as the sole durable deletion path, BEFORE the row delete. Abort on | |
| @@ -320,13 +325,13 @@ pub(super) async fn delete_insertion( | |||
| 320 | 325 | // The reaper's is_s3_key_live guard makes the reverse case safe. | |
| 321 | 326 | if let Some(ref ins) = insertion { | |
| 322 | 327 | db::pending_s3_deletions::enqueue_deletions( | |
| 323 | - | &state.db, | |
| 328 | + | &db, | |
| 324 | 329 | &[(ins.storage_key.clone(), "main".to_string())], | |
| 325 | 330 | "insertion_delete", | |
| 326 | 331 | ).await?; | |
| 327 | 332 | } | |
| 328 | 333 | ||
| 329 | - | let deleted = db::content_insertions::delete_insertion(&state.db, id, user.id).await?; | |
| 334 | + | let deleted = db::content_insertions::delete_insertion(&db, id, user.id).await?; | |
| 330 | 335 | ||
| 331 | 336 | if !deleted { | |
| 332 | 337 | return Err(AppError::NotFound); | |
| @@ -334,7 +339,7 @@ pub(super) async fn delete_insertion( | |||
| 334 | 339 | ||
| 335 | 340 | // Decrement storage counter | |
| 336 | 341 | if file_size > 0 { | |
| 337 | - | db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size).await?; | |
| 342 | + | db::creator_tiers::decrement_storage_used(&db, user.id, file_size).await?; | |
| 338 | 343 | } | |
| 339 | 344 | ||
| 340 | 345 | Ok(htmx_toast_response("Clip deleted", "success")) | |
| @@ -349,16 +354,16 @@ pub(super) async fn delete_insertion( | |||
| 349 | 354 | /// GET /api/items/{id}/insertions | |
| 350 | 355 | #[tracing::instrument(skip_all, name = "insertions::list_placements")] | |
| 351 | 356 | pub(super) async fn list_placements( | |
| 352 | - | State(state): State<AppState>, | |
| 357 | + | State(db): State<PgPool>, | |
| 353 | 358 | AuthUser(user): AuthUser, | |
| 354 | 359 | Path(item_id): Path<ItemId>, | |
| 355 | 360 | ) -> Result<impl IntoResponse> { | |
| 356 | - | let (item, _project) = verify_item_ownership(&state.db, item_id, user.id).await?; | |
| 361 | + | let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?; | |
| 357 | 362 | ||
| 358 | - | let placements = db::content_insertions::list_placements_for_item(&state.db, item_id).await?; | |
| 363 | + | let placements = db::content_insertions::list_placements_for_item(&db, item_id).await?; | |
| 359 | 364 | // Only offer clips that can legally be placed on this item (a video clip on an | |
| 360 | 365 | // audio item would be rejected by create_placement, so don't surface it). | |
| 361 | - | let available: Vec<_> = db::content_insertions::list_insertions(&state.db, user.id) | |
| 366 | + | let available: Vec<_> = db::content_insertions::list_insertions(&db, user.id) | |
| 362 | 367 | .await? | |
| 363 | 368 | .into_iter() | |
| 364 | 369 | .filter(|i| clip_compatible_with_item(&i.media_type, item.item_type)) | |
| @@ -400,16 +405,16 @@ pub(super) async fn list_placements( | |||
| 400 | 405 | /// POST /api/items/{id}/insertions | |
| 401 | 406 | #[tracing::instrument(skip_all, name = "insertions::create_placement")] | |
| 402 | 407 | pub(super) async fn create_placement( | |
| 403 | - | State(state): State<AppState>, | |
| 408 | + | State(db): State<PgPool>, | |
| 404 | 409 | AuthUser(user): AuthUser, | |
| 405 | 410 | Path(item_id): Path<ItemId>, | |
| 406 | 411 | Json(req): Json<CreatePlacementRequest>, | |
| 407 | 412 | ) -> Result<impl IntoResponse> { | |
| 408 | 413 | user.check_not_suspended()?; | |
| 409 | - | let (item, _project) = verify_item_ownership(&state.db, item_id, user.id).await?; | |
| 414 | + | let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?; | |
| 410 | 415 | ||
| 411 | 416 | // Verify the insertion belongs to this user | |
| 412 | - | let insertion = db::content_insertions::get_insertion(&state.db, req.insertion_id, user.id) | |
| 417 | + | let insertion = db::content_insertions::get_insertion(&db, req.insertion_id, user.id) | |
| 413 | 418 | .await? | |
| 414 | 419 | .ok_or(AppError::NotFound)?; | |
| 415 | 420 | ||
| @@ -436,7 +441,7 @@ pub(super) async fn create_placement( | |||
| 436 | 441 | } | |
| 437 | 442 | ||
| 438 | 443 | let _placement = db::content_insertions::create_placement( | |
| 439 | - | &state.db, | |
| 444 | + | &db, | |
| 440 | 445 | item_id, | |
| 441 | 446 | insertion.id, | |
| 442 | 447 | req.position, | |
| @@ -453,19 +458,19 @@ pub(super) async fn create_placement( | |||
| 453 | 458 | /// DELETE /api/item-insertions/{id} | |
| 454 | 459 | #[tracing::instrument(skip_all, name = "insertions::delete_placement")] | |
| 455 | 460 | pub(super) async fn delete_placement( | |
| 456 | - | State(state): State<AppState>, | |
| 461 | + | State(db): State<PgPool>, | |
| 457 | 462 | AuthUser(user): AuthUser, | |
| 458 | 463 | Path(placement_id): Path<ContentInsertionPlacementId>, | |
| 459 | 464 | ) -> Result<impl IntoResponse> { | |
| 460 | 465 | user.check_not_suspended()?; | |
| 461 | 466 | // Get placement to verify item ownership | |
| 462 | - | let placement = db::content_insertions::get_placement_by_id(&state.db, placement_id) | |
| 467 | + | let placement = db::content_insertions::get_placement_by_id(&db, placement_id) | |
| 463 | 468 | .await? | |
| 464 | 469 | .ok_or(AppError::NotFound)?; | |
| 465 | 470 | ||
| 466 | - | verify_item_ownership(&state.db, placement.item_id, user.id).await?; | |
| 471 | + | verify_item_ownership(&db, placement.item_id, user.id).await?; | |
| 467 | 472 | ||
| 468 | - | db::content_insertions::delete_placement(&state.db, placement_id).await?; | |
| 473 | + | db::content_insertions::delete_placement(&db, placement_id).await?; | |
| 469 | 474 | ||
| 470 | 475 | Ok(htmx_toast_response("Clip removed", "success")) | |
| 471 | 476 | } |
| @@ -8,11 +8,13 @@ use axum::{ | |||
| 8 | 8 | use serde::Deserialize; | |
| 9 | 9 | use serde_json::json; | |
| 10 | 10 | ||
| 11 | + | use sqlx::PgPool; | |
| 12 | + | use crate::{AppCaches, AppLimiters}; | |
| 13 | + | ||
| 11 | 14 | use crate::{ | |
| 12 | 15 | auth::AuthUser, | |
| 13 | 16 | db::{self, CustomDomainId}, | |
| 14 | 17 | error::{AppError, Result}, | |
| 15 | - | AppState, | |
| 16 | 18 | }; | |
| 17 | 19 | ||
| 18 | 20 | #[derive(Deserialize)] | |
| @@ -24,7 +26,7 @@ pub(super) struct AddDomainRequest { | |||
| 24 | 26 | /// POST /api/domains: add a custom domain. | |
| 25 | 27 | #[tracing::instrument(skip_all, name = "api::domains::add")] | |
| 26 | 28 | pub(super) async fn add_domain( | |
| 27 | - | State(state): State<AppState>, | |
| 29 | + | State(db): State<PgPool>, | |
| 28 | 30 | AuthUser(session_user): AuthUser, | |
| 29 | 31 | Form(req): Form<AddDomainRequest>, | |
| 30 | 32 | ) -> Result<impl IntoResponse> { | |
| @@ -38,7 +40,7 @@ pub(super) async fn add_domain( | |||
| 38 | 40 | // durable `UNIQUE (domain)` is global, so a domain another user already holds | |
| 39 | 41 | // surfaces here as a clean 409, not a raw 500 (ultra-fuzz Run 12 Storage). | |
| 40 | 42 | let _row = | |
| 41 | - | db::custom_domains::create_custom_domain(&state.db, session_user.id, &domain, &verification_token) | |
| 43 | + | db::custom_domains::create_custom_domain(&db, session_user.id, &domain, &verification_token) | |
| 42 | 44 | .await | |
| 43 | 45 | .map_err(|e| crate::helpers::map_unique_violation(e, "That domain is already registered"))?; | |
| 44 | 46 | ||
| @@ -67,12 +69,13 @@ pub(super) struct VerifyDomainRequest { | |||
| 67 | 69 | /// POST /api/domains/verify: trigger DNS verification. | |
| 68 | 70 | #[tracing::instrument(skip_all, name = "api::domains::verify")] | |
| 69 | 71 | pub(super) async fn verify_domain( | |
| 70 | - | State(state): State<AppState>, | |
| 72 | + | State(db): State<PgPool>, | |
| 73 | + | State(caches): State<AppCaches>, | |
| 71 | 74 | AuthUser(session_user): AuthUser, | |
| 72 | 75 | Form(req): Form<VerifyDomainRequest>, | |
| 73 | 76 | ) -> Result<impl IntoResponse> { | |
| 74 | 77 | session_user.check_not_sandbox()?; | |
| 75 | - | let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id) | |
| 78 | + | let cd = db::custom_domains::get_custom_domain_by_user(&db, session_user.id) | |
| 76 | 79 | .await? | |
| 77 | 80 | .ok_or(AppError::NotFound)?; | |
| 78 | 81 | ||
| @@ -100,9 +103,8 @@ pub(super) async fn verify_domain( | |||
| 100 | 103 | } | |
| 101 | 104 | ||
| 102 | 105 | // Mark verified in DB and update cache | |
| 103 | - | db::custom_domains::mark_domain_verified(&state.db, cd.id).await?; | |
| 104 | - | state | |
| 105 | - | .caches.domain_cache | |
| 106 | + | db::custom_domains::mark_domain_verified(&db, cd.id).await?; | |
| 107 | + | caches.domain_cache | |
| 106 | 108 | .insert(cd.domain.clone(), session_user.id); | |
| 107 | 109 | ||
| 108 | 110 | Ok(axum::response::Html("<p class=\"success\">Domain verified successfully. Reload to see changes.</p>".to_string())) | |
| @@ -111,13 +113,14 @@ pub(super) async fn verify_domain( | |||
| 111 | 113 | /// DELETE /api/domains/{id}: remove a custom domain. | |
| 112 | 114 | #[tracing::instrument(skip_all, name = "api::domains::remove")] | |
| 113 | 115 | pub(super) async fn remove_domain( | |
| 114 | - | State(state): State<AppState>, | |
| 116 | + | State(db): State<PgPool>, | |
| 117 | + | State(caches): State<AppCaches>, | |
| 115 | 118 | AuthUser(session_user): AuthUser, | |
| 116 | 119 | Path(id): Path<CustomDomainId>, | |
| 117 | 120 | ) -> Result<impl IntoResponse> { | |
| 118 | 121 | session_user.check_not_sandbox()?; | |
| 119 | 122 | // Fetch first so we can remove from cache | |
| 120 | - | let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id) | |
| 123 | + | let cd = db::custom_domains::get_custom_domain_by_user(&db, session_user.id) | |
| 121 | 124 | .await? | |
| 122 | 125 | .ok_or(AppError::NotFound)?; | |
| 123 | 126 | ||
| @@ -125,10 +128,10 @@ pub(super) async fn remove_domain( | |||
| 125 | 128 | return Err(AppError::NotFound); | |
| 126 | 129 | } | |
| 127 | 130 | ||
| 128 | - | db::custom_domains::delete_custom_domain(&state.db, id, session_user.id).await?; | |
| 131 | + | db::custom_domains::delete_custom_domain(&db, id, session_user.id).await?; | |
| 129 | 132 | ||
| 130 | 133 | // Remove from cache | |
| 131 | - | state.caches.domain_cache.remove(&cd.domain); | |
| 134 | + | caches.domain_cache.remove(&cd.domain); | |
| 132 | 135 | ||
| 133 | 136 | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 134 | 137 | } | |
| @@ -136,10 +139,10 @@ pub(super) async fn remove_domain( | |||
| 136 | 139 | /// GET /api/domains: get current user's custom domain. | |
| 137 | 140 | #[tracing::instrument(skip_all, name = "api::domains::get")] | |
| 138 | 141 | pub(super) async fn get_domain( | |
| 139 | - | State(state): State<AppState>, | |
| 142 | + | State(db): State<PgPool>, | |
| 140 | 143 | AuthUser(session_user): AuthUser, | |
| 141 | 144 | ) -> Result<impl IntoResponse> { | |
| 142 | - | let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id).await?; | |
| 145 | + | let cd = db::custom_domains::get_custom_domain_by_user(&db, session_user.id).await?; | |
| 143 | 146 | ||
| 144 | 147 | match cd { | |
| 145 | 148 | Some(d) => { | |
| @@ -183,7 +186,9 @@ pub(super) struct CaddyAskQuery { | |||
| 183 | 186 | /// at capacity, return 503 so Caddy backs off and the pool stays free. | |
| 184 | 187 | #[tracing::instrument(skip_all, name = "api::domains::caddy_ask")] | |
| 185 | 188 | pub(super) async fn caddy_ask( | |
| 186 | - | State(state): State<AppState>, | |
| 189 | + | State(db): State<PgPool>, | |
| 190 | + | State(caches): State<AppCaches>, | |
| 191 | + | State(limiters): State<AppLimiters>, | |
| 187 | 192 | Query(q): Query<CaddyAskQuery>, | |
| 188 | 193 | ) -> impl IntoResponse { | |
| 189 | 194 | use metrics::{counter, gauge}; | |
| @@ -202,7 +207,7 @@ pub(super) async fn caddy_ask( | |||
| 202 | 207 | } | |
| 203 | 208 | ||
| 204 | 209 | // Fast path: cache hit, no DB, no semaphore. | |
| 205 | - | if state.caches.domain_cache.contains_key(&domain) { | |
| 210 | + | if caches.domain_cache.contains_key(&domain) { | |
| 206 | 211 | counter!("caddy_ask_total", "outcome" => "cache_hit").increment(1); | |
| 207 | 212 | return axum::http::StatusCode::OK; | |
| 208 | 213 | } | |
| @@ -210,17 +215,17 @@ pub(super) async fn caddy_ask( | |||
| 210 | 215 | // Slow path: cap concurrent DB lookups. `try_acquire` is non-blocking; | |
| 211 | 216 | // hitting the cap means we're already under pressure and want Caddy to | |
| 212 | 217 | // retry rather than queue. | |
| 213 | - | let Ok(_permit) = state.limiters.caddy_ask_semaphore.try_acquire() else { | |
| 218 | + | let Ok(_permit) = limiters.caddy_ask_semaphore.try_acquire() else { | |
| 214 | 219 | tracing::warn!(domain = %domain, "caddy-ask: cache-miss concurrency cap reached"); | |
| 215 | 220 | counter!("caddy_ask_total", "outcome" => "rejected_at_cap").increment(1); | |
| 216 | 221 | return axum::http::StatusCode::SERVICE_UNAVAILABLE; | |
| 217 | 222 | }; | |
| 218 | 223 | ||
| 219 | - | match db::custom_domains::get_verified_domain(&state.db, &domain).await { | |
| 224 | + | match db::custom_domains::get_verified_domain(&db, &domain).await { | |
| 220 | 225 | Ok(Some(d)) => { | |
| 221 | - | state.caches.domain_cache.insert(d.domain, d.user_id); | |
| 226 | + | caches.domain_cache.insert(d.domain, d.user_id); | |
| 222 | 227 | // Update cache-size gauge after the insert so it reflects current state. | |
| 223 | - | gauge!("domain_cache_entries").set(state.caches.domain_cache.len() as f64); | |
| 228 | + | gauge!("domain_cache_entries").set(caches.domain_cache.len() as f64); | |
| 224 | 229 | counter!("caddy_ask_total", "outcome" => "miss_found").increment(1); | |
| 225 | 230 | axum::http::StatusCode::OK | |
| 226 | 231 | } |
| @@ -13,12 +13,16 @@ use axum::{ | |||
| 13 | 13 | use serde::Deserialize; | |
| 14 | 14 | use zip::write::SimpleFileOptions; | |
| 15 | 15 | ||
| 16 | + | use sqlx::PgPool; | |
| 17 | + | use crate::email::EmailClient; | |
| 18 | + | use crate::background::BackgroundTx; | |
| 19 | + | use crate::AppStorage; | |
| 20 | + | ||
| 16 | 21 | use crate::{ | |
| 17 | 22 | auth::AuthUser, | |
| 18 | 23 | db, | |
| 19 | 24 | error::{AppError, Result, ResultExt}, | |
| 20 | 25 | helpers::is_htmx_request, | |
| 21 | - | AppState, | |
| 22 | 26 | }; | |
| 23 | 27 | ||
| 24 | 28 | use super::export_error_html; | |
| @@ -49,22 +53,25 @@ pub(in crate::routes::api) struct ContentExportQuery { | |||
| 49 | 53 | /// (insertions are user-scoped and always excluded from per-project exports). | |
| 50 | 54 | #[tracing::instrument(skip_all, name = "exports::export_content")] | |
| 51 | 55 | pub(in crate::routes::api) async fn export_content( | |
| 52 | - | State(state): State<AppState>, | |
| 56 | + | State(db): State<PgPool>, | |
| 57 | + | State(storage): State<AppStorage>, | |
| 58 | + | State(email): State<EmailClient>, | |
| 59 | + | State(bg): State<BackgroundTx>, | |
| 53 | 60 | headers: HeaderMap, | |
| 54 | 61 | Query(query): Query<ContentExportQuery>, | |
| 55 | 62 | AuthUser(user): AuthUser, | |
| 56 | 63 | ) -> Result<Response> { | |
| 57 | 64 | let is_htmx = is_htmx_request(&headers); | |
| 58 | 65 | ||
| 59 | - | let s3 = state.storage.s3.as_ref().ok_or_else(|| { | |
| 66 | + | let s3 = storage.s3.as_ref().ok_or_else(|| { | |
| 60 | 67 | AppError::ServiceUnavailable("File storage is not configured".to_string()) | |
| 61 | 68 | })?; | |
| 62 | 69 | ||
| 63 | 70 | // Collect all S3 keys from items, versions, and insertions. These are fast | |
| 64 | 71 | // indexed reads, done up front so an empty export is reported immediately; | |
| 65 | 72 | // the heavy download + zip + multipart upload runs in the background (below). | |
| 66 | - | let item_keys = db::items::get_user_s3_keys(&state.db, user.id).await?; | |
| 67 | - | let version_keys = db::versions::get_user_version_s3_keys(&state.db, user.id).await?; | |
| 73 | + | let item_keys = db::items::get_user_s3_keys(&db, user.id).await?; | |
| 74 | + | let version_keys = db::versions::get_user_version_s3_keys(&db, user.id).await?; | |
| 68 | 75 | ||
| 69 | 76 | // Build the list of (s3_key, zip_path, db_size) triples. The DB-known file | |
| 70 | 77 | // size lets us enforce the per-file/total caps without a per-file S3 HEAD | |
| @@ -112,7 +119,7 @@ pub(in crate::routes::api) async fn export_content( | |||
| 112 | 119 | // Insertions are user-scoped (not project-scoped), so only include | |
| 113 | 120 | // them when exporting all content (no project_id filter). | |
| 114 | 121 | if query.project_id.is_none() { | |
| 115 | - | let insertions = db::content_insertions::list_insertions(&state.db, user.id).await?; | |
| 122 | + | let insertions = db::content_insertions::list_insertions(&db, user.id).await?; | |
| 116 | 123 | for ins in &insertions { | |
| 117 | 124 | let ext = extension_from_key(&ins.storage_key); | |
| 118 | 125 | let title = sanitize_filename(&ins.title); | |
| @@ -127,7 +134,7 @@ pub(in crate::routes::api) async fn export_content( | |||
| 127 | 134 | // keeps that shared query intact for storage accounting, which must still see | |
| 128 | 135 | // every key. | |
| 129 | 136 | let export_keys: Vec<String> = files.iter().map(|(k, _, _)| k.clone()).collect(); | |
| 130 | - | let quarantined = db::scanning::quarantined_s3_keys(&state.db, &export_keys).await?; | |
| 137 | + | let quarantined = db::scanning::quarantined_s3_keys(&db, &export_keys).await?; | |
| 131 | 138 | if !quarantined.is_empty() { | |
| 132 | 139 | tracing::warn!( | |
| 133 | 140 | user_id = %user.id, dropped = quarantined.len(), | |
| @@ -148,12 +155,12 @@ pub(in crate::routes::api) async fn export_content( | |||
| 148 | 155 | // returns now instead of holding a connection + export permit for the whole | |
| 149 | 156 | // multi-GB job — a wedged S3 GET used to pin both indefinitely. | |
| 150 | 157 | let s3 = s3.clone(); | |
| 151 | - | let email = state.email.clone(); | |
| 158 | + | let email = email.clone(); | |
| 152 | 159 | let username = user.username.to_string(); | |
| 153 | 160 | let to_email = user.email.clone(); | |
| 154 | 161 | let to_name = user.display_name.clone(); | |
| 155 | 162 | let user_id = user.id; | |
| 156 | - | state.bg.spawn("content_export", async move { | |
| 163 | + | bg.spawn("content_export", async move { | |
| 157 | 164 | match build_content_export(&s3, user_id, &username, files).await { | |
| 158 | 165 | Ok(download_url) => { | |
| 159 | 166 | if let Err(e) = email |
| @@ -13,13 +13,13 @@ use axum::{ | |||
| 13 | 13 | use bytes::Bytes; | |
| 14 | 14 | use tokio::sync::mpsc; | |
| 15 | 15 | use tokio_stream::{wrappers::ReceiverStream, StreamExt}; | |
| 16 | + | use sqlx::PgPool; | |
| 16 | 17 | use crate::{ | |
| 17 | 18 | auth::AuthUser, | |
| 18 | 19 | db, | |
| 19 | 20 | error::{AppError, Result, ResultExt}, | |
| 20 | 21 | helpers::{is_htmx_request, sanitize_csv_cell}, | |
| 21 | 22 | templates::{ExportDownloadTemplate, FormStatusTemplate}, | |
| 22 | - | AppState, | |
| 23 | 23 | }; | |
| 24 | 24 | ||
| 25 | 25 | /// Rows per page for streamed CSV exports. | |
| @@ -152,15 +152,15 @@ fn download_response(content: Vec<u8>, filename: &str, content_type: &str) -> Re | |||
| 152 | 152 | /// Export all projects and items as a downloadable JSON file. | |
| 153 | 153 | #[tracing::instrument(skip_all, name = "exports::export_projects")] | |
| 154 | 154 | pub(super) async fn export_projects( | |
| 155 | - | State(state): State<AppState>, | |
| 155 | + | State(db): State<PgPool>, | |
| 156 | 156 | headers: HeaderMap, | |
| 157 | 157 | AuthUser(user): AuthUser, | |
| 158 | 158 | ) -> Result<Response> { | |
| 159 | 159 | let is_htmx = is_htmx_request(&headers); | |
| 160 | 160 | ||
| 161 | 161 | // Get all projects and all items in 2 queries (not N+1) | |
| 162 | - | let projects = db::projects::get_projects_by_user(&state.db, user.id).await?; | |
| 163 | - | let all_items = db::items::get_items_by_user(&state.db, user.id).await?; | |
| 162 | + | let projects = db::projects::get_projects_by_user(&db, user.id).await?; | |
| 163 | + | let all_items = db::items::get_items_by_user(&db, user.id).await?; | |
| 164 | 164 | ||
| 165 | 165 | // DoS backstop: this handler materializes the entire catalog into an | |
| 166 | 166 | // in-memory JSON tree and string on the request path. The ceiling is far | |
| @@ -177,16 +177,16 @@ pub(super) async fn export_projects( | |||
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | 179 | let all_item_ids: Vec<db::ItemId> = all_items.iter().map(|i| i.id).collect(); | |
| 180 | - | let tags_map = db::tags::get_tags_for_items(&state.db, &all_item_ids).await?; | |
| 180 | + | let tags_map = db::tags::get_tags_for_items(&db, &all_item_ids).await?; | |
| 181 | 181 | ||
| 182 | 182 | // Batch-load per-item data (chapters, versions, license keys, item-scoped promo codes) | |
| 183 | - | let chapters_map = db::chapters::get_chapters_by_items(&state.db, &all_item_ids).await?; | |
| 184 | - | let versions_map = db::versions::get_versions_by_items(&state.db, &all_item_ids).await?; | |
| 185 | - | let license_keys_map = db::license_keys::get_license_keys_by_items(&state.db, &all_item_ids).await?; | |
| 186 | - | let item_promo_codes_map = db::promo_codes::get_promo_codes_by_items(&state.db, &all_item_ids).await?; | |
| 183 | + | let chapters_map = db::chapters::get_chapters_by_items(&db, &all_item_ids).await?; | |
| 184 | + | let versions_map = db::versions::get_versions_by_items(&db, &all_item_ids).await?; | |
| 185 | + | let license_keys_map = db::license_keys::get_license_keys_by_items(&db, &all_item_ids).await?; | |
| 186 | + | let item_promo_codes_map = db::promo_codes::get_promo_codes_by_items(&db, &all_item_ids).await?; | |
| 187 | 187 | ||
| 188 | 188 | // Promo codes are creator-scoped, fetch once | |
| 189 | - | let promo_codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?; | |
| 189 | + | let promo_codes = db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?; | |
| 190 | 190 | let promo_codes_data: Vec<serde_json::Value> = promo_codes.iter().map(|pc| { | |
| 191 | 191 | serde_json::json!({ | |
| 192 | 192 | "code": pc.code, | |
| @@ -214,8 +214,8 @@ pub(super) async fn export_projects( | |||
| 214 | 214 | ||
| 215 | 215 | // Batch-load per-project data (blog posts, bundle maps) | |
| 216 | 216 | let project_ids: Vec<db::ProjectId> = projects.iter().map(|p| p.id).collect(); | |
| 217 | - | let blog_posts_map = db::blog_posts::get_blog_posts_by_projects(&state.db, &project_ids).await?; | |
| 218 | - | let bundle_pairs = db::bundles::get_bundle_maps_by_projects(&state.db, &project_ids).await?; | |
| 217 | + | let blog_posts_map = db::blog_posts::get_blog_posts_by_projects(&db, &project_ids).await?; | |
| 218 | + | let bundle_pairs = db::bundles::get_bundle_maps_by_projects(&db, &project_ids).await?; | |
| 219 | 219 | let mut bundle_map: std::collections::HashMap<db::ItemId, Vec<db::ItemId>> = | |
| 220 | 220 | std::collections::HashMap::new(); | |
| 221 | 221 | for (bundle_id, child_id) in &bundle_pairs { | |
| @@ -379,9 +379,9 @@ pub(super) async fn export_projects( | |||
| 379 | 379 | } | |
| 380 | 380 | ||
| 381 | 381 | // Collections (batch-loaded to avoid N+1) | |
| 382 | - | let collections = db::collections::get_collections_by_user(&state.db, user.id).await?; | |
| 382 | + | let collections = db::collections::get_collections_by_user(&db, user.id).await?; | |
| 383 | 383 | let collection_ids: Vec<db::CollectionId> = collections.iter().map(|c| c.id).collect(); | |
| 384 | - | let collection_items_map = db::collections::get_item_ids_by_collections(&state.db, &collection_ids).await?; | |
| 384 | + | let collection_items_map = db::collections::get_item_ids_by_collections(&db, &collection_ids).await?; | |
| 385 | 385 | let mut collections_data = Vec::new(); | |
| 386 | 386 | for c in &collections { | |
| 387 | 387 | let item_ids = collection_items_map.get(&c.id).cloned().unwrap_or_default(); | |
| @@ -397,7 +397,7 @@ pub(super) async fn export_projects( | |||
| 397 | 397 | } | |
| 398 | 398 | ||
| 399 | 399 | // Custom domain | |
| 400 | - | let custom_domain = db::custom_domains::get_custom_domain_by_user(&state.db, user.id).await?; | |
| 400 | + | let custom_domain = db::custom_domains::get_custom_domain_by_user(&db, user.id).await?; | |
| 401 | 401 | let custom_domain_data = custom_domain.map(|d| serde_json::json!({ | |
| 402 | 402 | "domain": d.domain, | |
| 403 | 403 | "verified": d.verified, | |
| @@ -433,12 +433,12 @@ pub(super) async fn export_projects( | |||
| 433 | 433 | /// Export all sales transactions as a downloadable CSV file. | |
| 434 | 434 | #[tracing::instrument(skip_all, name = "exports::export_sales")] | |
| 435 | 435 | pub(super) async fn export_sales( | |
| 436 | - | State(state): State<AppState>, | |
| 436 | + | State(db): State<PgPool>, | |
| 437 | 437 | headers: HeaderMap, | |
| 438 | 438 | AuthUser(user): AuthUser, | |
| 439 | 439 | ) -> Result<Response> { | |
| 440 | 440 | let is_htmx = is_htmx_request(&headers); | |
| 441 | - | let pool = state.db.clone(); | |
| 441 | + | let pool = db.clone(); | |
| 442 | 442 | let uid = user.id; | |
| 443 | 443 | let rx = spawn_paginated_csv( | |
| 444 | 444 | "Date,Item ID,Item Title,Amount,Status,Buyer Email\n", | |
| @@ -477,12 +477,12 @@ pub(super) async fn export_sales( | |||
| 477 | 477 | /// Export revenue splits as a downloadable CSV file. | |
| 478 | 478 | #[tracing::instrument(skip_all, name = "exports::export_splits")] | |
| 479 | 479 | pub(super) async fn export_splits( | |
| 480 | - | State(state): State<AppState>, | |
| 480 | + | State(db): State<PgPool>, | |
| 481 | 481 | headers: HeaderMap, | |
| 482 | 482 | AuthUser(user): AuthUser, | |
| 483 | 483 | ) -> Result<Response> { | |
| 484 | 484 | let is_htmx = is_htmx_request(&headers); | |
| 485 | - | let pool = state.db.clone(); | |
| 485 | + | let pool = db.clone(); | |
| 486 | 486 | let uid = user.id; | |
| 487 | 487 | let rx = spawn_paginated_csv( | |
| 488 | 488 | "Date,Type,Direction,Recipient,Amount,Split %\n", | |
| @@ -514,12 +514,12 @@ pub(super) async fn export_splits( | |||
| 514 | 514 | /// Export all purchase transactions as a downloadable CSV file. | |
| 515 | 515 | #[tracing::instrument(skip_all, name = "exports::export_purchases")] | |
| 516 | 516 | pub(super) async fn export_purchases( | |
| 517 | - | State(state): State<AppState>, | |
| 517 | + | State(db): State<PgPool>, | |
| 518 | 518 | headers: HeaderMap, | |
| 519 | 519 | AuthUser(user): AuthUser, | |
| 520 | 520 | ) -> Result<Response> { | |
| 521 | 521 | let is_htmx = is_htmx_request(&headers); | |
| 522 | - | let pool = state.db.clone(); | |
| 522 | + | let pool = db.clone(); | |
| 523 | 523 | let uid = user.id; | |
| 524 | 524 | let rx = spawn_paginated_csv( | |
| 525 | 525 | "Date,Item ID,Item Title,Amount,Status\n", | |
| @@ -574,12 +574,12 @@ pub(super) async fn export_purchases( | |||
| 574 | 574 | /// Export followers and subscribers as a downloadable CSV file. | |
| 575 | 575 | #[tracing::instrument(skip_all, name = "exports::export_followers")] | |
| 576 | 576 | pub(super) async fn export_followers( | |
| 577 | - | State(state): State<AppState>, | |
| 577 | + | State(db): State<PgPool>, | |
| 578 | 578 | headers: HeaderMap, | |
| 579 | 579 | AuthUser(user): AuthUser, | |
| 580 | 580 | ) -> Result<Response> { | |
| 581 | 581 | let is_htmx = is_htmx_request(&headers); | |
| 582 | - | let pool = state.db.clone(); | |
| 582 | + | let pool = db.clone(); | |
| 583 | 583 | let uid = user.id; | |
| 584 | 584 | ||
| 585 | 585 | // Two-section CSV (followers, then subscribers); each section pages | |
| @@ -654,12 +654,12 @@ pub(super) async fn export_followers( | |||
| 654 | 654 | /// Export subscriptions as a downloadable CSV file with full detail. | |
| 655 | 655 | #[tracing::instrument(skip_all, name = "exports::export_subscriptions")] | |
| 656 | 656 | pub(super) async fn export_subscriptions( | |
| 657 | - | State(state): State<AppState>, | |
| 657 | + | State(db): State<PgPool>, | |
| 658 | 658 | headers: HeaderMap, | |
| 659 | 659 | AuthUser(user): AuthUser, | |
| 660 | 660 | ) -> Result<Response> { | |
| 661 | 661 | let is_htmx = is_htmx_request(&headers); | |
| 662 | - | let pool = state.db.clone(); | |
| 662 | + | let pool = db.clone(); | |
| 663 | 663 | let uid = user.id; | |
| 664 | 664 | let rx = spawn_paginated_csv( | |
| 665 | 665 | "Project,Tier,Price,Username,Status,Period Start,Period End,Canceled At,Created At\n", | |
| @@ -698,12 +698,12 @@ pub(super) async fn export_subscriptions( | |||
| 698 | 698 | /// Export buyer contacts (who opted to share their email) as CSV. | |
| 699 | 699 | #[tracing::instrument(skip_all, name = "exports::export_contacts")] | |
| 700 | 700 | pub(super) async fn export_contacts( | |
| 701 | - | State(state): State<AppState>, | |
| 701 | + | State(db): State<PgPool>, | |
| 702 | 702 | headers: HeaderMap, | |
| 703 | 703 | AuthUser(user): AuthUser, | |
| 704 | 704 | ) -> Result<Response> { | |
| 705 | 705 | let is_htmx = is_htmx_request(&headers); | |
| 706 | - | let pool = state.db.clone(); | |
| 706 | + | let pool = db.clone(); | |
| 707 | 707 | let uid = user.id; | |
| 708 | 708 | let rx = spawn_paginated_csv( | |
| 709 | 709 | "Username,Email,Purchases,Total Spent,Last Purchase\n", |
| @@ -6,19 +6,25 @@ use axum::{ | |||
| 6 | 6 | }; | |
| 7 | 7 | use uuid::Uuid; | |
| 8 | 8 | ||
| 9 | + | use sqlx::PgPool; | |
| 10 | + | use crate::config::Config; | |
| 11 | + | use crate::email::EmailClient; | |
| 12 | + | use crate::background::BackgroundTx; | |
| 13 | + | ||
| 9 | 14 | use crate::{ | |
| 10 | 15 | auth::AuthUser, | |
| 11 | 16 | db::{self, FollowTargetType, UserId}, | |
| 12 | 17 | error::{AppError, Result}, | |
| 13 | - | helpers::spawn_email, | |
| 14 | 18 | templates::{FollowButtonTemplate, TagFollowToggleTemplate}, | |
| 15 | - | AppState, | |
| 16 | 19 | }; | |
| 17 | 20 | ||
| 18 | 21 | /// POST /api/follow/{target_type}/{target_id}: follow a user or project. | |
| 19 | 22 | #[tracing::instrument(skip_all, name = "follows::follow_target")] | |
| 20 | 23 | pub(super) async fn follow_target( | |
| 21 | - | State(state): State<AppState>, | |
| 24 | + | State(db): State<PgPool>, | |
| 25 | + | State(config): State<Config>, | |
| 26 | + | State(email): State<EmailClient>, | |
| 27 | + | State(bg): State<BackgroundTx>, | |
| 22 | 28 | AuthUser(user): AuthUser, | |
| 23 | 29 | Path((target_type_str, target_id)): Path<(String, Uuid)>, | |
| 24 | 30 | ) -> Result<Response> { | |
| @@ -27,14 +33,14 @@ pub(super) async fn follow_target( | |||
| 27 | 33 | let target_type: FollowTargetType = target_type_str.parse() | |
| 28 | 34 | .map_err(|_| AppError::BadRequest("Invalid target type".to_string()))?; | |
| 29 | 35 | ||
| 30 | - | validate_target(&state, target_type, target_id, user.id).await?; | |
| 36 | + | validate_target(&db, target_type, target_id, user.id).await?; | |
| 31 | 37 | ||
| 32 | - | db::follows::follow(&state.db, user.id, target_type, target_id).await?; | |
| 38 | + | db::follows::follow(&db, user.id, target_type, target_id).await?; | |
| 33 | 39 | ||
| 34 | 40 | // Subscribe to project content mailing list (non-critical) | |
| 35 | 41 | if target_type == FollowTargetType::Project | |
| 36 | 42 | && let Err(e) = db::mailing_lists::subscribe_to_content_list( | |
| 37 | - | &state.db, db::ProjectId::from(target_id), user.id, | |
| 43 | + | &db, db::ProjectId::from(target_id), user.id, | |
| 38 | 44 | ).await | |
| 39 | 45 | { | |
| 40 | 46 | tracing::warn!(project_id = %target_id, error = ?e, "failed to subscribe to content mailing list"); | |
| @@ -44,35 +50,38 @@ pub(super) async fn follow_target( | |||
| 44 | 50 | match target_type { | |
| 45 | 51 | FollowTargetType::Tag => {} // no notification for tag follows | |
| 46 | 52 | FollowTargetType::User => { | |
| 47 | - | if let Ok(Some(target_user)) = db::users::get_user_by_id(&state.db, UserId::from(target_id)).await | |
| 53 | + | if let Ok(Some(target_user)) = db::users::get_user_by_id(&db, UserId::from(target_id)).await | |
| 48 | 54 | && target_user.notify_follower | |
| 49 | 55 | { | |
| 50 | - | let follower_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten(); | |
| 56 | + | let follower_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten(); | |
| 51 | 57 | let follower_username = follower_user.as_ref() | |
| 52 | 58 | .map(|u| u.username.to_string()) | |
| 53 | 59 | .unwrap_or_else(|| "Someone".to_string()); | |
| 54 | 60 | let target_email = target_user.email.clone(); | |
| 55 | 61 | let target_name = target_user.display_name.clone(); | |
| 56 | 62 | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 57 | - | &state.config.host_url, target_user.id, crate::email::UnsubscribeAction::Follower, &target_user.id.to_string(), &state.config.signing_secret, | |
| 63 | + | &config.host_url, target_user.id, crate::email::UnsubscribeAction::Follower, &target_user.id.to_string(), &config.signing_secret, | |
| 58 | 64 | ); | |
| 59 | - | spawn_email!(state, "follower notification", |email| { | |
| 60 | - | email.send_follower_notification( | |
| 65 | + | let email = email.clone(); | |
| 66 | + | bg.spawn("follower notification", async move { | |
| 67 | + | if let Err(e) = email.send_follower_notification( | |
| 61 | 68 | &target_email, | |
| 62 | 69 | target_name.as_deref(), | |
| 63 | 70 | &follower_username, | |
| 64 | 71 | "you", | |
| 65 | 72 | Some(&unsub_url), | |
| 66 | - | ) | |
| 73 | + | ).await { | |
| 74 | + | tracing::error!(error = ?e, "failed to send follower notification"); | |
| 75 | + | } | |
| 67 | 76 | }); | |
| 68 | 77 | } | |
| 69 | 78 | } | |
| 70 | 79 | FollowTargetType::Project => { | |
| 71 | - | if let Ok(Some(project)) = db::projects::get_project_by_id(&state.db, db::ProjectId::from(target_id)).await | |
| 72 | - | && let Ok(Some(owner)) = db::users::get_user_by_id(&state.db, project.user_id).await | |
| 80 | + | if let Ok(Some(project)) = db::projects::get_project_by_id(&db, db::ProjectId::from(target_id)).await | |
| 81 | + | && let Ok(Some(owner)) = db::users::get_user_by_id(&db, project.user_id).await | |
| 73 | 82 | && owner.notify_follower | |
| 74 | 83 | { | |
| 75 | - | let follower_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten(); | |
| 84 | + | let follower_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten(); | |
| 76 | 85 | let follower_username = follower_user.as_ref() | |
| 77 | 86 | .map(|u| u.username.to_string()) | |
| 78 | 87 | .unwrap_or_else(|| "Someone".to_string()); | |
| @@ -80,16 +89,19 @@ pub(super) async fn follow_target( | |||
| 80 | 89 | let owner_email = owner.email.clone(); | |
| 81 | 90 | let owner_name = owner.display_name.clone(); | |
| 82 | 91 | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 83 | - | &state.config.host_url, owner.id, crate::email::UnsubscribeAction::Follower, &owner.id.to_string(), &state.config.signing_secret, | |
| 92 | + | &config.host_url, owner.id, crate::email::UnsubscribeAction::Follower, &owner.id.to_string(), &config.signing_secret, | |
| 84 | 93 | ); | |
| 85 | - | spawn_email!(state, "follower notification", |email| { | |
| 86 | - | email.send_follower_notification( | |
| 94 | + | let email = email.clone(); | |
| 95 | + | bg.spawn("follower notification", async move { | |
| 96 | + | if let Err(e) = email.send_follower_notification( | |
| 87 | 97 | &owner_email, | |
| 88 | 98 | owner_name.as_deref(), | |
| 89 | 99 | &follower_username, | |
| 90 | 100 | &context, | |
| 91 | 101 | Some(&unsub_url), | |
| 92 | - | ) | |
| 102 | + | ).await { | |
| 103 | + | tracing::error!(error = ?e, "failed to send follower notification"); | |
| 104 | + | } | |
| 93 | 105 | }); | |
| 94 | 106 | } | |
| 95 | 107 | } | |
| @@ -103,7 +115,7 @@ pub(super) async fn follow_target( | |||
| 103 | 115 | .into_response()); | |
| 104 | 116 | } | |
| 105 | 117 | ||
| 106 | - | let count = db::follows::get_follower_count(&state.db, target_type, target_id).await?; | |
| 118 | + | let count = db::follows::get_follower_count(&db, target_type, target_id).await?; | |
| 107 | 119 | ||
| 108 | 120 | Ok(FollowButtonTemplate { | |
| 109 | 121 | target_type: target_type.to_string(), | |
| @@ -117,7 +129,7 @@ pub(super) async fn follow_target( | |||
| 117 | 129 | /// DELETE /api/follow/{target_type}/{target_id}: unfollow a user or project. | |
| 118 | 130 | #[tracing::instrument(skip_all, name = "follows::unfollow_target")] | |
| 119 | 131 | pub(super) async fn unfollow_target( | |
| 120 | - | State(state): State<AppState>, | |
| 132 | + | State(db): State<PgPool>, | |
| 121 | 133 | AuthUser(user): AuthUser, | |
| 122 | 134 | Path((target_type_str, target_id)): Path<(String, Uuid)>, | |
| 123 | 135 | ) -> Result<Response> { | |
| @@ -125,14 +137,14 @@ pub(super) async fn unfollow_target( | |||
| 125 | 137 | let target_type: FollowTargetType = target_type_str.parse() | |
| 126 | 138 | .map_err(|_| AppError::BadRequest("Invalid target type".to_string()))?; | |
| 127 | 139 | ||
| 128 | - | validate_target(&state, target_type, target_id, user.id).await?; | |
| 140 | + | validate_target(&db, target_type, target_id, user.id).await?; | |
| 129 | 141 | ||
| 130 | - | db::follows::unfollow(&state.db, user.id, target_type, target_id).await?; | |
| 142 | + | db::follows::unfollow(&db, user.id, target_type, target_id).await?; | |
| 131 | 143 | ||
| 132 | 144 | // Unsubscribe from all project mailing lists (non-critical) | |
| 133 | 145 | if target_type == FollowTargetType::Project | |
| 134 | 146 | && let Err(e) = db::mailing_lists::unsubscribe_from_project( | |
| 135 | - | &state.db, db::ProjectId::from(target_id), user.id, | |
| 147 | + | &db, db::ProjectId::from(target_id), user.id, | |
| 136 | 148 | ).await | |
| 137 | 149 | { | |
| 138 | 150 | tracing::warn!(project_id = %target_id, error = ?e, "failed to unsubscribe from project mailing lists"); | |
| @@ -146,7 +158,7 @@ pub(super) async fn unfollow_target( | |||
| 146 | 158 | .into_response()); | |
| 147 | 159 | } | |
| 148 | 160 | ||
| 149 | - | let count = db::follows::get_follower_count(&state.db, target_type, target_id).await?; | |
| 161 | + | let count = db::follows::get_follower_count(&db, target_type, target_id).await?; | |
| 150 | 162 | ||
| 151 | 163 | Ok(FollowButtonTemplate { | |
| 152 | 164 | target_type: target_type.to_string(), | |
| @@ -160,7 +172,7 @@ pub(super) async fn unfollow_target( | |||
| 160 | 172 | /// Validate that the target exists and prevent self-following for user targets. | |
| 161 | 173 | /// Project self-following is allowed (creators may want to follow their own project feed). | |
| 162 | 174 | async fn validate_target( | |
| 163 | - | state: &AppState, | |
| 175 | + | db: &PgPool, | |
| 164 | 176 | target_type: FollowTargetType, | |
| 165 | 177 | target_id: Uuid, | |
| 166 | 178 | follower_id: UserId, | |
| @@ -170,17 +182,17 @@ async fn validate_target( | |||
| 170 | 182 | if *follower_id == target_id { | |
| 171 | 183 | return Err(AppError::BadRequest("Cannot follow yourself".to_string())); | |
| 172 | 184 | } | |
| 173 | - | db::users::get_user_by_id(&state.db, UserId::from(target_id)) | |
| 185 | + | db::users::get_user_by_id(db, UserId::from(target_id)) | |
| 174 | 186 | .await? | |
| 175 | 187 | .ok_or(AppError::NotFound)?; | |
| 176 | 188 | } | |
| 177 | 189 | FollowTargetType::Project => { | |
| 178 | - | db::projects::get_project_by_id(&state.db, db::ProjectId::from(target_id)) | |
| 190 | + | db::projects::get_project_by_id(db, db::ProjectId::from(target_id)) | |
| 179 | 191 | .await? | |
| 180 | 192 | .ok_or(AppError::NotFound)?; | |
| 181 | 193 | } | |
| 182 | 194 | FollowTargetType::Tag => { | |
| 183 | - | db::tags::get_tag_by_id(&state.db, db::TagId::from(target_id)) | |
| 195 | + | db::tags::get_tag_by_id(db, db::TagId::from(target_id)) | |
| 184 | 196 | .await? | |
| 185 | 197 | .ok_or(AppError::NotFound)?; | |
| 186 | 198 | } |
| @@ -13,7 +13,7 @@ use crate::auth::AuthUser; | |||
| 13 | 13 | use crate::db::{self, GitAccessTokenId}; | |
| 14 | 14 | use crate::error::{AppError, Result}; | |
| 15 | 15 | use crate::helpers::{hx_toast, is_htmx_request}; | |
| 16 | - | use crate::AppState; | |
| 16 | + | use sqlx::PgPool; | |
| 17 | 17 | ||
| 18 | 18 | #[derive(Debug, Deserialize)] | |
| 19 | 19 | pub struct CreateTokenRequest { | |
| @@ -29,17 +29,17 @@ pub struct CreateTokenRequest { | |||
| 29 | 29 | /// GET /api/users/me/git-tokens/list: HTMX partial listing the user's tokens. | |
| 30 | 30 | #[tracing::instrument(skip_all, name = "git_tokens::list_html")] | |
| 31 | 31 | pub(super) async fn list_html( | |
| 32 | - | State(state): State<AppState>, | |
| 32 | + | State(db): State<PgPool>, | |
| 33 | 33 | AuthUser(user): AuthUser, | |
| 34 | 34 | ) -> Result<impl IntoResponse> { | |
| 35 | - | let html = render_list(&state, user.id, None).await?; | |
| 35 | + | let html = render_list(&db, user.id, None).await?; | |
| 36 | 36 | Ok(axum::response::Html(html)) | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | /// POST /api/users/me/git-tokens: mint a new token. Returns the plaintext once. | |
| 40 | 40 | #[tracing::instrument(skip_all, name = "git_tokens::create")] | |
| 41 | 41 | pub(super) async fn create( | |
| 42 | - | State(state): State<AppState>, | |
| 42 | + | State(db): State<PgPool>, | |
| 43 | 43 | headers: HeaderMap, | |
| 44 | 44 | AuthUser(user): AuthUser, | |
| 45 | 45 | Form(req): Form<CreateTokenRequest>, | |
| @@ -70,10 +70,10 @@ pub(super) async fn create( | |||
| 70 | 70 | }; | |
| 71 | 71 | ||
| 72 | 72 | let (plaintext, hash) = crate::crypto::generate_git_token(); | |
| 73 | - | db::git_access_tokens::create(&state.db, user.id, name, &hash, can_push, expires_at).await?; | |
| 73 | + | db::git_access_tokens::create(&db, user.id, name, &hash, can_push, expires_at).await?; | |
| 74 | 74 | ||
| 75 | 75 | if is_htmx_request(&headers) { | |
| 76 | - | let html = render_list(&state, user.id, Some(&plaintext)).await?; | |
| 76 | + | let html = render_list(&db, user.id, Some(&plaintext)).await?; | |
| 77 | 77 | return Ok(( | |
| 78 | 78 | [("HX-Trigger", hx_toast("Access token created", "success"))], | |
| 79 | 79 | axum::response::Html(html), | |
| @@ -88,17 +88,17 @@ pub(super) async fn create( | |||
| 88 | 88 | /// DELETE /api/users/me/git-tokens/{id}: revoke a token. | |
| 89 | 89 | #[tracing::instrument(skip_all, name = "git_tokens::revoke")] | |
| 90 | 90 | pub(super) async fn revoke( | |
| 91 | - | State(state): State<AppState>, | |
| 91 | + | State(db): State<PgPool>, | |
| 92 | 92 | headers: HeaderMap, | |
| 93 | 93 | AuthUser(user): AuthUser, | |
| 94 | 94 | Path(id): Path<GitAccessTokenId>, | |
| 95 | 95 | ) -> Result<Response> { | |
| 96 | 96 | user.check_not_suspended()?; | |
| 97 | - | if !db::git_access_tokens::revoke(&state.db, id, user.id).await? { | |
| 97 | + | if !db::git_access_tokens::revoke(&db, id, user.id).await? { | |
| 98 | 98 | return Err(AppError::NotFound); | |
| 99 | 99 | } | |
| 100 | 100 | if is_htmx_request(&headers) { | |
| 101 | - | let html = render_list(&state, user.id, None).await?; | |
| 101 | + | let html = render_list(&db, user.id, None).await?; | |
| 102 | 102 | return Ok(( | |
| 103 | 103 | [("HX-Trigger", hx_toast("Access token revoked", "success"))], | |
| 104 | 104 | axum::response::Html(html), | |
| @@ -110,8 +110,8 @@ pub(super) async fn revoke( | |||
| 110 | 110 | ||
| 111 | 111 | /// Render the tokens list partial, optionally with a freshly-minted plaintext | |
| 112 | 112 | /// shown once at the top. | |
| 113 | - | async fn render_list(state: &AppState, user_id: db::UserId, new_token: Option<&str>) -> Result<String> { | |
| 114 | - | let tokens = db::git_access_tokens::list_by_user(&state.db, user_id).await?; | |
| 113 | + | async fn render_list(db: &PgPool, user_id: db::UserId, new_token: Option<&str>) -> Result<String> { | |
| 114 | + | let tokens = db::git_access_tokens::list_by_user(db, user_id).await?; | |
| 115 | 115 | let tokens: Vec<GitTokenView> = tokens.iter().map(GitTokenView::from).collect(); | |
| 116 | 116 | crate::helpers::render_fragment(&crate::templates::GitTokensListTemplate { | |
| 117 | 117 | tokens, |
| @@ -11,10 +11,15 @@ use axum::{ | |||
| 11 | 11 | }; | |
| 12 | 12 | use serde::{Deserialize, Serialize}; | |
| 13 | 13 | ||
| 14 | + | use sqlx::PgPool; | |
| 15 | + | use crate::config::Config; | |
| 16 | + | use crate::email::EmailClient; | |
| 17 | + | use crate::background::BackgroundTx; | |
| 18 | + | use crate::{Billing, AppStorage, Integrations}; | |
| 19 | + | ||
| 14 | 20 | use crate::{ | |
| 15 | 21 | db::{self, Cents, ItemId}, | |
| 16 | 22 | error::{AppError, Result, ResultExt}, | |
| 17 | - | AppState, | |
| 18 | 23 | }; | |
| 19 | 24 | ||
| 20 | 25 | /// Request body for creating a guest checkout session. | |
| @@ -39,12 +44,14 @@ struct CheckoutResponse { | |||
| 39 | 44 | /// or redirects to it. | |
| 40 | 45 | #[tracing::instrument(skip_all, name = "guest_checkout::create")] | |
| 41 | 46 | pub(super) async fn create_guest_checkout( | |
| 42 | - | State(state): State<AppState>, | |
| 47 | + | State(db): State<PgPool>, | |
| 48 | + | State(config): State<Config>, | |
| 49 | + | State(payments): State<Billing>, | |
| 43 | 50 | Path(item_id): Path<ItemId>, | |
| 44 | 51 | Json(body): Json<GuestCheckoutRequest>, | |
| 45 | 52 | ) -> Result<Response> { | |
| 46 | 53 | // Fetch item | |
| 47 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 54 | + | let item = db::items::get_item_by_id(&db, item_id) | |
| 48 | 55 | .await? | |
| 49 | 56 | .ok_or(AppError::NotFound)?; | |
| 50 | 57 | ||
| @@ -53,10 +60,10 @@ pub(super) async fn create_guest_checkout( | |||
| 53 | 60 | } | |
| 54 | 61 | ||
| 55 | 62 | // Fetch seller via project | |
| 56 | - | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 63 | + | let project = db::projects::get_project_by_id(&db, item.project_id) | |
| 57 | 64 | .await? | |
| 58 | 65 | .ok_or(AppError::NotFound)?; | |
| 59 | - | let seller = db::users::get_user_by_id(&state.db, project.user_id) | |
| 66 | + | let seller = db::users::get_user_by_id(&db, project.user_id) | |
| 60 | 67 | .await? | |
| 61 | 68 | .ok_or(AppError::NotFound)?; | |
| 62 | 69 | ||
| @@ -93,7 +100,7 @@ pub(super) async fn create_guest_checkout( | |||
| 93 | 100 | } | |
| 94 | 101 | // Guests have no account, so no platform-wide Fan+ credit fallback (None). | |
| 95 | 102 | if let Some(validated) = | |
| 96 | - | db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, None, code_str).await? | |
| 103 | + | db::promo_codes::lookup_and_validate_promo(&db, seller_id, None, code_str).await? | |
| 97 | 104 | { | |
| 98 | 105 | use db::promo_codes::{PromoApplication, PromoIneligible}; | |
| 99 | 106 | match db::promo_codes::apply_promo_to_item(&validated, item_id, item.project_id, item.price_cents)? { | |
| @@ -139,7 +146,7 @@ pub(super) async fn create_guest_checkout( | |||
| 139 | 146 | return Err(AppError::BadRequest("Creator's payment account is not ready".to_string())); | |
| 140 | 147 | } | |
| 141 | 148 | ||
| 142 | - | let stripe = state.stripe.as_ref() | |
| 149 | + | let stripe = payments.stripe.as_ref() | |
| 143 | 150 | .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; | |
| 144 | 151 | ||
| 145 | 152 | // Reserve the promo code BEFORE creating the Stripe session or pending row, | |
| @@ -149,7 +156,7 @@ pub(super) async fn create_guest_checkout( | |||
| 149 | 156 | // and a failed reservation could orphan the pending row's promo. Every | |
| 150 | 157 | // failure path below now releases this reservation. | |
| 151 | 158 | if let Some(pc_id) = promo_code_id { | |
| 152 | - | let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id) | |
| 159 | + | let reserved = db::promo_codes::try_increment_use_count(&db, pc_id) | |
| 153 | 160 | .await | |
| 154 | 161 | .context("reserve promo code use at guest checkout")?; | |
| 155 | 162 | if !reserved { | |
| @@ -160,13 +167,13 @@ pub(super) async fn create_guest_checkout( | |||
| 160 | 167 | // Release the reservation above on any failure path below (no-op if no promo). | |
| 161 | 168 | let release_promo = || async { | |
| 162 | 169 | if let Some(pc_id) = promo_code_id { | |
| 163 | - | db::promo_codes::release_use_count(&state.db, pc_id).await.ok(); | |
| 170 | + | db::promo_codes::release_use_count(&db, pc_id).await.ok(); | |
| 164 | 171 | } | |
| 165 | 172 | }; | |
| 166 | 173 | ||
| 167 | 174 | // Build URLs | |
| 168 | - | let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url); | |
| 169 | - | let cancel_url = format!("{}/i/{}", state.config.host_url, item_id); | |
| 175 | + | let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", config.host_url); | |
| 176 | + | let cancel_url = format!("{}/i/{}", config.host_url, item_id); | |
| 170 | 177 | ||
| 171 | 178 | // Create guest checkout session | |
| 172 | 179 | let checkout_params = crate::payments::GuestCheckoutParams { | |
| @@ -195,7 +202,7 @@ pub(super) async fn create_guest_checkout( | |||
| 195 | 202 | // Release the promo and return an error (guests have no purchase page to | |
| 196 | 203 | // redirect to, unlike the authenticated path). | |
| 197 | 204 | match db::transactions::create_transaction( | |
| 198 | - | &state.db, | |
| 205 | + | &db, | |
| 199 | 206 | &db::transactions::CreateTransactionParams { | |
| 200 | 207 | buyer_id: None, | |
| 201 | 208 | seller_id, | |
| @@ -269,15 +276,16 @@ pub(super) async fn guest_checkout_preflight() -> Response { | |||
| 269 | 276 | /// No authentication required; the token is the proof of purchase. | |
| 270 | 277 | #[tracing::instrument(skip_all, name = "guest_checkout::download")] | |
| 271 | 278 | pub(super) async fn guest_download( | |
| 272 | - | State(state): State<AppState>, | |
| 279 | + | State(db): State<PgPool>, | |
| 280 | + | State(storage): State<AppStorage>, | |
| 273 | 281 | Path(token): Path<db::DownloadToken>, | |
| 274 | 282 | ) -> Result<Response> { | |
| 275 | - | let tx = db::transactions::get_transaction_by_download_token(&state.db, token) | |
| 283 | + | let tx = db::transactions::get_transaction_by_download_token(&db, token) | |
| 276 | 284 | .await? | |
| 277 | 285 | .ok_or(AppError::NotFound)?; | |
| 278 | 286 | ||
| 279 | 287 | let item_id = tx.item_id.ok_or(AppError::NotFound)?; | |
| 280 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 288 | + | let item = db::items::get_item_by_id(&db, item_id) | |
| 281 | 289 | .await? | |
| 282 | 290 | .ok_or(AppError::NotFound)?; | |
| 283 | 291 | ||
| @@ -290,7 +298,7 @@ pub(super) async fn guest_download( | |||
| 290 | 298 | // token that 404'd despite having paid (Run 15 fulfillment gap). | |
| 291 | 299 | let (s3_key, scan_status) = match item.audio_s3_key.clone().or(item.video_s3_key.clone()) { | |
| 292 | 300 | Some(key) => (key, item.scan_status), | |
| 293 | - | None => db::versions::get_versions_by_item(&state.db, item_id) | |
| 301 | + | None => db::versions::get_versions_by_item(&db, item_id) | |
| 294 | 302 | .await? | |
| 295 | 303 | .into_iter() | |
| 296 | 304 | .filter_map(|v| v.s3_key.map(|k| (k, v.scan_status, v.is_current, v.created_at))) | |
| @@ -310,7 +318,7 @@ pub(super) async fn guest_download( | |||
| 310 | 318 | return Err(AppError::NotFound); | |
| 311 | 319 | } | |
| 312 | 320 | ||
| 313 | - | let s3 = state.storage.s3.as_ref() | |
| 321 | + | let s3 = storage.s3.as_ref() | |
| 314 | 322 | .ok_or_else(|| AppError::ServiceUnavailable("File storage is not configured".to_string()))?; | |
| 315 | 323 | ||
| 316 | 324 | let download_url = s3.presign_download(&crate::storage::S3Key::from_stored(&s3_key), Some(3600)).await?; | |
| @@ -323,12 +331,13 @@ pub(super) async fn guest_download( | |||
| 323 | 331 | /// Attach a guest purchase to the authenticated user's account using a claim token. | |
| 324 | 332 | #[tracing::instrument(skip_all, name = "guest_checkout::claim")] | |
| 325 | 333 | pub(super) async fn claim_purchase( | |
| 326 | - | State(state): State<AppState>, | |
| 334 | + | State(db): State<PgPool>, | |
| 335 | + | State(integrations): State<Integrations>, | |
| 327 | 336 | crate::auth::AuthUser(user): crate::auth::AuthUser, | |
| 328 | 337 | Json(body): Json<ClaimRequest>, | |
| 329 | 338 | ) -> Result<Response> { | |
| 330 | 339 | user.check_not_sandbox()?; | |
| 331 | - | let tx = db::transactions::claim_guest_purchase(&state.db, body.claim_token, user.id) | |
| 340 | + | let tx = db::transactions::claim_guest_purchase(&db, body.claim_token, user.id) | |
| 332 | 341 | .await? | |
| 333 | 342 | .ok_or_else(|| AppError::BadRequest( | |
| 334 | 343 | "Invalid or already-claimed token".to_string() | |
| @@ -348,7 +357,7 @@ pub(super) async fn claim_purchase( | |||
| 348 | 357 | // once per purchase. | |
| 349 | 358 | if let Some(item_id) = tx.item_id { | |
| 350 | 359 | crate::routes::stripe::webhook::checkout_helpers::maybe_generate_license_key( | |
| 351 | - | &state.db, state.wam.as_ref(), item_id, user.id, tx.id, | |
| 360 | + | &db, integrations.wam.as_ref(), item_id, user.id, tx.id, | |
| 352 | 361 | ) | |
| 353 | 362 | .await; | |
| 354 | 363 | } | |
| @@ -373,14 +382,17 @@ pub(super) struct FreeGuestClaimRequest { | |||
| 373 | 382 | /// and sends download + claim links via email. No Stripe involved. | |
| 374 | 383 | #[tracing::instrument(skip_all, name = "guest_checkout::claim_free")] | |
| 375 | 384 | pub(super) async fn claim_free_guest( | |
| 376 | - | State(state): State<AppState>, | |
| 385 | + | State(db): State<PgPool>, | |
| 386 | + | State(config): State<Config>, | |
| 387 | + | State(email_client): State<EmailClient>, | |
| 388 | + | State(bg): State<BackgroundTx>, | |
| 377 | 389 | Path(item_id): Path<ItemId>, | |
| 378 | 390 | Json(body): Json<FreeGuestClaimRequest>, | |
| 379 | 391 | ) -> Result<Response> { | |
| 380 | 392 | let email = db::Email::new(&body.email) | |
| 381 | 393 | .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?; | |
| 382 | 394 | ||
| 383 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 395 | + | let item = db::items::get_item_by_id(&db, item_id) | |
| 384 | 396 | .await? | |
| 385 | 397 | .ok_or(AppError::NotFound)?; | |
| 386 | 398 | ||
| @@ -389,10 +401,10 @@ pub(super) async fn claim_free_guest( | |||
| 389 | 401 | } | |
| 390 | 402 | ||
| 391 | 403 | // Fetch seller | |
| 392 | - | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 404 | + | let project = db::projects::get_project_by_id(&db, item.project_id) | |
| 393 | 405 | .await? | |
| 394 | 406 | .ok_or(AppError::NotFound)?; | |
| 395 | - | let seller = db::users::get_user_by_id(&state.db, project.user_id) | |
| 407 | + | let seller = db::users::get_user_by_id(&db, project.user_id) | |
| 396 | 408 | .await? | |
| 397 | 409 | .ok_or(AppError::NotFound)?; | |
| 398 | 410 | ||
| @@ -409,7 +421,7 @@ pub(super) async fn claim_free_guest( | |||
| 409 | 421 | ||
| 410 | 422 | // Create completed transaction | |
| 411 | 423 | let result = db::transactions::create_free_guest_transaction( | |
| 412 | - | &state.db, | |
| 424 | + | &db, | |
| 413 | 425 | None, | |
| 414 | 426 | seller.id, | |
| 415 | 427 | item_id, | |
| @@ -428,7 +440,7 @@ pub(super) async fn claim_free_guest( | |||
| 428 | 440 | } | |
| 429 | 441 | Ok(_) => { | |
| 430 | 442 | // Increment sales count | |
| 431 | - | let _ = db::items::increment_sales_count(&state.db, item_id).await; | |
| 443 | + | let _ = db::items::increment_sales_count(&db, item_id).await; | |
| 432 | 444 | } | |
| 433 | 445 | Err(e) => { | |
| 434 | 446 | // Unique violation (already in library for existing user) | |
| @@ -445,18 +457,18 @@ pub(super) async fn claim_free_guest( | |||
| 445 | 457 | } | |
| 446 | 458 | ||
| 447 | 459 | // Send download email | |
| 448 | - | let host_url = &state.config.host_url; | |
| 460 | + | let host_url = &config.host_url; | |
| 449 | 461 | let download_url = format!("{}/download/{}", host_url, download_token); | |
| 450 | 462 | let claim_url = format!("{}/claim?token={}", host_url, claim_token); | |
| 451 | 463 | ||
| 452 | 464 | // Always send the claim link — there's no auto-attach to skip it for. | |
| 453 | 465 | { | |
| 454 | - | let email_client = state.email.clone(); | |
| 466 | + | let email_client = email_client.clone(); | |
| 455 | 467 | let email_addr = email.clone().into_inner(); | |
| 456 | 468 | let item_title = item.title.clone(); | |
| 457 | 469 | let dl_url = download_url.clone(); | |
| 458 | 470 | let cl_url = claim_url.clone(); | |
| 459 | - | state.bg.spawn("free guest claim email", async move { | |
| 471 | + | bg.spawn("free guest claim email", async move { | |
| 460 | 472 | if let Err(e) = email_client.send_guest_purchase_confirmation( | |
| 461 | 473 | &email_addr, &item_title, "Free", &dl_url, &cl_url, | |
| 462 | 474 | ).await { |
| @@ -9,12 +9,14 @@ use base64::Engine; | |||
| 9 | 9 | use serde::Deserialize; | |
| 10 | 10 | use serde_json::json; | |
| 11 | 11 | ||
| 12 | + | use sqlx::PgPool; | |
| 13 | + | use crate::background::BackgroundTx; | |
| 14 | + | ||
| 12 | 15 | use crate::{ | |
| 13 | 16 | auth::AuthUser, | |
| 14 | 17 | db::{self, ImportJobId, ProjectId}, | |
| 15 | 18 | error::{AppError, Result}, | |
| 16 | 19 | import::{self, ColumnMapping, ImportSource}, | |
| 17 | - | AppState, | |
| 18 | 20 | }; | |
| 19 | 21 | ||
| 20 | 22 | /// Maximum CSV size: 10 MB (base64-encoded). | |
| @@ -35,7 +37,8 @@ pub(super) struct StartImportRequest { | |||
| 35 | 37 | /// to parse the CSV and run the import pipeline. | |
| 36 | 38 | #[tracing::instrument(skip_all, name = "imports::start_import")] | |
| 37 | 39 | pub(super) async fn start_import( | |
| 38 | - | State(state): State<AppState>, | |
| 40 | + | State(db): State<PgPool>, | |
| 41 | + | State(bg): State<BackgroundTx>, | |
| 39 | 42 | AuthUser(user): AuthUser, | |
| 40 | 43 | Json(req): Json<StartImportRequest>, | |
| 41 | 44 | ) -> Result<impl IntoResponse> { | |
| @@ -43,7 +46,7 @@ pub(super) async fn start_import( | |||
| 43 | 46 | user.check_not_sandbox()?; | |
| 44 | 47 | ||
| 45 | 48 | // Validate project ownership | |
| 46 | - | super::verify_project_ownership(&state.db, req.project_id, user.id).await?; | |
| 49 | + | super::verify_project_ownership(&db, req.project_id, user.id).await?; | |
| 47 | 50 | ||
| 48 | 51 | // Only generic_csv is currently supported | |
| 49 | 52 | if req.source != ImportSource::GenericCsv { | |
| @@ -71,7 +74,7 @@ pub(super) async fn start_import( | |||
| 71 | 74 | ||
| 72 | 75 | // Create import job | |
| 73 | 76 | let job = db::imports::create_import_job( | |
| 74 | - | &state.db, | |
| 77 | + | &db, | |
| 75 | 78 | user.id, | |
| 76 | 79 | req.project_id, | |
| 77 | 80 | req.source, | |
| @@ -80,7 +83,7 @@ pub(super) async fn start_import( | |||
| 80 | 83 | .await?; | |
| 81 | 84 | ||
| 82 | 85 | let job_id = job.id; | |
| 83 | - | let pool = state.db.clone(); | |
| 86 | + | let pool = db.clone(); | |
| 84 | 87 | let project_id = req.project_id; | |
| 85 | 88 | let user_id = user.id; | |
| 86 | 89 | ||
| @@ -89,7 +92,7 @@ pub(super) async fn start_import( | |||
| 89 | 92 | // per-item write loop against the shared 25-connection pool, starving request | |
| 90 | 93 | // handlers to their acquire timeout. `state.bg` caps concurrent execution and | |
| 91 | 94 | // queues the rest (Run 21 perf, chronic import N+1/uncapped-spawn). | |
| 92 | - | state.bg.spawn("import-pipeline", async move { | |
| 95 | + | bg.spawn("import-pipeline", async move { | |
| 93 | 96 | if let Err(e) = import::pipeline::run_import( | |
| 94 | 97 | &pool, | |
| 95 | 98 | job_id, | |
| @@ -110,13 +113,13 @@ pub(super) async fn start_import( | |||
| 110 | 113 | /// Get the status and progress of an import job. | |
| 111 | 114 | #[tracing::instrument(skip_all, name = "imports::get_import_status")] | |
| 112 | 115 | pub(super) async fn get_import_status( | |
| 113 | - | State(state): State<AppState>, | |
| 116 | + | State(db): State<PgPool>, | |
| 114 | 117 | AuthUser(user): AuthUser, | |
| 115 | 118 | Path(id): Path<String>, | |
| 116 | 119 | ) -> Result<impl IntoResponse> { | |
| 117 | 120 | let job_id: ImportJobId = id.parse().map_err(|_| AppError::NotFound)?; | |
| 118 | 121 | ||
| 119 | - | let job = db::imports::get_import_job(&state.db, job_id, user.id) | |
| 122 | + | let job = db::imports::get_import_job(&db, job_id, user.id) | |
| 120 | 123 | .await? | |
| 121 | 124 | .ok_or(AppError::NotFound)?; | |
| 122 | 125 | ||
| @@ -137,10 +140,10 @@ pub(super) async fn get_import_status( | |||
| 137 | 140 | /// List all import jobs for the authenticated user. | |
| 138 | 141 | #[tracing::instrument(skip_all, name = "imports::list_imports")] | |
| 139 | 142 | pub(super) async fn list_imports( | |
| 140 | - | State(state): State<AppState>, | |
| 143 | + | State(db): State<PgPool>, | |
| 141 | 144 | AuthUser(user): AuthUser, | |
| 142 | 145 | ) -> Result<impl IntoResponse> { | |
| 143 | - | let jobs = db::imports::list_import_jobs(&state.db, user.id).await?; | |
| 146 | + | let jobs = db::imports::list_import_jobs(&db, user.id).await?; | |
| 144 | 147 | ||
| 145 | 148 | let data: Vec<serde_json::Value> = jobs | |
| 146 | 149 | .into_iter() |
| @@ -11,6 +11,9 @@ use axum::{ | |||
| 11 | 11 | use chrono::{DateTime, Datelike, Utc}; | |
| 12 | 12 | use serde::{Deserialize, Serialize}; | |
| 13 | 13 | ||
| 14 | + | use sqlx::PgPool; | |
| 15 | + | use crate::config::Config; | |
| 16 | + | ||
| 14 | 17 | use crate::{ | |
| 15 | 18 | auth::AuthUser, | |
| 16 | 19 | db::{self, ItemId, KeyCode, LicenseKeyId}, | |
| @@ -20,7 +23,6 @@ use crate::{ | |||
| 20 | 23 | types::LicenseKeyRow, | |
| 21 | 24 | types::ListResponse, | |
| 22 | 25 | validation, | |
| 23 | - | AppState, | |
| 24 | 26 | }; | |
| 25 | 27 | use jsonwebtoken::{encode, EncodingKey, Header}; | |
| 26 | 28 | ||
| @@ -112,7 +114,7 @@ pub(crate) struct ValidateKeyRequest { | |||
| 112 | 114 | )] | |
| 113 | 115 | #[tracing::instrument(skip_all, name = "license_keys::validate_key")] | |
| 114 | 116 | pub(super) async fn validate_key( | |
| 115 | - | State(state): State<AppState>, | |
| 117 | + | State(db): State<PgPool>, | |
| 116 | 118 | Json(req): Json<ValidateKeyRequest>, | |
| 117 | 119 | ) -> Result<impl IntoResponse> { | |
| 118 | 120 | // key is auto-validated by KeyCode deserialization | |
| @@ -122,7 +124,7 @@ pub(super) async fn validate_key( | |||
| 122 | 124 | } | |
| 123 | 125 | ||
| 124 | 126 | // Look up key | |
| 125 | - | let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? { | |
| 127 | + | let key = match db::license_keys::get_license_key_by_code(&db, &req.key).await? { | |
| 126 | 128 | Some(k) => k, | |
| 127 | 129 | None => return Ok(Json(ValidateKeyResponse { | |
| 128 | 130 | valid: false, | |
| @@ -143,13 +145,13 @@ pub(super) async fn validate_key( | |||
| 143 | 145 | } | |
| 144 | 146 | ||
| 145 | 147 | // Check if already activated on this machine (fast path, no lock needed) | |
| 146 | - | if let Some(activation) = db::license_keys::get_activation(&state.db, key.id, &req.machine_id).await? | |
| 148 | + | if let Some(activation) = db::license_keys::get_activation(&db, key.id, &req.machine_id).await? | |
| 147 | 149 | && activation.is_active | |
| 148 | 150 | { | |
| 149 | - | db::license_keys::touch_activation(&state.db, activation.id).await?; | |
| 151 | + | db::license_keys::touch_activation(&db, activation.id).await?; | |
| 150 | 152 | // Report the authoritative denormalized count, not the pre-lookup read, | |
| 151 | 153 | // so a concurrent activation on another machine isn't reflected stale. | |
| 152 | - | let activation_count = db::license_keys::get_activation_count(&state.db, key.id).await?; | |
| 154 | + | let activation_count = db::license_keys::get_activation_count(&db, key.id).await?; | |
| 153 | 155 | return Ok(Json(ValidateKeyResponse { | |
| 154 | 156 | valid: true, | |
| 155 | 157 | activated: None, | |
| @@ -165,7 +167,7 @@ pub(super) async fn validate_key( | |||
| 165 | 167 | ||
| 166 | 168 | // Atomically check limit and create activation (serialized via FOR UPDATE) | |
| 167 | 169 | let activation = db::license_keys::try_create_activation( | |
| 168 | - | &state.db, | |
| 170 | + | &db, | |
| 169 | 171 | key.id, | |
| 170 | 172 | &req.machine_id, | |
| 171 | 173 | req.label.as_deref(), | |
| @@ -183,7 +185,7 @@ pub(super) async fn validate_key( | |||
| 183 | 185 | // try_create_activation recomputed the denormalized count under the row | |
| 184 | 186 | // lock; read it back rather than guessing `+ 1` (which is wrong if another | |
| 185 | 187 | // machine activated concurrently). | |
| 186 | - | let activation_count = db::license_keys::get_activation_count(&state.db, key.id).await?; | |
| 188 | + | let activation_count = db::license_keys::get_activation_count(&db, key.id).await?; | |
| 187 | 189 | Ok(Json(ValidateKeyResponse { | |
| 188 | 190 | valid: true, | |
| 189 | 191 | activated: Some(true), | |
| @@ -217,12 +219,12 @@ pub(crate) struct DeactivateKeyRequest { | |||
| 217 | 219 | )] | |
| 218 | 220 | #[tracing::instrument(skip_all, name = "license_keys::deactivate_key")] | |
| 219 | 221 | pub(super) async fn deactivate_key( | |
| 220 | - | State(state): State<AppState>, | |
| 222 | + | State(db): State<PgPool>, | |
| 221 | 223 | Json(req): Json<DeactivateKeyRequest>, | |
| 222 | 224 | ) -> Result<impl IntoResponse> { | |
| 223 | 225 | validation::validate_machine_id(&req.machine_id)?; | |
| 224 | 226 | ||
| 225 | - | let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? { | |
| 227 | + | let key = match db::license_keys::get_license_key_by_code(&db, &req.key).await? { | |
| 226 | 228 | Some(k) => k, | |
| 227 | 229 | None => return Ok(Json(DeactivateKeyResponse { | |
| 228 | 230 | success: false, | |
| @@ -230,7 +232,7 @@ pub(super) async fn deactivate_key( | |||
| 230 | 232 | })), | |
| 231 | 233 | }; | |
| 232 | 234 | ||
| 233 | - | let deactivated = db::license_keys::deactivate_machine(&state.db, key.id, &req.machine_id).await?; | |
| 235 | + | let deactivated = db::license_keys::deactivate_machine(&db, key.id, &req.machine_id).await?; | |
| 234 | 236 | ||
| 235 | 237 | Ok(Json(DeactivateKeyResponse { | |
| 236 | 238 | success: deactivated, | |
| @@ -240,8 +242,8 @@ pub(super) async fn deactivate_key( | |||
| 240 | 242 | ||
| 241 | 243 | /// Compute the status response for a key code. Shared by the GET-path form and | |
| 242 | 244 | /// the POST-body form so they can't drift. | |
| 243 | - | async fn resolve_key_status(state: &AppState, key_code: &KeyCode) -> Result<KeyStatusResponse> { | |
| 244 | - | let key = match db::license_keys::get_license_key_by_code(&state.db, key_code).await? { | |
| 245 | + | async fn resolve_key_status(db: &PgPool, key_code: &KeyCode) -> Result<KeyStatusResponse> { | |
| 246 | + | let key = match db::license_keys::get_license_key_by_code(db, key_code).await? { | |
| 245 | 247 | Some(k) => k, | |
| 246 | 248 | None => return Ok(KeyStatusResponse { | |
| 247 | 249 | valid: false, | |
| @@ -301,10 +303,10 @@ pub(crate) struct KeyStatusRequest { | |||
| 301 | 303 | )] | |
| 302 | 304 | #[tracing::instrument(skip_all, name = "license_keys::key_status_post")] | |
| 303 | 305 | pub(super) async fn key_status_post( | |
| 304 | - | State(state): State<AppState>, | |
| 306 | + | State(db): State<PgPool>, | |
| 305 | 307 | Json(req): Json<KeyStatusRequest>, | |
| 306 | 308 | ) -> Result<impl IntoResponse> { | |
| 307 | - | Ok(Json(resolve_key_status(&state, &req.key).await?)) | |
| 309 | + | Ok(Json(resolve_key_status(&db, &req.key).await?)) | |
| 308 | 310 | } | |
| 309 | 311 | ||
| 310 | 312 | /// Quick validity check without activating. | |
| @@ -324,11 +326,11 @@ pub(super) async fn key_status_post( | |||
| 324 | 326 | )] | |
| 325 | 327 | #[tracing::instrument(skip_all, name = "license_keys::key_status")] | |
| 326 | 328 | pub(super) async fn key_status( | |
| 327 | - | State(state): State<AppState>, | |
| 329 | + | State(db): State<PgPool>, | |
| 328 | 330 | Path(key_code): Path<String>, | |
| 329 | 331 | ) -> Result<impl IntoResponse> { | |
| 330 | 332 | let key_code = KeyCode::new(&key_code)?; | |
| 331 | - | Ok(Json(resolve_key_status(&state, &key_code).await?)) | |
| 333 | + | Ok(Json(resolve_key_status(&db, &key_code).await?)) | |
| 332 | 334 | } | |
| 333 | 335 | ||
| 334 | 336 | // ============================================================================= | |
| @@ -347,7 +349,7 @@ pub struct UpdateLicenseSettingsForm { | |||
| 347 | 349 | /// Toggle license keys and set default activation limit for an item. | |
| 348 | 350 | #[tracing::instrument(skip_all, name = "license_keys::update_license_settings")] | |
| 349 | 351 | pub(super) async fn update_license_settings( | |
| 350 | - | State(state): State<AppState>, | |
| 352 | + | State(db): State<PgPool>, | |
| 351 | 353 | headers: HeaderMap, | |
| 352 | 354 | AuthUser(user): AuthUser, | |
| 353 | 355 | Path(id): Path<ItemId>, | |
| @@ -355,10 +357,10 @@ pub(super) async fn update_license_settings( | |||
| 355 | 357 | ) -> Result<Response> { | |
| 356 | 358 | user.check_not_suspended()?; | |
| 357 | 359 | ||
| 358 | - | verify_item_ownership(&state.db, id, user.id).await?; | |
| 360 | + | verify_item_ownership(&db, id, user.id).await?; | |
| 359 | 361 | ||
| 360 | 362 | let enable = req.enable_license_keys.is_some(); | |
| 361 | - | db::items::update_item_license_settings(&state.db, id, user.id, enable, req.default_max_activations).await?; | |
| 363 | + | db::items::update_item_license_settings(&db, id, user.id, enable, req.default_max_activations).await?; | |
| 362 | 364 | ||
| 363 | 365 | // License text preset | |
| 364 | 366 | let preset_str = req.license_preset.as_deref().filter(|s| !s.is_empty()); | |
| @@ -379,10 +381,10 @@ pub(super) async fn update_license_settings( | |||
| 379 | 381 | } else { | |
| 380 | 382 | None | |
| 381 | 383 | }; | |
| 382 | - | db::items::update_item_license_text(&state.db, id, user.id, Some(preset_key), custom_text).await?; | |
| 384 | + | db::items::update_item_license_text(&db, id, user.id, Some(preset_key), custom_text).await?; | |
| 383 | 385 | } else { | |
| 384 | 386 | // Clear license | |
| 385 | - | db::items::update_item_license_text(&state.db, id, user.id, None, None).await?; | |
| 387 | + | db::items::update_item_license_text(&db, id, user.id, None, None).await?; | |
| 386 | 388 | } | |
| 387 | 389 | ||
| 388 | 390 | if is_htmx_request(&headers) { | |
| @@ -404,7 +406,7 @@ pub struct GenerateKeyForm { | |||
| 404 | 406 | /// Generate a new license key for an item (creator dashboard). | |
| 405 | 407 | #[tracing::instrument(skip_all, name = "license_keys::generate_key")] | |
| 406 | 408 | pub(super) async fn generate_key( | |
| 407 | - | State(state): State<AppState>, | |
| 409 | + | State(db): State<PgPool>, | |
| 408 | 410 | headers: HeaderMap, | |
| 409 | 411 | AuthUser(user): AuthUser, | |
| 410 | 412 | Path(item_id): Path<ItemId>, | |
| @@ -412,7 +414,7 @@ pub(super) async fn generate_key( | |||
| 412 | 414 | ) -> Result<Response> { | |
| 413 | 415 | user.check_not_suspended()?; | |
| 414 | 416 | ||
| 415 | - | let (item, _project) = verify_item_ownership(&state.db, item_id, user.id).await?; | |
| 417 | + | let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?; | |
| 416 | 418 | ||
| 417 | 419 | if !item.enable_license_keys { | |
| 418 | 420 | return Err(AppError::BadRequest( | |
| @@ -426,7 +428,7 @@ pub(super) async fn generate_key( | |||
| 426 | 428 | // Cap at 1000 manually-generated keys per item, enforced atomically under an | |
| 427 | 429 | // item-row lock so concurrent generates can't race past it (fuzz 2026-07-06 C6-1). | |
| 428 | 430 | let _key = match db::license_keys::create_manual_key_capped( | |
| 429 | - | &state.db, | |
| 431 | + | &db, | |
| 430 | 432 | item_id, | |
| 431 | 433 | user.id, | |
| 432 | 434 | &key_code, | |
| @@ -444,7 +446,7 @@ pub(super) async fn generate_key( | |||
| 444 | 446 | }; | |
| 445 | 447 | ||
| 446 | 448 | if is_htmx_request(&headers) { | |
| 447 | - | let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?; | |
| 449 | + | let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?; | |
| 448 | 450 | return Ok(ItemLicenseKeysTemplate { | |
| 449 | 451 | license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(), | |
| 450 | 452 | } | |
| @@ -463,14 +465,14 @@ pub(super) async fn generate_key( | |||
| 463 | 465 | /// List all license keys for an item (creator dashboard). | |
| 464 | 466 | #[tracing::instrument(skip_all, name = "license_keys::list_keys")] | |
| 465 | 467 | pub(super) async fn list_keys( | |
| 466 | - | State(state): State<AppState>, | |
| 468 | + | State(db): State<PgPool>, | |
| 467 | 469 | headers: HeaderMap, | |
| 468 | 470 | AuthUser(user): AuthUser, | |
| 469 | 471 | Path(item_id): Path<ItemId>, | |
| 470 | 472 | ) -> Result<Response> { | |
| 471 | - | verify_item_ownership(&state.db, item_id, user.id).await?; | |
| 473 | + | verify_item_ownership(&db, item_id, user.id).await?; | |
| 472 | 474 | ||
| 473 | - | let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?; | |
| 475 | + | let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?; | |
| 474 | 476 | ||
| 475 | 477 | if is_htmx_request(&headers) { | |
| 476 | 478 | return Ok(ItemLicenseKeysTemplate { | |
| @@ -492,23 +494,23 @@ pub(super) async fn list_keys( | |||
| 492 | 494 | /// Revoke a license key (creator dashboard). | |
| 493 | 495 | #[tracing::instrument(skip_all, name = "license_keys::revoke_key")] | |
| 494 | 496 | pub(super) async fn revoke_key( | |
| 495 | - | State(state): State<AppState>, | |
| 497 | + | State(db): State<PgPool>, | |
| 496 | 498 | headers: HeaderMap, | |
| 497 | 499 | AuthUser(user): AuthUser, | |
| 498 | 500 | Path(key_id): Path<LicenseKeyId>, | |
| 499 | 501 | ) -> Result<Response> { | |
| 500 | 502 | user.check_not_suspended()?; | |
| 501 | 503 | ||
| 502 | - | let key = db::license_keys::get_license_key_by_id_unchecked(&state.db, key_id) | |
| 504 | + | let key = db::license_keys::get_license_key_by_id_unchecked(&db, key_id) | |
| 503 | 505 | .await? | |
| 504 | 506 | .ok_or(AppError::NotFound)?; | |
| 505 | 507 | ||
| 506 | - | verify_item_ownership(&state.db, key.item_id, user.id).await?; | |
| 508 | + | verify_item_ownership(&db, key.item_id, user.id).await?; | |
| 507 | 509 | ||
| 508 | - | db::license_keys::revoke_license_key(&state.db, key_id).await?; | |
| 510 | + | db::license_keys::revoke_license_key(&db, key_id).await?; | |
| 509 | 511 | ||
| 510 | 512 | if is_htmx_request(&headers) { | |
| 511 | - | let keys = db::license_keys::get_license_keys_by_item(&state.db, key.item_id).await?; | |
| 513 | + | let keys = db::license_keys::get_license_keys_by_item(&db, key.item_id).await?; | |
| 512 | 514 | return Ok(( | |
| 513 | 515 | [("HX-Trigger", hx_toast("License key revoked", "success"))], | |
| 514 | 516 | ItemLicenseKeysTemplate { | |
| @@ -579,12 +581,13 @@ const LICENSE_VERIFY_EXPIRY_SECS: i64 = 7 * 24 * 3600; | |||
| 579 | 581 | )] | |
| 580 | 582 | #[tracing::instrument(skip_all, name = "license_keys::license_verify")] | |
| 581 | 583 | pub(super) async fn license_verify( | |
| 582 | - | State(state): State<AppState>, | |
| 584 | + | State(db): State<PgPool>, | |
| 585 | + | State(config): State<Config>, | |
| 583 | 586 | Json(req): Json<LicenseVerifyRequest>, | |
| 584 | 587 | ) -> Result<impl IntoResponse> { | |
| 585 | 588 | validation::validate_machine_id(&req.machine_fingerprint)?; | |
| 586 | 589 | ||
| 587 | - | let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? { | |
| 590 | + | let key = match db::license_keys::get_license_key_by_code(&db, &req.key).await? { | |
| 588 | 591 | Some(k) => k, | |
| 589 | 592 | None => { | |
| 590 | 593 | return Ok(Json(LicenseVerifyResponse { | |
| @@ -606,10 +609,10 @@ pub(super) async fn license_verify( | |||
| 606 | 609 | } | |
| 607 | 610 | ||
| 608 | 611 | // Check if the project has license verification enabled | |
| 609 | - | let item = db::items::get_item_by_id(&state.db, key.item_id) | |
| 612 | + | let item = db::items::get_item_by_id(&db, key.item_id) | |
| 610 | 613 | .await? | |
| 611 | 614 | .ok_or(AppError::NotFound)?; | |
| 612 | - | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 615 | + | let project = db::projects::get_project_by_id(&db, item.project_id) | |
| 613 | 616 | .await? | |
| 614 | 617 | .ok_or(AppError::NotFound)?; | |
| 615 | 618 | ||
| @@ -624,14 +627,14 @@ pub(super) async fn license_verify( | |||
| 624 | 627 | ||
| 625 | 628 | // Try to activate (re-activation on same machine is always OK) | |
| 626 | 629 | if let Some(activation) = | |
| 627 | - | db::license_keys::get_activation(&state.db, key.id, &req.machine_fingerprint).await? | |
| 630 | + | db::license_keys::get_activation(&db, key.id, &req.machine_fingerprint).await? | |
| 628 | 631 | { | |
| 629 | 632 | if activation.is_active { | |
| 630 | - | db::license_keys::touch_activation(&state.db, activation.id).await?; | |
| 633 | + | db::license_keys::touch_activation(&db, activation.id).await?; | |
| 631 | 634 | } else { | |
| 632 | 635 | // Re-activate a previously deactivated machine | |
| 633 | 636 | let reactivated = db::license_keys::try_create_activation( | |
| 634 | - | &state.db, | |
| 637 | + | &db, | |
| 635 | 638 | key.id, | |
| 636 | 639 | &req.machine_fingerprint, | |
| 637 | 640 | None, | |
| @@ -649,7 +652,7 @@ pub(super) async fn license_verify( | |||
| 649 | 652 | } else { | |
| 650 | 653 | // New activation | |
| 651 | 654 | let activation = db::license_keys::try_create_activation( | |
| 652 | - | &state.db, | |
| 655 | + | &db, | |
| 653 | 656 | key.id, | |
| 654 | 657 | &req.machine_fingerprint, | |
| 655 | 658 | None, | |
| @@ -678,7 +681,7 @@ pub(super) async fn license_verify( | |||
| 678 | 681 | let token = encode( | |
| 679 | 682 | &Header::default(), | |
| 680 | 683 | &claims, | |
| 681 | - | &EncodingKey::from_secret(state.config.signing_secret.as_bytes()), | |
| 684 | + | &EncodingKey::from_secret(config.signing_secret.as_bytes()), | |
| 682 | 685 | ) | |
| 683 | 686 | .context("jwt encode")?; | |
| 684 | 687 | ||
| @@ -710,12 +713,12 @@ pub(crate) struct LicenseDeactivateRequest { | |||
| 710 | 713 | )] | |
| 711 | 714 | #[tracing::instrument(skip_all, name = "license_keys::license_deactivate")] | |
| 712 | 715 | pub(super) async fn license_deactivate( | |
| 713 | - | State(state): State<AppState>, | |
| 716 | + | State(db): State<PgPool>, | |
| 714 | 717 | Json(req): Json<LicenseDeactivateRequest>, | |
| 715 | 718 | ) -> Result<impl IntoResponse> { | |
| 716 | 719 | validation::validate_machine_id(&req.machine_fingerprint)?; | |
| 717 | 720 | ||
| 718 | - | let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? { | |
| 721 | + | let key = match db::license_keys::get_license_key_by_code(&db, &req.key).await? { | |
| 719 | 722 | Some(k) => k, | |
| 720 | 723 | None => { | |
| 721 | 724 | return Ok(Json(DeactivateKeyResponse { | |
| @@ -726,7 +729,7 @@ pub(super) async fn license_deactivate( | |||
| 726 | 729 | }; | |
| 727 | 730 | ||
| 728 | 731 | let deactivated = | |
| 729 | - | db::license_keys::deactivate_machine(&state.db, key.id, &req.machine_fingerprint).await?; | |
| 732 | + | db::license_keys::deactivate_machine(&db, key.id, &req.machine_fingerprint).await?; | |
| 730 | 733 | ||
| 731 | 734 | Ok(Json(DeactivateKeyResponse { | |
| 732 | 735 | success: deactivated, | |
| @@ -755,12 +758,12 @@ pub(super) async fn license_deactivate( | |||
| 755 | 758 | )] | |
| 756 | 759 | #[tracing::instrument(skip_all, name = "license_keys::license_text")] | |
| 757 | 760 | pub(super) async fn license_text( | |
| 758 | - | State(state): State<AppState>, | |
| 761 | + | State(db): State<PgPool>, | |
| 759 | 762 | Path(item_id): Path<ItemId>, | |
| 760 | 763 | ) -> Result<Response> { | |
| 761 | 764 | use crate::license_templates::{render_license_text, LicensePreset}; | |
| 762 | 765 | ||
| 763 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 766 | + | let item = db::items::get_item_by_id(&db, item_id) | |
| 764 | 767 | .await? | |
| 765 | 768 | .ok_or(AppError::NotFound)?; | |
| 766 | 769 | ||
| @@ -770,10 +773,10 @@ pub(super) async fn license_text( | |||
| 770 | 773 | .map_err(|_| AppError::NotFound)?; | |
| 771 | 774 | ||
| 772 | 775 | // Get owner display name | |
| 773 | - | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 776 | + | let project = db::projects::get_project_by_id(&db, item.project_id) | |
| 774 | 777 | .await? | |
| 775 | 778 | .ok_or(AppError::NotFound)?; | |
| 776 | - | let user = db::users::get_user_by_id(&state.db, project.user_id) | |
| 779 | + | let user = db::users::get_user_by_id(&db, project.user_id) | |
| 777 | 780 | .await? | |
| 778 | 781 | .ok_or(AppError::NotFound)?; | |
| 779 | 782 |
| @@ -8,6 +8,8 @@ use axum::{ | |||
| 8 | 8 | }; | |
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | ||
| 11 | + | use sqlx::PgPool; | |
| 12 | + | ||
| 11 | 13 | use crate::{ | |
| 12 | 14 | auth::AuthUser, | |
| 13 | 15 | db::{self, CustomLinkId}, | |
| @@ -15,7 +17,6 @@ use crate::{ | |||
| 15 | 17 | helpers::{htmx_toast_response, is_htmx_request}, | |
| 16 | 18 | templates::LinkRowTemplate, | |
| 17 | 19 | validation, | |
| 18 | - | AppState, | |
| 19 | 20 | }; | |
| 20 | 21 | ||
| 21 | 22 | // ============================================================================= | |
| @@ -43,7 +44,7 @@ pub struct LinkResponse { | |||
| 43 | 44 | /// Create a new custom profile link for the authenticated user. | |
| 44 | 45 | #[tracing::instrument(skip_all, name = "links::create_link")] | |
| 45 | 46 | pub(super) async fn create_link( | |
| 46 | - | State(state): State<AppState>, | |
| 47 | + | State(db): State<PgPool>, | |
| 47 | 48 | headers: HeaderMap, | |
| 48 | 49 | AuthUser(user): AuthUser, | |
| 49 | 50 | Form(req): Form<CreateLinkRequest>, | |
| @@ -54,7 +55,7 @@ pub(super) async fn create_link( | |||
| 54 | 55 | validation::validate_link_title(&req.title)?; | |
| 55 | 56 | ||
| 56 | 57 | let link = db::custom_links::create_custom_link( | |
| 57 | - | &state.db, | |
| 58 | + | &db, | |
| 58 | 59 | user.id, | |
| 59 | 60 | &req.url, | |
| 60 | 61 | &req.title, | |
| @@ -90,7 +91,7 @@ pub struct UpdateLinkRequest { | |||
| 90 | 91 | /// Update an existing custom profile link owned by the user. | |
| 91 | 92 | #[tracing::instrument(skip_all, name = "links::update_link")] | |
| 92 | 93 | pub(super) async fn update_link( | |
| 93 | - | State(state): State<AppState>, | |
| 94 | + | State(db): State<PgPool>, | |
| 94 | 95 | AuthUser(user): AuthUser, | |
| 95 | 96 | Path(id): Path<CustomLinkId>, | |
| 96 | 97 | Json(req): Json<UpdateLinkRequest>, | |
| @@ -106,12 +107,12 @@ pub(super) async fn update_link( | |||
| 106 | 107 | } | |
| 107 | 108 | ||
| 108 | 109 | // Verify ownership with efficient single-row check | |
| 109 | - | if !db::custom_links::user_owns_custom_link(&state.db, user.id, id).await? { | |
| 110 | + | if !db::custom_links::user_owns_custom_link(&db, user.id, id).await? { | |
| 110 | 111 | return Err(AppError::NotFound); | |
| 111 | 112 | } | |
| 112 | 113 | ||
| 113 | 114 | let link = db::custom_links::update_custom_link( | |
| 114 | - | &state.db, | |
| 115 | + | &db, | |
| 115 | 116 | id, | |
| 116 | 117 | user.id, | |
| 117 | 118 | req.url.as_deref(), | |
| @@ -132,18 +133,18 @@ pub(super) async fn update_link( | |||
| 132 | 133 | /// Delete a custom profile link owned by the user. | |
| 133 | 134 | #[tracing::instrument(skip_all, name = "links::delete_link")] | |
| 134 | 135 | pub(super) async fn delete_link( | |
| 135 | - | State(state): State<AppState>, | |
| 136 | + | State(db): State<PgPool>, | |
| 136 | 137 | headers: HeaderMap, | |
| 137 | 138 | AuthUser(user): AuthUser, | |
| 138 | 139 | Path(id): Path<CustomLinkId>, | |
| 139 | 140 | ) -> Result<Response> { | |
| 140 | 141 | user.check_not_suspended()?; | |
| 141 | 142 | // Verify ownership with efficient single-row check | |
| 142 | - | if !db::custom_links::user_owns_custom_link(&state.db, user.id, id).await? { | |
| 143 | + | if !db::custom_links::user_owns_custom_link(&db, user.id, id).await? { | |
| 143 | 144 | return Err(AppError::NotFound); | |
| 144 | 145 | } | |
| 145 | 146 | ||
| 146 | - | db::custom_links::delete_custom_link(&state.db, id, user.id).await?; | |
| 147 | + | db::custom_links::delete_custom_link(&db, id, user.id).await?; | |
| 147 | 148 | ||
| 148 | 149 | if is_htmx_request(&headers) { | |
| 149 | 150 | return Ok(htmx_toast_response("Link removed", "success").into_response()); | |
| @@ -161,11 +162,11 @@ pub struct ReorderLinksRequest { | |||
| 161 | 162 | /// Reorder the authenticated user's custom profile links. | |
| 162 | 163 | #[tracing::instrument(skip_all, name = "links::reorder_links")] | |
| 163 | 164 | pub(super) async fn reorder_links( | |
| 164 | - | State(state): State<AppState>, | |
| 165 | + | State(db): State<PgPool>, | |
| 165 | 166 | AuthUser(user): AuthUser, | |
| 166 | 167 | Json(req): Json<ReorderLinksRequest>, | |
| 167 | 168 | ) -> Result<impl IntoResponse> { | |
| 168 | 169 | user.check_not_suspended()?; | |
| 169 | - | db::custom_links::reorder_custom_links(&state.db, user.id, &req.link_ids).await?; | |
| 170 | + | db::custom_links::reorder_custom_links(&db, user.id, &req.link_ids).await?; | |
| 170 | 171 | Ok(StatusCode::NO_CONTENT) | |
| 171 | 172 | } |
| @@ -9,13 +9,14 @@ use serde::Deserialize; | |||
| 9 | 9 | use tower_sessions::Session; | |
| 10 | 10 | use webauthn_rs::prelude::*; | |
| 11 | 11 | ||
| 12 | + | use sqlx::PgPool; | |
| 13 | + | ||
| 12 | 14 | use crate::{ | |
| 13 | 15 | auth::{verify_password_async, AuthUser}, | |
| 14 | 16 | db::{self, PasskeyId}, | |
| 15 | 17 | error::{AppError, Result, ResultExt}, | |
| 16 | 18 | helpers::hx_toast, | |
| 17 | 19 | templates::{PasskeyListTemplate, PasskeyDisplay}, | |
| 18 | - | AppState, | |
| 19 | 20 | }; | |
| 20 | 21 | ||
| 21 | 22 | /// Session key for in-flight passkey registration challenge state. | |
| @@ -34,7 +35,8 @@ pub struct RegisterStartForm { | |||
| 34 | 35 | /// Requires password confirmation to prevent session-theft → persistent backdoor. | |
| 35 | 36 | #[tracing::instrument(skip_all, name = "passkeys::register_start")] | |
| 36 | 37 | pub(super) async fn register_start( | |
| 37 | - | State(state): State<AppState>, | |
| 38 | + | State(db): State<PgPool>, | |
| 39 | + | State(webauthn): State<std::sync::Arc<Webauthn>>, | |
| 38 | 40 | AuthUser(user): AuthUser, | |
| 39 | 41 | session: Session, | |
| 40 | 42 | Form(form): Form<RegisterStartForm>, | |
| @@ -42,14 +44,14 @@ pub(super) async fn register_start( | |||
| 42 | 44 | user.check_not_sandbox()?; | |
| 43 | 45 | ||
| 44 | 46 | // Require password confirmation (matches delete flow) | |
| 45 | - | let db_user = db::users::get_user_by_id(&state.db, user.id) | |
| 47 | + | let db_user = db::users::get_user_by_id(&db, user.id) | |
| 46 | 48 | .await? | |
| 47 | 49 | .ok_or(AppError::Unauthorized)?; | |
| 48 | 50 | if !verify_password_async(form.password.clone(), db_user.password_hash.clone()).await? { | |
| 49 | 51 | return Err(AppError::BadRequest("Incorrect password".to_string())); | |
| 50 | 52 | } | |
| 51 | 53 | // Enforce registration cap | |
| 52 | - | let count = db::passkeys::count_passkeys(&state.db, user.id).await?; | |
| 54 | + | let count = db::passkeys::count_passkeys(&db, user.id).await?; | |
| 53 | 55 | if count >= MAX_PASSKEYS_PER_USER { | |
| 54 | 56 | return Err(AppError::BadRequest(format!( | |
| 55 | 57 | "Maximum of {} passkeys reached", | |
| @@ -58,7 +60,7 @@ pub(super) async fn register_start( | |||
| 58 | 60 | } | |
| 59 | 61 | ||
| 60 | 62 | // Load existing credentials to exclude (prevents re-registration of the same authenticator) | |
| 61 | - | let existing_json = db::passkeys::get_passkey_credentials(&state.db, user.id).await?; | |
| 63 | + | let existing_json = db::passkeys::get_passkey_credentials(&db, user.id).await?; | |
| 62 | 64 | let exclude_creds: Vec<CredentialID> = existing_json | |
| 63 | 65 | .iter() | |
| 64 | 66 | .filter_map(|j| serde_json::from_value::<Passkey>(j.clone()).ok()) | |
| @@ -71,8 +73,7 @@ pub(super) async fn register_start( | |||
| 71 | 73 | Some(exclude_creds) | |
| 72 | 74 | }; | |
| 73 | 75 | ||
| 74 | - | let (ccr, reg_state) = state | |
| 75 | - | .webauthn | |
| 76 | + | let (ccr, reg_state) = webauthn | |
| 76 | 77 | .start_passkey_registration( | |
| 77 | 78 | *user.id.as_uuid(), | |
| 78 | 79 | user.username.as_ref(), | |
| @@ -93,7 +94,8 @@ pub(super) async fn register_start( | |||
| 93 | 94 | /// Finish passkey registration: verify attestation, store credential. | |
| 94 | 95 | #[tracing::instrument(skip_all, name = "passkeys::register_finish")] | |
| 95 | 96 | pub(super) async fn register_finish( | |
| 96 | - | State(state): State<AppState>, | |
| 97 | + | State(db): State<PgPool>, | |
| 98 | + | State(webauthn): State<std::sync::Arc<Webauthn>>, | |
| 97 | 99 | AuthUser(user): AuthUser, | |
| 98 | 100 | session: Session, | |
| 99 | 101 | Json(reg): Json<RegisterPublicKeyCredential>, | |
| @@ -110,8 +112,7 @@ pub(super) async fn register_finish( | |||
| 110 | 112 | .await | |
| 111 | 113 | .ok(); | |
| 112 | 114 | ||
| 113 | - | let passkey = state | |
| 114 | - | .webauthn | |
| 115 | + | let passkey = webauthn | |
| 115 | 116 | .finish_passkey_registration(®, ®_state) | |
| 116 | 117 | .map_err(|e| AppError::BadRequest(format!("Registration failed: {}", e)))?; | |
| 117 | 118 | ||
| @@ -119,7 +120,7 @@ pub(super) async fn register_finish( | |||
| 119 | 120 | .context("serialize passkey")?; | |
| 120 | 121 | let credential_id = passkey.cred_id().to_vec(); | |
| 121 | 122 | ||
| 122 | - | db::passkeys::create_passkey(&state.db, user.id, "Passkey", &credential_json, &credential_id) | |
| 123 | + | db::passkeys::create_passkey(&db, user.id, "Passkey", &credential_json, &credential_id) | |
| 123 | 124 | .await | |
| 124 | 125 | .map_err(|e| crate::helpers::map_unique_violation(e, "This passkey is already registered"))?; | |
| 125 | 126 | ||
| @@ -127,7 +128,7 @@ pub(super) async fn register_finish( | |||
| 127 | 128 | ||
| 128 | 129 | Ok(( | |
| 129 | 130 | [("HX-Trigger", hx_toast("Passkey registered", "success"))], | |
| 130 | - | list_inner(&state, user.id).await?, | |
| 131 | + | list_inner(&db, user.id).await?, | |
| 131 | 132 | ) | |
| 132 | 133 | .into_response()) | |
| 133 | 134 | } | |
| @@ -135,15 +136,15 @@ pub(super) async fn register_finish( | |||
| 135 | 136 | /// List passkeys as HTMX partial. | |
| 136 | 137 | #[tracing::instrument(skip_all, name = "passkeys::list")] | |
| 137 | 138 | pub(super) async fn list( | |
| 138 | - | State(state): State<AppState>, | |
| 139 | + | State(db): State<PgPool>, | |
| 139 | 140 | AuthUser(user): AuthUser, | |
| 140 | 141 | ) -> Result<Response> { | |
| 141 | - | Ok(list_inner(&state, user.id).await?.into_response()) | |
| 142 | + | Ok(list_inner(&db, user.id).await?.into_response()) | |
| 142 | 143 | } | |
| 143 | 144 | ||
| 144 | 145 | /// Inner helper to build the passkey list template. | |
| 145 | - | async fn list_inner(state: &AppState, user_id: db::UserId) -> Result<PasskeyListTemplate> { | |
| 146 | - | let passkeys = db::passkeys::list_passkeys(&state.db, user_id).await?; | |
| 146 | + | async fn list_inner(db: &PgPool, user_id: db::UserId) -> Result<PasskeyListTemplate> { | |
| 147 | + | let passkeys = db::passkeys::list_passkeys(db, user_id).await?; | |
| 147 | 148 | let passkeys = passkeys | |
| 148 | 149 | .into_iter() | |
| 149 | 150 | .map(|p| PasskeyDisplay { | |
| @@ -165,7 +166,7 @@ pub struct RenameForm { | |||
| 165 | 166 | ||
| 166 | 167 | #[tracing::instrument(skip_all, name = "passkeys::rename")] | |
| 167 | 168 | pub(super) async fn rename( | |
| 168 | - | State(state): State<AppState>, | |
| 169 | + | State(db): State<PgPool>, | |
| 169 | 170 | AuthUser(user): AuthUser, | |
| 170 | 171 | Path(id): Path<PasskeyId>, | |
| 171 | 172 | Form(form): Form<RenameForm>, | |
| @@ -175,13 +176,13 @@ pub(super) async fn rename( | |||
| 175 | 176 | return Err(AppError::validation("Name must be 1-100 characters".to_string())); | |
| 176 | 177 | } | |
| 177 | 178 | ||
| 178 | - | if !db::passkeys::rename_passkey(&state.db, id, user.id, name).await? { | |
| 179 | + | if !db::passkeys::rename_passkey(&db, id, user.id, name).await? { | |
| 179 | 180 | return Err(AppError::NotFound); | |
| 180 | 181 | } | |
| 181 | 182 | ||
| 182 | 183 | Ok(( | |
| 183 | 184 | [("HX-Trigger", hx_toast("Passkey renamed", "success"))], | |
| 184 | - | list_inner(&state, user.id).await?, | |
| 185 | + | list_inner(&db, user.id).await?, | |
| 185 | 186 | ) | |
| 186 | 187 | .into_response()) | |
| 187 | 188 | } | |
| @@ -194,12 +195,12 @@ pub struct DeleteForm { | |||
| 194 | 195 | ||
| 195 | 196 | #[tracing::instrument(skip_all, name = "passkeys::delete")] | |
| 196 | 197 | pub(super) async fn delete( | |
| 197 | - | State(state): State<AppState>, | |
| 198 | + | State(db): State<PgPool>, | |
| 198 | 199 | AuthUser(user): AuthUser, | |
| 199 | 200 | Path(id): Path<PasskeyId>, | |
| 200 | 201 | Form(form): Form<DeleteForm>, | |
| 201 | 202 | ) -> Result<Response> { | |
| 202 | - | let db_user = db::users::get_user_by_id(&state.db, user.id) | |
| 203 | + | let db_user = db::users::get_user_by_id(&db, user.id) | |
| 203 | 204 | .await? | |
| 204 | 205 | .ok_or(AppError::Unauthorized)?; | |
| 205 | 206 | ||
| @@ -207,7 +208,7 @@ pub(super) async fn delete( | |||
| 207 | 208 | return Err(AppError::BadRequest("Incorrect password".to_string())); | |
| 208 | 209 | } | |
| 209 | 210 | ||
| 210 | - | if !db::passkeys::delete_passkey(&state.db, id, user.id).await? { | |
| 211 | + | if !db::passkeys::delete_passkey(&db, id, user.id).await? { | |
| 211 | 212 | return Err(AppError::NotFound); | |
| 212 | 213 | } | |
| 213 | 214 | ||
| @@ -215,7 +216,7 @@ pub(super) async fn delete( | |||
| 215 | 216 | ||
| 216 | 217 | Ok(( | |
| 217 | 218 | [("HX-Trigger", hx_toast("Passkey deleted", "success"))], | |
| 218 | - | list_inner(&state, user.id).await?, | |
| 219 | + | list_inner(&db, user.id).await?, | |
| 219 | 220 | ) | |
| 220 | 221 | .into_response()) | |
| 221 | 222 | } |
| @@ -9,6 +9,8 @@ use axum::{ | |||
| 9 | 9 | }; | |
| 10 | 10 | use serde::{Deserialize, Serialize}; | |
| 11 | 11 | ||
| 12 | + | use sqlx::PgPool; | |
| 13 | + | ||
| 12 | 14 | use crate::{ | |
| 13 | 15 | auth::AuthUser, | |
| 14 | 16 | db::{self, ProjectId, ProjectSectionId}, | |
| @@ -16,7 +18,6 @@ use crate::{ | |||
| 16 | 18 | helpers::{htmx_toast_response, is_htmx_request, slugify}, | |
| 17 | 19 | types::ListResponse, | |
| 18 | 20 | validation, | |
| 19 | - | AppState, | |
| 20 | 21 | }; | |
| 21 | 22 | ||
| 22 | 23 | use super::verify_project_ownership; | |
| @@ -68,7 +69,7 @@ impl From<db::DbProjectSection> for SectionResponse { | |||
| 68 | 69 | ||
| 69 | 70 | #[tracing::instrument(skip_all, name = "projects::create_section")] | |
| 70 | 71 | pub(super) async fn create_section( | |
| 71 | - | State(state): State<AppState>, | |
| 72 | + | State(db): State<PgPool>, | |
| 72 | 73 | AuthUser(user): AuthUser, | |
| 73 | 74 | Path(project_id): Path<ProjectId>, | |
| 74 | 75 | Json(req): Json<CreateSectionRequest>, | |
| @@ -78,9 +79,9 @@ pub(super) async fn create_section( | |||
| 78 | 79 | validation::validate_section_title(&title)?; | |
| 79 | 80 | validation::validate_section_body(&req.body)?; | |
| 80 | 81 | ||
| 81 | - | verify_project_ownership(&state.db, project_id, user.id).await?; | |
| 82 | + | verify_project_ownership(&db, project_id, user.id).await?; | |
| 82 | 83 | ||
| 83 | - | let count = db::project_sections::count_by_project(&state.db, project_id).await?; | |
| 84 | + | let count = db::project_sections::count_by_project(&db, project_id).await?; | |
| 84 | 85 | if count >= MAX_SECTIONS_PER_PROJECT { | |
| 85 | 86 | return Err(AppError::validation(format!( | |
| 86 | 87 | "Maximum of {} sections per project", | |
| @@ -95,37 +96,37 @@ pub(super) async fn create_section( | |||
| 95 | 96 | // owns the `-N` suffixing and 23505 retry, with the index as the race-safe | |
| 96 | 97 | // source of truth. | |
| 97 | 98 | let base = slugify(&title).to_string(); | |
| 98 | - | let pool = &state.db; | |
| 99 | + | let pool = &db; | |
| 99 | 100 | let (title_s, body_s) = (title.as_str(), req.body.as_str()); | |
| 100 | 101 | let section = crate::helpers::insert_with_unique_slug(&base, |slug| async move { | |
| 101 | 102 | db::project_sections::create(pool, project_id, title_s, &slug, body_s, sort_order).await | |
| 102 | 103 | }) | |
| 103 | 104 | .await?; | |
| 104 | 105 | ||
| 105 | - | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 106 | + | db::projects::bump_cache_generation(&db, project_id).await?; | |
| 106 | 107 | ||
| 107 | 108 | Ok(Json(SectionResponse::from(section))) | |
| 108 | 109 | } | |
| 109 | 110 | ||
| 110 | 111 | #[tracing::instrument(skip_all, name = "projects::list_sections")] | |
| 111 | 112 | pub(super) async fn list_sections( | |
| 112 | - | State(state): State<AppState>, | |
| 113 | + | State(db): State<PgPool>, | |
| 113 | 114 | Path(project_id): Path<ProjectId>, | |
| 114 | 115 | ) -> Result<impl IntoResponse> { | |
| 115 | - | let project = db::projects::get_project_by_id(&state.db, project_id) | |
| 116 | + | let project = db::projects::get_project_by_id(&db, project_id) | |
| 116 | 117 | .await? | |
| 117 | 118 | .ok_or(AppError::NotFound)?; | |
| 118 | 119 | if !project.is_public { | |
| 119 | 120 | return Err(AppError::NotFound); | |
| 120 | 121 | } | |
| 121 | - | let sections = db::project_sections::list_by_project(&state.db, project_id).await?; | |
| 122 | + | let sections = db::project_sections::list_by_project(&db, project_id).await?; | |
| 122 | 123 | let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect(); | |
| 123 | 124 | Ok(Json(ListResponse { data })) | |
| 124 | 125 | } | |
| 125 | 126 | ||
| 126 | 127 | #[tracing::instrument(skip_all, name = "projects::update_section")] | |
| 127 | 128 | pub(super) async fn update_section( | |
| 128 | - | State(state): State<AppState>, | |
| 129 | + | State(db): State<PgPool>, | |
| 129 | 130 | AuthUser(user): AuthUser, | |
| 130 | 131 | Path(section_id): Path<ProjectSectionId>, | |
| 131 | 132 | Json(req): Json<UpdateSectionRequest>, | |
| @@ -135,45 +136,45 @@ pub(super) async fn update_section( | |||
| 135 | 136 | validation::validate_section_title(&title)?; | |
| 136 | 137 | validation::validate_section_body(&req.body)?; | |
| 137 | 138 | ||
| 138 | - | let section = db::project_sections::get_by_id(&state.db, section_id) | |
| 139 | + | let section = db::project_sections::get_by_id(&db, section_id) | |
| 139 | 140 | .await? | |
| 140 | 141 | .ok_or(AppError::NotFound)?; | |
| 141 | 142 | ||
| 142 | - | verify_project_ownership(&state.db, section.project_id, user.id).await?; | |
| 143 | + | verify_project_ownership(&db, section.project_id, user.id).await?; | |
| 143 | 144 | ||
| 144 | 145 | // Same dedup as create. Re-saving under the section's own slug is not a | |
| 145 | 146 | // conflict (same row); only a collision with a different section triggers | |
| 146 | 147 | // the `-N` suffix retry. | |
| 147 | 148 | let base = slugify(&title).to_string(); | |
| 148 | - | let pool = &state.db; | |
| 149 | + | let pool = &db; | |
| 149 | 150 | let (title_s, body_s) = (title.as_str(), req.body.as_str()); | |
| 150 | 151 | let updated = crate::helpers::insert_with_unique_slug(&base, |slug| async move { | |
| 151 | 152 | db::project_sections::update(pool, section_id, title_s, &slug, body_s).await | |
| 152 | 153 | }) | |
| 153 | 154 | .await?; | |
| 154 | 155 | ||
| 155 | - | db::projects::bump_cache_generation(&state.db, section.project_id).await?; | |
| 156 | + | db::projects::bump_cache_generation(&db, section.project_id).await?; | |
| 156 | 157 | ||
| 157 | 158 | Ok(Json(SectionResponse::from(updated))) | |
| 158 | 159 | } | |
| 159 | 160 | ||
| 160 | 161 | #[tracing::instrument(skip_all, name = "projects::delete_section")] | |
| 161 | 162 | pub(super) async fn delete_section( | |
| 162 | - | State(state): State<AppState>, | |
| 163 | + | State(db): State<PgPool>, | |
| 163 | 164 | headers: HeaderMap, | |
| 164 | 165 | AuthUser(user): AuthUser, | |
| 165 | 166 | Path(section_id): Path<ProjectSectionId>, | |
| 166 | 167 | ) -> Result<Response> { | |
| 167 | 168 | user.check_not_suspended()?; | |
| 168 | 169 | ||
| 169 | - | let section = db::project_sections::get_by_id(&state.db, section_id) | |
| 170 | + | let section = db::project_sections::get_by_id(&db, section_id) | |
| 170 | 171 | .await? | |
| 171 | 172 | .ok_or(AppError::NotFound)?; | |
| 172 | 173 | ||
| 173 | - | verify_project_ownership(&state.db, section.project_id, user.id).await?; | |
| 174 | + | verify_project_ownership(&db, section.project_id, user.id).await?; | |
| 174 | 175 | ||
| 175 | - | db::project_sections::delete(&state.db, section_id).await?; | |
| 176 | - | db::projects::bump_cache_generation(&state.db, section.project_id).await?; | |
| 176 | + | db::project_sections::delete(&db, section_id).await?; | |
| 177 | + | db::projects::bump_cache_generation(&db, section.project_id).await?; | |
| 177 | 178 | ||
| 178 | 179 | if is_htmx_request(&headers) { | |
| 179 | 180 | return Ok(htmx_toast_response("Section deleted", "success").into_response()); | |
| @@ -184,16 +185,16 @@ pub(super) async fn delete_section( | |||
| 184 | 185 | ||
| 185 | 186 | #[tracing::instrument(skip_all, name = "projects::reorder_sections")] | |
| 186 | 187 | pub(super) async fn reorder_sections( | |
| 187 | - | State(state): State<AppState>, | |
| 188 | + | State(db): State<PgPool>, | |
| 188 | 189 | AuthUser(user): AuthUser, | |
| 189 | 190 | Path(project_id): Path<ProjectId>, | |
| 190 | 191 | Json(req): Json<ReorderSectionsRequest>, | |
| 191 | 192 | ) -> Result<impl IntoResponse> { | |
| 192 | 193 | user.check_not_suspended()?; | |
| 193 | - | verify_project_ownership(&state.db, project_id, user.id).await?; | |
| 194 | + | verify_project_ownership(&db, project_id, user.id).await?; | |
| 194 | 195 | ||
| 195 | - | db::project_sections::reorder(&state.db, project_id, &req.section_ids).await?; | |
| 196 | - | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 196 | + | db::project_sections::reorder(&db, project_id, &req.section_ids).await?; | |
| 197 | + | db::projects::bump_cache_generation(&db, project_id).await?; | |
| 197 | 198 | ||
| 198 | 199 | Ok(StatusCode::NO_CONTENT) | |
| 199 | 200 | } |
| @@ -8,6 +8,10 @@ use axum::{ | |||
| 8 | 8 | }; | |
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | ||
| 11 | + | use sqlx::PgPool; | |
| 12 | + | use crate::config::Config; | |
| 13 | + | use crate::{Integrations, AppStorage}; | |
| 14 | + | ||
| 11 | 15 | use crate::{ | |
| 12 | 16 | auth::AuthUser, | |
| 13 | 17 | db::{self, GitRepoId, ProjectId, ProjectType, Slug, UserId, Visibility}, | |
| @@ -15,7 +19,6 @@ use crate::{ | |||
| 15 | 19 | helpers::{htmx_toast_response, is_htmx_request}, | |
| 16 | 20 | types::ListResponse, | |
| 17 | 21 | validation, | |
| 18 | - | AppState, | |
| 19 | 22 | }; | |
| 20 | 23 | ||
| 21 | 24 | use crate::extractors::{ValidatedForm, ValidatedJson}; | |
| @@ -51,7 +54,8 @@ pub struct ProjectResponse { | |||
| 51 | 54 | /// Create a new project for the authenticated creator. | |
| 52 | 55 | #[tracing::instrument(skip_all, name = "projects::create_project")] | |
| 53 | 56 | pub(super) async fn create_project( | |
| 54 | - | State(state): State<AppState>, | |
| 57 | + | State(db): State<PgPool>, | |
| 58 | + | State(integrations): State<Integrations>, | |
| 55 | 59 | headers: HeaderMap, | |
| 56 | 60 | AuthUser(user): AuthUser, | |
| 57 | 61 | ValidatedForm(req): ValidatedForm<CreateProjectRequest>, | |
| @@ -73,7 +77,7 @@ pub(super) async fn create_project( | |||
| 73 | 77 | let category_id = if let Some(ref cat_name) = req.category { | |
| 74 | 78 | let trimmed = cat_name.trim(); | |
| 75 | 79 | if !trimmed.is_empty() { | |
| 76 | - | let cat = db::categories::get_or_create_category(&state.db, trimmed).await?; | |
| 80 | + | let cat = db::categories::get_or_create_category(&db, trimmed).await?; | |
| 77 | 81 | Some(cat.id) | |
| 78 | 82 | } else { | |
| 79 | 83 | None | |
| @@ -89,7 +93,7 @@ pub(super) async fn create_project( | |||
| 89 | 93 | } | |
| 90 | 94 | ||
| 91 | 95 | let project = db::projects::create_project( | |
| 92 | - | &state.db, | |
| 96 | + | &db, | |
| 93 | 97 | user.id, | |
| 94 | 98 | &req.slug, | |
| 95 | 99 | &req.title, | |
| @@ -100,21 +104,21 @@ pub(super) async fn create_project( | |||
| 100 | 104 | ||
| 101 | 105 | // Set category if resolved | |
| 102 | 106 | if let Some(cat_id) = category_id { | |
| 103 | - | db::projects::set_project_category(&state.db, project.id, user.id, Some(cat_id)).await?; | |
| 107 | + | db::projects::set_project_category(&db, project.id, user.id, Some(cat_id)).await?; | |
| 104 | 108 | } | |
| 105 | 109 | ||
| 106 | 110 | // Create default mailing lists (non-blocking) | |
| 107 | - | if let Err(e) = db::mailing_lists::create_default_lists(&state.db, project.id, &req.title).await { | |
| 111 | + | if let Err(e) = db::mailing_lists::create_default_lists(&db, project.id, &req.title).await { | |
| 108 | 112 | tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists"); | |
| 109 | 113 | } | |
| 110 | 114 | ||
| 111 | - | db::users::bump_cache_generation(&state.db, user.id).await?; | |
| 112 | - | db::projects::bump_cache_generation(&state.db, project.id).await?; | |
| 115 | + | db::users::bump_cache_generation(&db, user.id).await?; | |
| 116 | + | db::projects::bump_cache_generation(&db, project.id).await?; | |
| 113 | 117 | ||
| 114 | 118 | // Fire-and-forget: provision a paired MT community | |
| 115 | - | if let Some(ref mt) = state.mt_client { | |
| 119 | + | if let Some(ref mt) = integrations.mt_client { | |
| 116 | 120 | let mt = mt.clone(); | |
| 117 | - | let db = state.db.clone(); | |
| 121 | + | let db = db.clone(); | |
| 118 | 122 | let project_id = project.id; | |
| 119 | 123 | let slug = project.slug.to_string(); | |
| 120 | 124 | let title = project.title.clone(); | |
| @@ -172,10 +176,10 @@ pub(super) async fn create_project( | |||
| 172 | 176 | /// List all projects for the authenticated user. | |
| 173 | 177 | #[tracing::instrument(skip_all, name = "projects::list_projects")] | |
| 174 | 178 | pub(super) async fn list_projects( | |
| 175 | - | State(state): State<AppState>, | |
| 179 | + | State(db): State<PgPool>, | |
| 176 | 180 | AuthUser(user): AuthUser, | |
| 177 | 181 | ) -> Result<impl IntoResponse> { | |
| 178 | - | let projects = db::projects::get_projects_by_user(&state.db, user.id).await?; | |
| 182 | + | let projects = db::projects::get_projects_by_user(&db, user.id).await?; | |
| 179 | 183 | ||
| 180 | 184 | let data: Vec<ProjectResponse> = projects | |
| 181 | 185 | .into_iter() | |
| @@ -212,14 +216,14 @@ pub struct UpdateProjectRequest { | |||
| 212 | 216 | /// Update an existing project owned by the authenticated user. | |
| 213 | 217 | #[tracing::instrument(skip_all, name = "projects::update_project", fields(project_id))] | |
| 214 | 218 | pub(super) async fn update_project( | |
| 215 | - | State(state): State<AppState>, | |
| 219 | + | State(db): State<PgPool>, | |
| 216 | 220 | AuthUser(user): AuthUser, | |
| 217 | 221 | Path(id): Path<ProjectId>, | |
| 218 | 222 | ValidatedJson(req): ValidatedJson<UpdateProjectRequest>, | |
| 219 | 223 | ) -> Result<impl IntoResponse> { | |
| 220 | 224 | tracing::Span::current().record("project_id", tracing::field::display(&id)); | |
| 221 | 225 | user.check_not_suspended()?; | |
| 222 | - | verify_project_ownership(&state.db, id, user.id).await?; | |
| 226 | + | verify_project_ownership(&db, id, user.id).await?; | |
| 223 | 227 | ||
| 224 | 228 | // Validate input (same rules as create_project, but all fields are optional) | |
| 225 | 229 | if let Some(ref title) = req.title { | |
| @@ -233,10 +237,10 @@ pub(super) async fn update_project( | |||
| 233 | 237 | if let Some(ref cat_name) = req.category { | |
| 234 | 238 | let trimmed = cat_name.trim(); | |
| 235 | 239 | if trimmed.is_empty() { | |
| 236 | - | db::projects::set_project_category(&state.db, id, user.id, None).await?; | |
| 240 | + | db::projects::set_project_category(&db, id, user.id, None).await?; | |
| 237 | 241 | } else { | |
| 238 | - | let cat = db::categories::get_or_create_category(&state.db, trimmed).await?; | |
| 239 | - | db::projects::set_project_category(&state.db, id, user.id, Some(cat.id)).await?; | |
| 242 | + | let cat = db::categories::get_or_create_category(&db, trimmed).await?; | |
| 243 | + | db::projects::set_project_category(&db, id, user.id, Some(cat.id)).await?; | |
| 240 | 244 | } | |
| 241 | 245 | } | |
| 242 | 246 | ||
| @@ -249,7 +253,7 @@ pub(super) async fn update_project( | |||
| 249 | 253 | } | |
| 250 | 254 | ||
| 251 | 255 | let updated = db::projects::update_project( | |
| 252 | - | &state.db, | |
| 256 | + | &db, | |
| 253 | 257 | id, | |
| 254 | 258 | user.id, | |
| 255 | 259 | req.title.as_deref(), | |
| @@ -289,7 +293,7 @@ pub(super) async fn update_project( | |||
| 289 | 293 | }; | |
| 290 | 294 | ||
| 291 | 295 | db::projects::update_project_pricing( | |
| 292 | - | &state.db, | |
| 296 | + | &db, | |
| 293 | 297 | id, | |
| 294 | 298 | user.id, | |
| 295 | 299 | kind, | |
| @@ -299,7 +303,7 @@ pub(super) async fn update_project( | |||
| 299 | 303 | .await?; | |
| 300 | 304 | } | |
| 301 | 305 | ||
| 302 | - | db::projects::bump_cache_generation(&state.db, id).await?; | |
| 306 | + | db::projects::bump_cache_generation(&db, id).await?; | |
| 303 | 307 | ||
| 304 | 308 | Ok(Json(ProjectResponse { | |
| 305 | 309 | id: updated.id, | |
| @@ -324,19 +328,19 @@ pub struct UpdateProjectThemeRequest { | |||
| 324 | 328 | /// re-render with the new palette. | |
| 325 | 329 | #[tracing::instrument(skip_all, name = "projects::update_project_theme", fields(project_id))] | |
| 326 | 330 | pub(super) async fn update_project_theme( | |
| 327 | - | State(state): State<AppState>, | |
| 331 | + | State(db): State<PgPool>, | |
| 328 | 332 | AuthUser(user): AuthUser, | |
| 329 | 333 | Path(id): Path<ProjectId>, | |
| 330 | 334 | Form(req): Form<UpdateProjectThemeRequest>, | |
| 331 | 335 | ) -> Result<impl IntoResponse> { | |
| 332 | 336 | tracing::Span::current().record("project_id", tracing::field::display(&id)); | |
| 333 | 337 | user.check_not_suspended()?; | |
| 334 | - | verify_project_ownership(&state.db, id, user.id).await?; | |
| 338 | + | verify_project_ownership(&db, id, user.id).await?; | |
| 335 | 339 | ||
| 336 | 340 | let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref()) | |
| 337 | 341 | .map_err(|t| AppError::validation(format!("Unknown theme: {t}")))?; | |
| 338 | - | db::projects::set_project_theme(&state.db, id, user.id, theme_id.as_deref()).await?; | |
| 339 | - | db::projects::bump_cache_generation(&state.db, id).await?; | |
| 342 | + | db::projects::set_project_theme(&db, id, user.id, theme_id.as_deref()).await?; | |
| 343 | + | db::projects::bump_cache_generation(&db, id).await?; | |
| 340 | 344 | ||
| 341 | 345 | Ok(htmx_toast_response("Theme saved", "success")) | |
| 342 | 346 | } | |
| @@ -347,21 +351,23 @@ pub(super) async fn update_project_theme( | |||
| 347 | 351 | /// cover image) for durable deletion and decrements the user's storage counter. | |
| 348 | 352 | #[tracing::instrument(skip_all, name = "projects::delete_project", fields(project_id))] | |
| 349 | 353 | pub(super) async fn delete_project( | |
| 350 | - | State(state): State<AppState>, | |
| 354 | + | State(db): State<PgPool>, | |
| 355 | + | State(config): State<Config>, | |
| 356 | + | State(storage): State<AppStorage>, | |
| 351 | 357 | AuthUser(user): AuthUser, | |
| 352 | 358 | Path(id): Path<ProjectId>, | |
| 353 | 359 | ) -> Result<impl IntoResponse> { | |
| 354 | 360 | tracing::Span::current().record("project_id", tracing::field::display(&id)); | |
| 355 | 361 | user.check_not_suspended()?; | |
| 356 | - | let project = verify_project_ownership(&state.db, id, user.id).await?; | |
| 362 | + | let project = verify_project_ownership(&db, id, user.id).await?; | |
| 357 | 363 | ||
| 358 | 364 | // Collect all S3 keys from items + versions + galleries before CASCADE | |
| 359 | 365 | // delete destroys them. Gallery rows (item_images / project_images) cascade | |
| 360 | 366 | // away too, so their keys must be swept here or they orphan with no durable | |
| 361 | 367 | // record (Run #18 Storage B2). | |
| 362 | - | let item_keys = db::items::get_project_item_s3_keys(&state.db, id).await?; | |
| 363 | - | let version_keys = db::items::get_project_version_s3_keys(&state.db, id).await?; | |
| 364 | - | let gallery_keys = db::gallery_images::s3_keys_for_project(&state.db, id).await?; | |
| 368 | + | let item_keys = db::items::get_project_item_s3_keys(&db, id).await?; | |
| 369 | + | let version_keys = db::items::get_project_version_s3_keys(&db, id).await?; | |
| 370 | + | let gallery_keys = db::gallery_images::s3_keys_for_project(&db, id).await?; | |
| 365 | 371 | ||
| 366 | 372 | let mut all_keys: Vec<(String, String)> = Vec::new(); | |
| 367 | 373 | // Version downloads are gated media — always the private bucket. | |
| @@ -383,9 +389,9 @@ pub(super) async fn delete_project( | |||
| 383 | 389 | if let Some(ref url) = project.cover_image_url | |
| 384 | 390 | && let Some(key) = crate::storage::extract_s3_key_from_url( | |
| 385 | 391 | url, | |
| 386 | - | state.config.cdn_base_url.as_deref(), | |
| 387 | - | state.storage.s3.as_deref().map(|s| s.bucket()), | |
| 388 | - | state.config.storage.as_ref().map(|c| c.endpoint.as_str()), | |
| 392 | + | config.cdn_base_url.as_deref(), | |
| 393 | + | storage.s3.as_deref().map(|s| s.bucket()), | |
| 394 | + | config.storage.as_ref().map(|c| c.endpoint.as_str()), | |
| 389 | 395 | ) | |
| 390 | 396 | { | |
| 391 | 397 | all_keys.extend(crate::storage::both_bucket_delete(&key)); | |
| @@ -397,18 +403,18 @@ pub(super) async fn delete_project( | |||
| 397 | 403 | // deleting the rows anyway would orphan every object with no record (ultra-fuzz | |
| 398 | 404 | // Run 12 Storage F3 sibling). The reverse case (enqueue succeeds, delete fails) | |
| 399 | 405 | // is safe — the reaper's is_s3_key_live guard skips keys whose rows still exist. | |
| 400 | - | db::pending_s3_deletions::enqueue_deletions(&state.db, &all_keys, "project_delete").await?; | |
| 406 | + | db::pending_s3_deletions::enqueue_deletions(&db, &all_keys, "project_delete").await?; | |
| 401 | 407 | ||
| 402 | 408 | // Decrement storage before deleting rows | |
| 403 | - | let storage_bytes = db::items::get_project_storage_bytes(&state.db, id).await?; | |
| 409 | + | let storage_bytes = db::items::get_project_storage_bytes(&db, id).await?; | |
| 404 | 410 | if storage_bytes > 0 | |
| 405 | - | && let Err(e) = db::creator_tiers::decrement_storage_used(&state.db, user.id, storage_bytes).await | |
| 411 | + | && let Err(e) = db::creator_tiers::decrement_storage_used(&db, user.id, storage_bytes).await | |
| 406 | 412 | { | |
| 407 | 413 | tracing::warn!(error = ?e, bytes = storage_bytes, "failed to decrement storage for project delete"); | |
| 408 | 414 | } | |
| 409 | 415 | ||
| 410 | - | db::projects::delete_project(&state.db, id, user.id).await?; | |
| 411 | - | db::users::bump_cache_generation(&state.db, user.id).await?; | |
| 416 | + | db::projects::delete_project(&db, id, user.id).await?; | |
| 417 | + | db::users::bump_cache_generation(&db, user.id).await?; | |
| 412 | 418 | Ok(htmx_toast_response("Project deleted", "success")) | |
| 413 | 419 | } | |
| 414 | 420 | ||
| @@ -425,20 +431,20 @@ pub struct LinkRepoRequest { | |||
| 425 | 431 | /// Link a git repo to a project. The repo must exist and be owned by the same user. | |
| 426 | 432 | #[tracing::instrument(skip_all, name = "projects::link_repo")] | |
| 427 | 433 | pub(super) async fn link_repo( | |
| 428 | - | State(state): State<AppState>, | |
| 434 | + | State(db): State<PgPool>, | |
| 429 | 435 | AuthUser(user): AuthUser, | |
| 430 | 436 | Path(id): Path<ProjectId>, | |
| 431 | 437 | Json(req): Json<LinkRepoRequest>, | |
| 432 | 438 | ) -> Result<impl IntoResponse> { | |
| 433 | 439 | user.check_not_suspended()?; | |
| 434 | - | verify_project_ownership(&state.db, id, user.id).await?; | |
| 440 | + | verify_project_ownership(&db, id, user.id).await?; | |
| 435 | 441 | ||
| 436 | - | let repo = db::git_repos::get_repo_by_user_and_name(&state.db, user.id, &req.name) | |
| 442 | + | let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &req.name) | |
| 437 | 443 | .await? | |
| 438 | 444 | .ok_or(AppError::validation("Repository not found".to_string()))?; | |
| 439 | 445 | ||
| 440 | - | db::git_repos::link_repo_to_project(&state.db, repo.id, id).await?; | |
| 441 | - | db::projects::bump_cache_generation(&state.db, id).await?; | |
| 446 | + | db::git_repos::link_repo_to_project(&db, repo.id, id).await?; | |
| 447 | + | db::projects::bump_cache_generation(&db, id).await?; | |
| 442 | 448 | ||
| 443 | 449 | Ok(htmx_toast_response("Repository linked", "success")) | |
| 444 | 450 | } | |
| @@ -446,19 +452,19 @@ pub(super) async fn link_repo( | |||
| 446 | 452 | /// Unlink a git repo from a project. The repo must be owned by the same user. | |
| 447 | 453 | #[tracing::instrument(skip_all, name = "projects::unlink_repo")] | |
| 448 | 454 | pub(super) async fn unlink_repo( | |
| 449 | - | State(state): State<AppState>, | |
| 455 | + | State(db): State<PgPool>, | |
| 450 | 456 | AuthUser(user): AuthUser, | |
| 451 | 457 | Path((id, repo_name)): Path<(ProjectId, String)>, | |
| 452 | 458 | ) -> Result<impl IntoResponse> { | |
| 453 | 459 | user.check_not_suspended()?; | |
| 454 | - | verify_project_ownership(&state.db, id, user.id).await?; | |
| 460 | + | verify_project_ownership(&db, id, user.id).await?; | |
| 455 | 461 | ||
| 456 | - | let repo = db::git_repos::get_repo_by_user_and_name(&state.db, user.id, &repo_name) | |
| 462 | + | let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &repo_name) | |
| 457 | 463 | .await? | |
| 458 | 464 | .ok_or(AppError::validation("Repository not found".to_string()))?; | |
| 459 | 465 | ||
| 460 | - | db::git_repos::unlink_repo_from_project(&state.db, repo.id).await?; | |
| 461 | - | db::projects::bump_cache_generation(&state.db, id).await?; | |
| 466 | + | db::git_repos::unlink_repo_from_project(&db, repo.id).await?; | |
| 467 | + | db::projects::bump_cache_generation(&db, id).await?; | |
| 462 | 468 | ||
| 463 | 469 | Ok(htmx_toast_response("Repository unlinked", "success")) | |
| 464 | 470 | } | |
| @@ -485,7 +491,8 @@ pub struct RepoResponse { | |||
| 485 | 491 | /// Create a bare git repo on disk and register it in the DB. | |
| 486 | 492 | #[tracing::instrument(skip_all, name = "projects::create_repo")] | |
| 487 | 493 | pub(super) async fn create_repo( | |
| 488 | - | State(state): State<AppState>, | |
| 494 | + | State(db): State<PgPool>, | |
| 495 | + | State(config): State<Config>, | |
| 489 | 496 | AuthUser(user): AuthUser, | |
| 490 | 497 | Json(req): Json<CreateRepoRequest>, | |
| 491 | 498 | ) -> Result<impl IntoResponse> { | |
| @@ -510,14 +517,13 @@ pub(super) async fn create_repo( | |||
| 510 | 517 | let visibility = req.visibility.unwrap_or(Visibility::Public); | |
| 511 | 518 | ||
| 512 | 519 | // Need git_repos_path configured | |
| 513 | - | let git_root = state | |
| 514 | - | .config | |
| 520 | + | let git_root = config | |
| 515 | 521 | .build.git_repos_path | |
| 516 | 522 | .as_deref() | |
| 517 | 523 | .ok_or_else(|| AppError::validation("Git repositories are not configured on this server".to_string()))?; | |
| 518 | 524 | ||
| 519 | 525 | // Check repo doesn't already exist in DB | |
| 520 | - | if db::git_repos::get_repo_by_user_and_name(&state.db, user.id, name).await?.is_some() { | |
| 526 | + | if db::git_repos::get_repo_by_user_and_name(&db, user.id, name).await?.is_some() { | |
| 521 | 527 | return Err(AppError::validation("A repository with that name already exists".to_string())); | |
| 522 | 528 | } | |
| 523 | 529 | ||
| @@ -537,7 +543,7 @@ pub(super) async fn create_repo( | |||
| 537 | 543 | .context("init bare git repo")?; | |
| 538 | 544 | ||
| 539 | 545 | // Install post-receive hook if build triggers are configured | |
| 540 | - | if let Some(token) = &state.config.build.trigger_token { | |
| 546 | + | if let Some(token) = &config.build.trigger_token { | |
| 541 | 547 | let hooks_dir = repo_dir.join("hooks"); | |
| 542 | 548 | let hook_path = hooks_dir.join("post-receive"); | |
| 543 | 549 | let hook_content = crate::build_runner::post_receive_hook(token, &username, name); | |
| @@ -556,7 +562,7 @@ pub(super) async fn create_repo( | |||
| 556 | 562 | } | |
| 557 | 563 | ||
| 558 | 564 | // Register in DB | |
| 559 | - | let db_repo = db::git_repos::create_repo_with_visibility(&state.db, user.id, name, visibility).await?; | |
| 565 | + | let db_repo = db::git_repos::create_repo_with_visibility(&db, user.id, name, visibility).await?; | |
| 560 | 566 | ||
| 561 | 567 | Ok(Json(RepoResponse { | |
| 562 | 568 | id: db_repo.id, | |
| @@ -574,7 +580,7 @@ pub struct UpdateRepoVisibilityRequest { | |||
| 574 | 580 | /// Update a repo's visibility. The repo must be owned by the authenticated user. | |
| 575 | 581 | #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")] | |
| 576 | 582 | pub(super) async fn update_repo_visibility( | |
| 577 | - | State(state): State<AppState>, | |
| 583 | + | State(db): State<PgPool>, | |
| 578 | 584 | AuthUser(user): AuthUser, | |
| 579 | 585 | Path(repo_id): Path<GitRepoId>, | |
| 580 | 586 | Json(req): Json<UpdateRepoVisibilityRequest>, | |
| @@ -583,7 +589,7 @@ pub(super) async fn update_repo_visibility( | |||
| 583 | 589 | ||
| 584 | 590 | // Indexed lookup by primary key, not fetch-all-then-find over the user's | |
| 585 | 591 | // whole repo set (ultra-fuzz Run 4 Perf). Ownership is still enforced below. | |
| 586 | - | let repo = db::git_repos::get_repo_by_id(&state.db, repo_id) | |
| 592 | + | let repo = db::git_repos::get_repo_by_id(&db, repo_id) | |
| 587 | 593 | .await? | |
| 588 | 594 | .ok_or(AppError::NotFound)?; | |
| 589 | 595 | ||
| @@ -591,7 +597,7 @@ pub(super) async fn update_repo_visibility( | |||
| 591 | 597 | return Err(AppError::Forbidden); | |
| 592 | 598 | } | |
| 593 | 599 | ||
| 594 | - | db::git_repos::update_visibility(&state.db, repo_id, req.visibility).await?; | |
| 600 | + | db::git_repos::update_visibility(&db, repo_id, req.visibility).await?; | |
| 595 | 601 | ||
| 596 | 602 | Ok(htmx_toast_response("Visibility updated", "success")) | |
| 597 | 603 | } | |
| @@ -611,12 +617,12 @@ pub struct AddMemberForm { | |||
| 611 | 617 | /// POST /api/projects/{id}/members - Add a member to a project | |
| 612 | 618 | #[tracing::instrument(skip_all, name = "api::add_project_member")] | |
| 613 | 619 | pub async fn add_project_member( | |
| 614 | - | State(state): State<AppState>, | |
| 620 | + | State(db): State<PgPool>, | |
| 615 | 621 | AuthUser(session_user): AuthUser, | |
| 616 | 622 | Path(project_id): Path<ProjectId>, | |
| 617 | 623 | Form(form): Form<AddMemberForm>, | |
| 618 | 624 | ) -> Result<Response> { | |
| 619 | - | let _project = verify_project_ownership(&state.db, project_id, session_user.id).await?; | |
| 625 | + | let _project = verify_project_ownership(&db, project_id, session_user.id).await?; | |
| 620 | 626 | ||
| 621 | 627 | // Validate split percent | |
| 622 | 628 | if form.split_percent < 1 || form.split_percent > 99 { | |
| @@ -625,7 +631,7 @@ pub async fn add_project_member( | |||
| 625 | 631 | ||
| 626 | 632 | // Look up the member by username (validate untrusted form input) | |
| 627 | 633 | let username = db::Username::new(&form.username)?; | |
| 628 | - | let member_user = db::users::get_user_by_username(&state.db, &username) | |
| 634 | + | let member_user = db::users::get_user_by_username(&db, &username) | |
| 629 | 635 | .await? | |
| 630 | 636 | .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?; | |
| 631 | 637 | ||
| @@ -637,7 +643,7 @@ pub async fn add_project_member( | |||
| 637 | 643 | let role = form.role.unwrap_or(db::ProjectRole::Member); | |
| 638 | 644 | ||
| 639 | 645 | db::project_members::add_project_member( | |
| 640 | - | &state.db, | |
| 646 | + | &db, | |
| 641 | 647 | project_id, | |
| 642 | 648 | member_user.id, | |
| 643 | 649 | role, | |
| @@ -646,7 +652,7 @@ pub async fn add_project_member( | |||
| 646 | 652 | ).await?; | |
| 647 | 653 | ||
| 648 | 654 | // Bump cache generation so the tab refreshes | |
| 649 | - | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 655 | + | db::projects::bump_cache_generation(&db, project_id).await?; | |
| 650 | 656 | ||
| 651 | 657 | Ok(htmx_toast_response( | |
| 652 | 658 | &format!("Added @{} with {}% split", member_user.username, form.split_percent), | |
| @@ -657,19 +663,19 @@ pub async fn add_project_member( | |||
| 657 | 663 | /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member | |
| 658 | 664 | #[tracing::instrument(skip_all, name = "api::remove_project_member")] | |
| 659 | 665 | pub async fn remove_project_member( | |
| 660 | - | State(state): State<AppState>, | |
| 666 | + | State(db): State<PgPool>, | |
| 661 | 667 | AuthUser(session_user): AuthUser, | |
| 662 | 668 | Path((project_id, user_id)): Path<(ProjectId, db::UserId)>, | |
| 663 | 669 | ) -> Result<Response> { | |
| 664 | - | verify_project_ownership(&state.db, project_id, session_user.id).await?; | |
| 670 | + | verify_project_ownership(&db, project_id, session_user.id).await?; | |
| 665 | 671 | ||
| 666 | - | let removed = db::project_members::remove_project_member(&state.db, project_id, user_id).await?; | |
| 672 | + | let removed = db::project_members::remove_project_member(&db, project_id, user_id).await?; | |
| 667 | 673 | ||
| 668 | 674 | if !removed { | |
| 669 | 675 | return Err(AppError::NotFound); | |
| 670 | 676 | } | |
| 671 | 677 | ||
| 672 | - | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 678 | + | db::projects::bump_cache_generation(&db, project_id).await?; | |
| 673 | 679 | ||
| 674 | 680 | Ok(htmx_toast_response("Member removed", "success").into_response()) | |
| 675 | 681 | } | |
| @@ -697,11 +703,11 @@ pub struct CollaboratorResponse { | |||
| 697 | 703 | ||
| 698 | 704 | /// Verify the authenticated user owns this repo and return it. | |
| 699 | 705 | async fn verify_repo_ownership( | |
| 700 | - | state: &AppState, | |
| 706 | + | db: &PgPool, | |
| 701 | 707 | repo_id: GitRepoId, | |
| 702 | 708 | user_id: UserId, | |
| 703 | 709 | ) -> Result<db::DbGitRepo> { | |
| 704 | - | let repo = db::git_repos::get_repo_by_id(&state.db, repo_id) | |
| 710 | + | let repo = db::git_repos::get_repo_by_id(db, repo_id) | |
| 705 | 711 | .await? | |
| 706 | 712 | .ok_or(AppError::NotFound)?; | |
| 707 | 713 | ||
| @@ -715,17 +721,17 @@ async fn verify_repo_ownership( | |||
| 715 | 721 | /// POST /api/repos/{id}/collaborators: add a collaborator by username. | |
| 716 | 722 | #[tracing::instrument(skip_all, name = "api::add_repo_collaborator")] | |
| 717 | 723 | pub async fn add_repo_collaborator( | |
| 718 | - | State(state): State<AppState>, | |
| 724 | + | State(db): State<PgPool>, | |
| 719 | 725 | AuthUser(user): AuthUser, | |
| 720 | 726 | Path(repo_id): Path<GitRepoId>, | |
| 721 | 727 | Form(form): Form<AddCollaboratorForm>, | |
| 722 | 728 | ) -> Result<Response> { | |
| 723 | 729 | user.check_not_suspended()?; | |
| 724 | 730 | ||
| 725 | - | let _repo = verify_repo_ownership(&state, repo_id, user.id).await?; | |
| 731 | + | let _repo = verify_repo_ownership(&db, repo_id, user.id).await?; | |
| 726 | 732 | ||
| 727 | 733 | let username = db::Username::new(&form.username)?; | |
| 728 | - | let collab_user = db::users::get_user_by_username(&state.db, &username) | |
| 734 | + | let collab_user = db::users::get_user_by_username(&db, &username) | |
| 729 | 735 | .await? | |
| 730 | 736 | .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?; | |
| 731 | 737 | ||
| @@ -733,7 +739,7 @@ pub async fn add_repo_collaborator( | |||
| 733 | 739 | return Err(AppError::validation("You are already the repo owner".to_string())); | |
| 734 | 740 | } | |
| 735 | 741 | ||
| 736 | - | db::repo_collaborators::add_collaborator(&state.db, repo_id, collab_user.id, form.can_push) | |
| 742 | + | db::repo_collaborators::add_collaborator(&db, repo_id, collab_user.id, form.can_push) | |
| 737 | 743 | .await | |
| 738 | 744 | .map_err(|e| { | |
| 739 | 745 | if let AppError::Database(ref db_err) = e | |
| @@ -753,15 +759,15 @@ pub async fn add_repo_collaborator( | |||
| 753 | 759 | /// DELETE /api/repos/{repo_id}/collaborators/{user_id}: remove a collaborator. | |
| 754 | 760 | #[tracing::instrument(skip_all, name = "api::remove_repo_collaborator")] | |
| 755 | 761 | pub async fn remove_repo_collaborator( | |
| 756 | - | State(state): State<AppState>, | |
| 762 | + | State(db): State<PgPool>, | |
| 757 | 763 | AuthUser(user): AuthUser, | |
| 758 | 764 | Path((repo_id, collab_user_id)): Path<(GitRepoId, UserId)>, | |
| 759 | 765 | ) -> Result<Response> { | |
| 760 | 766 | user.check_not_suspended()?; | |
| 761 | 767 | ||
| 762 | - | let _repo = verify_repo_ownership(&state, repo_id, user.id).await?; | |
| 768 | + | let _repo = verify_repo_ownership(&db, repo_id, user.id).await?; | |
| 763 | 769 | ||
| 764 | - | let removed = db::repo_collaborators::remove_collaborator(&state.db, repo_id, collab_user_id).await?; | |
| 770 | + | let removed = db::repo_collaborators::remove_collaborator(&db, repo_id, collab_user_id).await?; | |
| 765 | 771 | ||
| 766 | 772 | if !removed { | |
| 767 | 773 | return Err(AppError::NotFound); | |
| @@ -773,13 +779,13 @@ pub async fn remove_repo_collaborator( | |||
| 773 | 779 | /// GET /api/repos/{id}/collaborators: list collaborators (JSON). | |
| 774 | 780 | #[tracing::instrument(skip_all, name = "api::list_repo_collaborators")] | |
| 775 | 781 | pub async fn list_repo_collaborators( | |
| 776 | - | State(state): State<AppState>, | |
| 782 | + | State(db): State<PgPool>, | |
| 777 | 783 | AuthUser(user): AuthUser, | |
| 778 | 784 | Path(repo_id): Path<GitRepoId>, | |
| 779 | 785 | ) -> Result<impl IntoResponse> { | |
| 780 | - | let _repo = verify_repo_ownership(&state, repo_id, user.id).await?; | |
| 786 | + | let _repo = verify_repo_ownership(&db, repo_id, user.id).await?; | |
| 781 | 787 | ||
| 782 | - | let collabs = db::repo_collaborators::list_collaborators(&state.db, repo_id).await?; | |
| 788 | + | let collabs = db::repo_collaborators::list_collaborators(&db, repo_id).await?; | |
| 783 | 789 | ||
| 784 | 790 | let data: Vec<CollaboratorResponse> = collabs | |
| 785 | 791 | .into_iter() |
| @@ -8,6 +8,8 @@ use axum::{ | |||
| 8 | 8 | }; | |
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | ||
| 11 | + | use sqlx::PgPool; | |
| 12 | + | ||
| 11 | 13 | use crate::{ | |
| 12 | 14 | auth::AuthUser, | |
| 13 | 15 | db::{self, CodePurpose, DiscountType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId}, | |
| @@ -15,8 +17,7 @@ use crate::{ | |||
| 15 | 17 | helpers::{self, hx_toast, is_htmx_request}, | |
| 16 | 18 | templates::PromoCodesListTemplate, | |
| 17 | 19 | types::PromoCodeRow, | |
| 18 | - | types::ListResponse, | |
| 19 | - | AppState, | |
| 20 | + | types::ListResponse, | |
| 20 | 21 | }; | |
| 21 | 22 | ||
| 22 | 23 | use super::verify_item_ownership; | |
| @@ -67,7 +68,7 @@ pub struct CreatePromoCodeForm { | |||
| 67 | 68 | /// Create a new promo code (creator dashboard). | |
| 68 | 69 | #[tracing::instrument(skip_all, name = "promo_codes::create_promo_code")] | |
| 69 | 70 | pub(super) async fn create_promo_code( | |
| 70 | - | State(state): State<AppState>, | |
| 71 | + | State(db): State<PgPool>, | |
| 71 | 72 | headers: HeaderMap, | |
| 72 | 73 | AuthUser(user): AuthUser, | |
| 73 | 74 | Form(req): Form<CreatePromoCodeForm>, | |
| @@ -159,7 +160,7 @@ pub(super) async fn create_promo_code( | |||
| 159 | 160 | } else { | |
| 160 | 161 | let item_id: ItemId = id_str.parse() | |
| 161 | 162 | .map_err(|_| AppError::BadRequest("Invalid item ID".to_string()))?; | |
| 162 | - | verify_item_ownership(&state.db, item_id, user.id).await?; | |
| 163 | + | verify_item_ownership(&db, item_id, user.id).await?; | |
| 163 | 164 | Some(item_id) | |
| 164 | 165 | } | |
| 165 | 166 | } else { | |
| @@ -174,7 +175,7 @@ pub(super) async fn create_promo_code( | |||
| 174 | 175 | } else { | |
| 175 | 176 | let pid: ProjectId = id_str.parse() | |
| 176 | 177 | .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?; | |
| 177 | - | let project = db::projects::get_project_by_id(&state.db, pid) | |
| 178 | + | let project = db::projects::get_project_by_id(&db, pid) | |
| 178 | 179 | .await? | |
| 179 | 180 | .ok_or(AppError::NotFound)?; | |
| 180 | 181 | if project.user_id != user.id { | |
| @@ -194,12 +195,12 @@ pub(super) async fn create_promo_code( | |||
| 194 | 195 | } else { | |
| 195 | 196 | let tid: SubscriptionTierId = id_str.parse() | |
| 196 | 197 | .map_err(|_| AppError::BadRequest("Invalid tier ID".to_string()))?; | |
| 197 | - | let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tid) | |
| 198 | + | let tier = db::subscriptions::get_subscription_tier_by_id(&db, tid) | |
| 198 | 199 | .await? | |
| 199 | 200 | .ok_or(AppError::NotFound)?; | |
| 200 | 201 | let tier_project_id = tier.project_id | |
| 201 | 202 | .ok_or(AppError::BadRequest("Tier has no project".to_string()))?; | |
| 202 | - | let tier_project = db::projects::get_project_by_id(&state.db, tier_project_id) | |
| 203 | + | let tier_project = db::projects::get_project_by_id(&db, tier_project_id) | |
| 203 | 204 | .await? | |
| 204 | 205 | .ok_or(AppError::NotFound)?; | |
| 205 | 206 | if tier_project.user_id != user.id { | |
| @@ -258,7 +259,7 @@ pub(super) async fn create_promo_code( | |||
| 258 | 259 | } | |
| 259 | 260 | ||
| 260 | 261 | let promo_code = match db::promo_codes::create_promo_code( | |
| 261 | - | &state.db, | |
| 262 | + | &db, | |
| 262 | 263 | user.id, | |
| 263 | 264 | &code, | |
| 264 | 265 | req.code_purpose, | |
| @@ -284,15 +285,15 @@ pub(super) async fn create_promo_code( | |||
| 284 | 285 | }; | |
| 285 | 286 | ||
| 286 | 287 | if let Some(pid) = project_id { | |
| 287 | - | db::projects::bump_cache_generation(&state.db, pid).await?; | |
| 288 | + | db::projects::bump_cache_generation(&db, pid).await?; | |
| 288 | 289 | } | |
| 289 | 290 | ||
| 290 | 291 | if is_htmx_request(&headers) { | |
| 291 | 292 | // Return project-scoped codes if created from project context, otherwise creator-global | |
| 292 | 293 | let codes = if let Some(pid) = project_id { | |
| 293 | - | db::promo_codes::get_promo_codes_by_project(&state.db, pid).await? | |
| 294 | + | db::promo_codes::get_promo_codes_by_project(&db, pid).await? | |
| 294 | 295 | } else { | |
| 295 | - | db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await? | |
| 296 | + | db::promo_codes::get_promo_codes_by_creator(&db, user.id).await? | |
| 296 | 297 | }; | |
| 297 | 298 | return Ok(( | |
| 298 | 299 | [("HX-Trigger", hx_toast("Promo code created", "success"))], | |
| @@ -319,11 +320,11 @@ pub(super) async fn create_promo_code( | |||
| 319 | 320 | /// List all promo codes for the authenticated creator. | |
| 320 | 321 | #[tracing::instrument(skip_all, name = "promo_codes::list_promo_codes")] | |
| 321 | 322 | pub(super) async fn list_promo_codes( | |
| 322 | - | State(state): State<AppState>, | |
| 323 | + | State(db): State<PgPool>, | |
| 323 | 324 | headers: HeaderMap, | |
| 324 | 325 | AuthUser(user): AuthUser, | |
| 325 | 326 | ) -> Result<Response> { | |
| 326 | - | let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?; | |
| 327 | + | let codes = db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?; | |
| 327 | 328 | ||
| 328 | 329 | if is_htmx_request(&headers) { | |
| 329 | 330 | return Ok(PromoCodesListTemplate { | |
| @@ -355,11 +356,11 @@ pub(super) async fn list_promo_codes( | |||
| 355 | 356 | /// log a TODO if you hit it). | |
| 356 | 357 | #[tracing::instrument(skip_all, name = "promo_codes::list_redemptions")] | |
| 357 | 358 | pub(super) async fn list_redemptions( | |
| 358 | - | State(state): State<AppState>, | |
| 359 | + | State(db): State<PgPool>, | |
| 359 | 360 | AuthUser(user): AuthUser, | |
| 360 | 361 | Path(code_id): Path<PromoCodeId>, | |
| 361 | 362 | ) -> Result<Response> { | |
| 362 | - | let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) | |
| 363 | + | let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id) | |
| 363 | 364 | .await? | |
| 364 | 365 | .ok_or(AppError::NotFound)?; | |
| 365 | 366 | ||
| @@ -367,21 +368,21 @@ pub(super) async fn list_redemptions( | |||
| 367 | 368 | return Err(AppError::Forbidden); | |
| 368 | 369 | } | |
| 369 | 370 | ||
| 370 | - | let rows = db::promo_codes::list_redemptions(&state.db, code_id).await?; | |
| 371 | + | let rows = db::promo_codes::list_redemptions(&db, code_id).await?; | |
| 371 | 372 | Ok(Json(serde_json::json!({ "redemptions": rows })).into_response()) | |
| 372 | 373 | } | |
| 373 | 374 | ||
| 374 | 375 | /// Delete a promo code. | |
| 375 | 376 | #[tracing::instrument(skip_all, name = "promo_codes::delete_promo_code")] | |
| 376 | 377 | pub(super) async fn delete_promo_code( | |
| 377 | - | State(state): State<AppState>, | |
| 378 | + | State(db): State<PgPool>, | |
| 378 | 379 | headers: HeaderMap, | |
| 379 | 380 | AuthUser(user): AuthUser, | |
| 380 | 381 | Path(code_id): Path<PromoCodeId>, | |
| 381 | 382 | ) -> Result<Response> { | |
| 382 | 383 | user.check_not_suspended()?; | |
| 383 | 384 | ||
| 384 | - | let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) | |
| 385 | + | let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id) | |
| 385 | 386 | .await? | |
| 386 | 387 | .ok_or(AppError::NotFound)?; | |
| 387 | 388 | ||
| @@ -390,17 +391,17 @@ pub(super) async fn delete_promo_code( | |||
| 390 | 391 | } | |
| 391 | 392 | ||
| 392 | 393 | let deleted_project_id = promo_code.project_id; | |
| 393 | - | db::promo_codes::delete_promo_code(&state.db, code_id).await?; | |
| 394 | + | db::promo_codes::delete_promo_code(&db, code_id).await?; | |
| 394 | 395 | ||
| 395 | 396 | if let Some(pid) = deleted_project_id { | |
| 396 | - | db::projects::bump_cache_generation(&state.db, pid).await?; | |
| 397 | + | db::projects::bump_cache_generation(&db, pid).await?; | |
| 397 | 398 | } | |
| 398 | 399 | ||
| 399 | 400 | if is_htmx_request(&headers) { | |
| 400 | 401 | let codes = if let Some(pid) = deleted_project_id { | |
| 401 | - | db::promo_codes::get_promo_codes_by_project(&state.db, pid).await? | |
| 402 | + | db::promo_codes::get_promo_codes_by_project(&db, pid).await? | |
| 402 | 403 | } else { | |
| 403 | - | db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await? | |
| 404 | + | db::promo_codes::get_promo_codes_by_creator(&db, user.id).await? | |
| 404 | 405 | }; | |
| 405 | 406 | return Ok(( | |
| 406 | 407 | [("HX-Trigger", hx_toast("Promo code deleted", "success"))], | |
| @@ -425,7 +426,7 @@ pub struct UpdatePromoCodeForm { | |||
| 425 | 426 | /// Update an existing promo code (expires_at, starts_at, max_uses only). | |
| 426 | 427 | #[tracing::instrument(skip_all, name = "promo_codes::update_promo_code")] | |
| 427 | 428 | pub(super) async fn update_promo_code( | |
| 428 | - | State(state): State<AppState>, | |
| 429 | + | State(db): State<PgPool>, | |
| 429 | 430 | headers: HeaderMap, | |
| 430 | 431 | AuthUser(user): AuthUser, | |
| 431 | 432 | Path(code_id): Path<PromoCodeId>, | |
| @@ -433,7 +434,7 @@ pub(super) async fn update_promo_code( | |||
| 433 | 434 | ) -> Result<Response> { | |
| 434 | 435 | user.check_not_suspended()?; | |
| 435 | 436 | ||
| 436 | - | let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) | |
| 437 | + | let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id) | |
| 437 | 438 | .await? | |
| 438 | 439 | .ok_or(AppError::NotFound)?; | |
| 439 | 440 | ||
| @@ -490,17 +491,17 @@ pub(super) async fn update_promo_code( | |||
| 490 | 491 | Ok::<_, AppError>(Some(n)) | |
| 491 | 492 | }).transpose()?; | |
| 492 | 493 | ||
| 493 | - | db::promo_codes::update_promo_code(&state.db, code_id, expires_at, starts_at, max_uses).await?; | |
| 494 | + | db::promo_codes::update_promo_code(&db, code_id, expires_at, starts_at, max_uses).await?; | |
| 494 | 495 | ||
| 495 | 496 | if let Some(pid) = promo_code.project_id { | |
| 496 | - | db::projects::bump_cache_generation(&state.db, pid).await?; | |
| 497 | + | db::projects::bump_cache_generation(&db, pid).await?; | |
| 497 | 498 | } | |
| 498 | 499 | ||
| 499 | 500 | if is_htmx_request(&headers) { | |
| 500 | 501 | let codes = if let Some(pid) = promo_code.project_id { | |
| 501 | - | db::promo_codes::get_promo_codes_by_project(&state.db, pid).await? | |
| 502 | + | db::promo_codes::get_promo_codes_by_project(&db, pid).await? | |
| 502 | 503 | } else { | |
| 503 | - | db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await? | |
| 504 | + | db::promo_codes::get_promo_codes_by_creator(&db, user.id).await? | |
| 504 | 505 | }; | |
| 505 | 506 | return Ok(( | |
| 506 | 507 | [("HX-Trigger", hx_toast("Promo code updated", "success"))], | |
| @@ -517,16 +518,16 @@ pub(super) async fn update_promo_code( | |||
| 517 | 518 | /// Delete all expired promo codes for this creator. | |
| 518 | 519 | #[tracing::instrument(skip_all, name = "promo_codes::delete_expired")] | |
| 519 | 520 | pub(super) async fn delete_expired_promo_codes( | |
| 520 | - | State(state): State<AppState>, | |
| 521 | + | State(db): State<PgPool>, | |
| 521 | 522 | headers: HeaderMap, | |
| 522 | 523 | AuthUser(user): AuthUser, | |
| 523 | 524 | ) -> Result<Response> { | |
| 524 | 525 | user.check_not_suspended()?; | |
| 525 | 526 | ||
| 526 | - | let count = db::promo_codes::delete_expired_by_creator(&state.db, user.id).await?; | |
| 527 | + | let count = db::promo_codes::delete_expired_by_creator(&db, user.id).await?; | |
| 527 | 528 | ||
| 528 | 529 | if is_htmx_request(&headers) { | |
| 529 | - | let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?; | |
| 530 | + | let codes = db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?; | |
| 530 | 531 | return Ok(( | |
| 531 | 532 | [("HX-Trigger", hx_toast(&format!("{count} expired code(s) deleted"), "success"))], | |
| 532 | 533 | PromoCodesListTemplate { | |
| @@ -552,7 +553,7 @@ pub struct ClaimPromoCodeForm { | |||
| 552 | 553 | /// Claim a free_access promo code: validates the code and grants free access to the item. | |
| 553 | 554 | #[tracing::instrument(skip_all, name = "api::claim_promo_code")] | |
| 554 | 555 | pub(super) async fn claim_promo_code( | |
| 555 | - | State(state): State<AppState>, | |
| 556 | + | State(db): State<PgPool>, | |
| 556 | 557 | headers: HeaderMap, | |
| 557 | 558 | AuthUser(user): AuthUser, | |
| 558 | 559 | Form(req): Form<ClaimPromoCodeForm>, | |
| @@ -563,7 +564,7 @@ pub(super) async fn claim_promo_code( | |||
| 563 | 564 | let is_htmx = is_htmx_request(&headers); | |
| 564 | 565 | ||
| 565 | 566 | // Look up the code | |
| 566 | - | let promo_code = db::promo_codes::get_promo_code_by_code(&state.db, &req.code) | |
| 567 | + | let promo_code = db::promo_codes::get_promo_code_by_code(&db, &req.code) | |
| 567 | 568 | .await? | |
| 568 | 569 | .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?; | |
| 569 | 570 | ||
| @@ -596,7 +597,7 @@ pub(super) async fn claim_promo_code( | |||
| 596 | 597 | } | |
| 597 | 598 | ||
| 598 | 599 | // Get the item and its seller info for the transaction record | |
| 599 | - | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 600 | + | let item = db::items::get_item_by_id(&db, item_id) | |
| 600 | 601 | .await? | |
| 601 | 602 | .ok_or(AppError::NotFound)?; | |
| 602 | 603 | ||
| @@ -604,11 +605,11 @@ pub(super) async fn claim_promo_code( | |||
| 604 | 605 | return Err(AppError::NotFound); | |
| 605 | 606 | } | |
| 606 | 607 | ||
| 607 | - | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 608 | + | let project = db::projects::get_project_by_id(&db, item.project_id) | |
| 608 | 609 | .await? | |
| 609 | 610 | .ok_or(AppError::NotFound)?; | |
| 610 | 611 | ||
| 611 | - | let seller = db::users::get_user_by_id(&state.db, project.user_id) | |
| 612 | + | let seller = db::users::get_user_by_id(&db, project.user_id) | |
| 612 | 613 | .await? | |
| 613 | 614 | .ok_or(AppError::NotFound)?; | |
| 614 | 615 | ||
| @@ -625,7 +626,7 @@ pub(super) async fn claim_promo_code( | |||
| 625 | 626 | ||
| 626 | 627 | // Wrap promo code increment, claim, and license key in a single transaction | |
| 627 | 628 | let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code( | |
| 628 | - | &state.db, | |
| 629 | + | &db, | |
| 629 | 630 | promo_code.id, | |
| 630 | 631 | &db::transactions::ClaimParams { | |
| 631 | 632 | buyer_id: user.id, |
| @@ -8,12 +8,13 @@ use axum::{ | |||
| 8 | 8 | use serde::Deserialize; | |
| 9 | 9 | use uuid::Uuid; | |
| 10 | 10 | ||
| 11 | + | use sqlx::PgPool; | |
| 12 | + | ||
| 11 | 13 | use crate::{ | |
| 12 | 14 | auth::AuthUser, | |
| 13 | 15 | db::{self, ReportTargetType, ReportType}, | |
| 14 | 16 | error::{AppError, Result}, | |
| 15 | 17 | templates::AlertTemplate, | |
| 16 | - | AppState, | |
| 17 | 18 | }; | |
| 18 | 19 | ||
| 19 | 20 | /// Form input for submitting a report. | |
| @@ -29,7 +30,7 @@ pub struct ReportForm { | |||
| 29 | 30 | /// Submit a report (logged-in users only). | |
| 30 | 31 | #[tracing::instrument(skip_all, name = "api::submit_report")] | |
| 31 | 32 | pub async fn submit_report( | |
| 32 | - | State(state): State<AppState>, | |
| 33 | + | State(db): State<PgPool>, | |
| 33 | 34 | AuthUser(user): AuthUser, | |
| 34 | 35 | Form(form): Form<ReportForm>, | |
| 35 | 36 | ) -> Result<impl IntoResponse> { | |
| @@ -55,7 +56,7 @@ pub async fn submit_report( | |||
| 55 | 56 | // Prevent self-reporting | |
| 56 | 57 | match target_type { | |
| 57 | 58 | ReportTargetType::Project => { | |
| 58 | - | let project = db::projects::get_project_by_id(&state.db, target_id.into()) | |
| 59 | + | let project = db::projects::get_project_by_id(&db, target_id.into()) | |
| 59 | 60 | .await? | |
| 60 | 61 | .ok_or(AppError::NotFound)?; | |
| 61 | 62 | if project.user_id == user.id { | |
| @@ -63,10 +64,10 @@ pub async fn submit_report( | |||
| 63 | 64 | } | |
| 64 | 65 | } | |
| 65 | 66 | ReportTargetType::Item => { | |
| 66 | - | let item = db::items::get_item_by_id(&state.db, target_id.into()) | |
| 67 | + | let item = db::items::get_item_by_id(&db, target_id.into()) | |
| 67 | 68 | .await? | |
| 68 | 69 | .ok_or(AppError::NotFound)?; | |
| 69 | - | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 70 | + | let project = db::projects::get_project_by_id(&db, item.project_id) | |
| 70 | 71 | .await? | |
| 71 | 72 | .ok_or(AppError::NotFound)?; | |
| 72 | 73 | if project.user_id == user.id { | |
| @@ -79,7 +80,7 @@ pub async fn submit_report( | |||
| 79 | 80 | // insert under a per-reporter advisory lock) so concurrent submits can't | |
| 80 | 81 | // slip past the cap. | |
| 81 | 82 | let created = db::reports::create_report_within_daily_limit( | |
| 82 | - | &state.db, | |
| 83 | + | &db, | |
| 83 | 84 | user.id, | |
| 84 | 85 | target_type, | |
| 85 | 86 | target_id, |
| @@ -11,7 +11,7 @@ use crate::db::{self, SshKeyId}; | |||
| 11 | 11 | use crate::error::{AppError, Result}; | |
| 12 | 12 | use crate::helpers::{hx_toast, is_htmx_request}; | |
| 13 | 13 | use crate::validation; | |
| 14 | - | use crate::AppState; | |
| 14 | + | use sqlx::PgPool; | |
| 15 | 15 | ||
| 16 | 16 | #[derive(Debug, Deserialize)] | |
| 17 | 17 | pub struct AddKeyRequest { | |
| @@ -31,10 +31,10 @@ pub struct SshKeyResponse { | |||
| 31 | 31 | /// GET /api/users/me/ssh-keys/list: HTMX partial for the SSH keys list. | |
| 32 | 32 | #[tracing::instrument(skip_all, name = "ssh_keys::list_keys_html")] | |
| 33 | 33 | pub(super) async fn list_keys_html( | |
| 34 | - | State(state): State<AppState>, | |
| 34 | + | State(db): State<PgPool>, | |
| 35 | 35 | AuthUser(user): AuthUser, | |
| 36 | 36 | ) -> Result<impl IntoResponse> { | |
| 37 | - | let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?; | |
| 37 | + | let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?; | |
| 38 | 38 | let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect(); | |
| 39 | 39 | let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?; | |
| 40 | 40 | Ok(axum::response::Html(html)) | |
| @@ -43,10 +43,10 @@ pub(super) async fn list_keys_html( | |||
| 43 | 43 | /// GET /api/users/me/ssh-keys: list the authenticated user's SSH keys. | |
| 44 | 44 | #[tracing::instrument(skip_all, name = "ssh_keys::list_keys")] | |
| 45 | 45 | pub(super) async fn list_keys( | |
| 46 | - | State(state): State<AppState>, | |
| 46 | + | State(db): State<PgPool>, | |
| 47 | 47 | AuthUser(user): AuthUser, | |
| 48 | 48 | ) -> Result<impl IntoResponse> { | |
| 49 | - | let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?; | |
| 49 | + | let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?; | |
| 50 | 50 | ||
| 51 | 51 | let data: Vec<SshKeyResponse> = keys | |
| 52 | 52 | .into_iter() | |
| @@ -64,7 +64,7 @@ pub(super) async fn list_keys( | |||
| 64 | 64 | /// POST /api/users/me/ssh-keys: add a new SSH key. | |
| 65 | 65 | #[tracing::instrument(skip_all, name = "ssh_keys::add_key")] | |
| 66 | 66 | pub(super) async fn add_key( | |
| 67 | - | State(state): State<AppState>, | |
| 67 | + | State(db): State<PgPool>, | |
| 68 | 68 | headers: HeaderMap, | |
| 69 | 69 | AuthUser(user): AuthUser, | |
| 70 | 70 | Form(req): Form<AddKeyRequest>, | |
| @@ -77,7 +77,7 @@ pub(super) async fn add_key( | |||
| 77 | 77 | validation::validate_ssh_key_label(&req.label)?; | |
| 78 | 78 | ||
| 79 | 79 | // Insert (unique constraint on user_id + fingerprint handles races) | |
| 80 | - | let key = db::ssh_keys::add_key(&state.db, user.id, &normalized_key, &fingerprint, &req.label) | |
| 80 | + | let key = db::ssh_keys::add_key(&db, user.id, &normalized_key, &fingerprint, &req.label) | |
| 81 | 81 | .await | |
| 82 | 82 | .map_err(|e| { | |
| 83 | 83 | // Check for unique constraint violation (duplicate fingerprint) | |
| @@ -105,7 +105,7 @@ pub(super) async fn add_key( | |||
| 105 | 105 | ||
| 106 | 106 | if is_htmx_request(&headers) { | |
| 107 | 107 | // Re-render the SSH keys section via HTMX | |
| 108 | - | let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?; | |
| 108 | + | let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?; | |
| 109 | 109 | let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect(); | |
| 110 | 110 | let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?; | |
| 111 | 111 | return Ok(( | |
| @@ -127,14 +127,14 @@ pub(super) async fn add_key( | |||
| 127 | 127 | /// DELETE /api/users/me/ssh-keys/{id}: remove an SSH key. | |
| 128 | 128 | #[tracing::instrument(skip_all, name = "ssh_keys::delete_key")] | |
| 129 | 129 | pub(super) async fn delete_key( | |
| 130 | - | State(state): State<AppState>, | |
| 130 | + | State(db): State<PgPool>, | |
| 131 | 131 | headers: HeaderMap, | |
| 132 | 132 | AuthUser(user): AuthUser, | |
| 133 | 133 | Path(key_id): Path<SshKeyId>, | |
| 134 | 134 | ) -> Result<Response> { | |
| 135 | 135 | user.check_not_suspended()?; | |
| 136 | 136 | ||
| 137 | - | let deleted = db::ssh_keys::delete_key(&state.db, key_id, user.id).await?; | |
| 137 | + | let deleted = db::ssh_keys::delete_key(&db, key_id, user.id).await?; | |
| 138 | 138 | if !deleted { | |
| 139 | 139 | return Err(AppError::NotFound); | |
| 140 | 140 | } | |
| @@ -143,7 +143,7 @@ pub(super) async fn delete_key( | |||
| 143 | 143 | rebuild_authorized_keys(); | |
| 144 | 144 | ||
| 145 | 145 | if is_htmx_request(&headers) { | |
| 146 | - | let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?; | |
| 146 | + | let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?; | |
| 147 | 147 | let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect(); | |
| 148 | 148 | let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?; | |
| 149 | 149 | return Ok(( |