max / makenotwork
36 files changed,
+896 insertions,
-344 deletions
| @@ -3350,7 +3350,7 @@ dependencies = [ | |||
| 3350 | 3350 | ||
| 3351 | 3351 | [[package]] | |
| 3352 | 3352 | name = "makenotwork" | |
| 3353 | - | version = "0.3.13" | |
| 3353 | + | version = "0.3.14" | |
| 3354 | 3354 | dependencies = [ | |
| 3355 | 3355 | "anyhow", | |
| 3356 | 3356 | "argon2", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.3.14" | |
| 3 | + | version = "0.3.15" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 |
| @@ -0,0 +1 @@ | |||
| 1 | + | ALTER TABLE users ADD COLUMN stripe_tax_enabled BOOLEAN NOT NULL DEFAULT false; |
| @@ -728,6 +728,23 @@ pub async fn update_item_audio_file_size( | |||
| 728 | 728 | Ok(()) | |
| 729 | 729 | } | |
| 730 | 730 | ||
| 731 | + | /// Update the cover image URL for an item (permanent/CDN URL for display). | |
| 732 | + | pub async fn update_item_cover_image_url( | |
| 733 | + | pool: &PgPool, | |
| 734 | + | item_id: ItemId, | |
| 735 | + | url: &str, | |
| 736 | + | ) -> Result<()> { | |
| 737 | + | sqlx::query( | |
| 738 | + | "UPDATE items SET cover_image_url = $2, updated_at = NOW() WHERE id = $1", | |
| 739 | + | ) | |
| 740 | + | .bind(item_id) | |
| 741 | + | .bind(url) | |
| 742 | + | .execute(pool) | |
| 743 | + | .await?; | |
| 744 | + | ||
| 745 | + | Ok(()) | |
| 746 | + | } | |
| 747 | + | ||
| 731 | 748 | /// Update the cover file size on an item. | |
| 732 | 749 | pub async fn update_item_cover_file_size( | |
| 733 | 750 | pool: &PgPool, |
| @@ -83,6 +83,8 @@ pub struct DbUser { | |||
| 83 | 83 | pub stripe_payouts_enabled: bool, | |
| 84 | 84 | /// Whether Stripe charges (payments) are enabled. Checked independently in checkout routes. | |
| 85 | 85 | pub stripe_charges_enabled: bool, | |
| 86 | + | /// Whether the creator has opted in to Stripe Tax (automatic tax calculation at checkout). | |
| 87 | + | pub stripe_tax_enabled: bool, | |
| 86 | 88 | // Email verification | |
| 87 | 89 | /// Whether the user's email address has been verified. | |
| 88 | 90 | pub email_verified: bool, | |
| @@ -1711,6 +1713,7 @@ mod tests { | |||
| 1711 | 1713 | stripe_onboarding_complete: onboarding, | |
| 1712 | 1714 | stripe_payouts_enabled: payouts, | |
| 1713 | 1715 | stripe_charges_enabled: false, | |
| 1716 | + | stripe_tax_enabled: false, | |
| 1714 | 1717 | email_verified: false, | |
| 1715 | 1718 | email_verification_token: None, | |
| 1716 | 1719 | email_verification_sent_at: None, |
| @@ -587,6 +587,24 @@ pub async fn batch_advance_onboarding_step( | |||
| 587 | 587 | Ok(()) | |
| 588 | 588 | } | |
| 589 | 589 | ||
| 590 | + | /// Update a user's Stripe Tax toggle. | |
| 591 | + | pub async fn update_stripe_tax_enabled(pool: &PgPool, user_id: UserId, enabled: bool) -> Result<()> { | |
| 592 | + | sqlx::query( | |
| 593 | + | r#" | |
| 594 | + | UPDATE users | |
| 595 | + | SET stripe_tax_enabled = $2, | |
| 596 | + | updated_at = NOW() | |
| 597 | + | WHERE id = $1 | |
| 598 | + | "#, | |
| 599 | + | ) | |
| 600 | + | .bind(user_id) | |
| 601 | + | .bind(enabled) | |
| 602 | + | .execute(pool) | |
| 603 | + | .await?; | |
| 604 | + | ||
| 605 | + | Ok(()) | |
| 606 | + | } | |
| 607 | + | ||
| 590 | 608 | /// Disconnect a user's Stripe account | |
| 591 | 609 | pub async fn disconnect_user_stripe(pool: &PgPool, user_id: UserId) -> Result<DbUser> { | |
| 592 | 610 | let user = sqlx::query_as::<_, DbUser>( |
| @@ -44,6 +44,7 @@ pub struct CheckoutParams<'a> { | |||
| 44 | 44 | pub success_url: &'a str, | |
| 45 | 45 | pub cancel_url: &'a str, | |
| 46 | 46 | pub promo_code_id: Option<PromoCodeId>, | |
| 47 | + | pub enable_stripe_tax: bool, | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | /// Parameters for creating a subscription Checkout Session. | |
| @@ -57,6 +58,7 @@ pub struct SubscriptionCheckoutParams<'a> { | |||
| 57 | 58 | pub cancel_url: &'a str, | |
| 58 | 59 | pub trial_days: Option<i32>, | |
| 59 | 60 | pub promo_code_id: Option<PromoCodeId>, | |
| 61 | + | pub enable_stripe_tax: bool, | |
| 60 | 62 | } | |
| 61 | 63 | ||
| 62 | 64 | /// Stripe client wrapper for payment operations | |
| @@ -162,6 +164,13 @@ impl StripeClient { | |||
| 162 | 164 | } | |
| 163 | 165 | params.metadata = Some(metadata); | |
| 164 | 166 | ||
| 167 | + | if checkout.enable_stripe_tax { | |
| 168 | + | params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax { | |
| 169 | + | enabled: true, | |
| 170 | + | liability: None, | |
| 171 | + | }); | |
| 172 | + | } | |
| 173 | + | ||
| 165 | 174 | // Direct Charges: the checkout session is created on the connected | |
| 166 | 175 | // account (the creator's Stripe), so all funds go directly to them. | |
| 167 | 176 | // We intentionally omit `application_fee_amount` — this is the core | |
| @@ -336,6 +345,13 @@ impl StripeClient { | |||
| 336 | 345 | } | |
| 337 | 346 | params.metadata = Some(metadata); | |
| 338 | 347 | ||
| 348 | + | if sub.enable_stripe_tax { | |
| 349 | + | params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax { | |
| 350 | + | enabled: true, | |
| 351 | + | liability: None, | |
| 352 | + | }); | |
| 353 | + | } | |
| 354 | + | ||
| 339 | 355 | // Apply free trial period if specified | |
| 340 | 356 | if let Some(days) = sub.trial_days { | |
| 341 | 357 | params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData { |
| @@ -168,6 +168,7 @@ pub fn api_routes() -> Router<AppState> { | |||
| 168 | 168 | .route("/api/users/me/password", put(users::update_password)) | |
| 169 | 169 | .route("/api/users/me/preferences", put(users::update_preferences)) | |
| 170 | 170 | .route("/api/users/me/stripe", delete(users::disconnect_stripe)) | |
| 171 | + | .route("/api/users/me/stripe-tax", put(users::toggle_stripe_tax)) | |
| 171 | 172 | .route("/api/users/me", delete(users::delete_account)) | |
| 172 | 173 | // Broadcast | |
| 173 | 174 | .route("/api/broadcast", post(users::broadcast_send)) |
| @@ -7,6 +7,7 @@ mod library; | |||
| 7 | 7 | mod notifications; | |
| 8 | 8 | mod profile; | |
| 9 | 9 | mod sessions; | |
| 10 | + | mod stripe_tax; | |
| 10 | 11 | mod waitlist; | |
| 11 | 12 | ||
| 12 | 13 | pub(super) use broadcast::broadcast_send; | |
| @@ -19,6 +20,7 @@ pub(super) use profile::{ | |||
| 19 | 20 | submit_appeal, update_password, update_profile, | |
| 20 | 21 | }; | |
| 21 | 22 | pub(super) use sessions::{revoke_other_sessions, revoke_session}; | |
| 23 | + | pub(super) use stripe_tax::toggle_stripe_tax; | |
| 22 | 24 | pub(super) use waitlist::waitlist_apply; | |
| 23 | 25 | ||
| 24 | 26 | use serde::Serialize; |
| @@ -0,0 +1,38 @@ | |||
| 1 | + | //! Stripe Tax toggle for creator accounts. | |
| 2 | + | ||
| 3 | + | use axum::{ | |
| 4 | + | extract::State, | |
| 5 | + | response::{Html, IntoResponse, Response}, | |
| 6 | + | Form, | |
| 7 | + | }; | |
| 8 | + | use serde::Deserialize; | |
| 9 | + | ||
| 10 | + | use crate::{ | |
| 11 | + | auth::AuthUser, | |
| 12 | + | db, | |
| 13 | + | error::Result, | |
| 14 | + | templates::SaveStatusTemplate, | |
| 15 | + | AppState, | |
| 16 | + | }; | |
| 17 | + | ||
| 18 | + | #[derive(Debug, Deserialize)] | |
| 19 | + | pub struct StripeTaxForm { | |
| 20 | + | pub stripe_tax_enabled: Option<String>, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Toggle Stripe Tax for the authenticated creator. | |
| 24 | + | #[tracing::instrument(skip_all, name = "users::toggle_stripe_tax")] | |
| 25 | + | pub(in crate::routes::api) async fn toggle_stripe_tax( | |
| 26 | + | State(state): State<AppState>, | |
| 27 | + | AuthUser(user): AuthUser, | |
| 28 | + | Form(form): Form<StripeTaxForm>, | |
| 29 | + | ) -> Result<Response> { | |
| 30 | + | let enabled = form.stripe_tax_enabled.as_deref() == Some("on"); | |
| 31 | + | ||
| 32 | + | db::users::update_stripe_tax_enabled(&state.db, user.id, enabled).await?; | |
| 33 | + | ||
| 34 | + | Ok(Html(SaveStatusTemplate { | |
| 35 | + | success: true, | |
| 36 | + | message: "Tax setting saved".to_string(), | |
| 37 | + | }.render_string()).into_response()) | |
| 38 | + | } |
| @@ -50,7 +50,6 @@ pub fn dashboard_routes() -> Router<AppState> { | |||
| 50 | 50 | .route("/dashboard/tabs/analytics", get(tabs::dashboard_tab_analytics)) | |
| 51 | 51 | .route("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit)) | |
| 52 | 52 | .route("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums)) | |
| 53 | - | .route("/dashboard/tabs/collections", get(tabs::dashboard_tab_collections)) | |
| 54 | 53 | .route("/dashboard/transactions", get(tabs::dashboard_transactions)) | |
| 55 | 54 | .route("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview)) | |
| 56 | 55 | .route("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content)) |
| @@ -517,21 +517,4 @@ pub(super) async fn dashboard_tab_synckit( | |||
| 517 | 517 | Ok(UserSyncKitTabTemplate { apps, projects }) | |
| 518 | 518 | } | |
| 519 | 519 | ||
| 520 | - | /// Render the HTMX partial for the dashboard collections tab. | |
| 521 | - | #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_collections")] | |
| 522 | - | pub(super) async fn dashboard_tab_collections( | |
| 523 | - | State(state): State<AppState>, | |
| 524 | - | session: Session, | |
| 525 | - | AuthUser(session_user): AuthUser, | |
| 526 | - | ) -> Result<impl IntoResponse> { | |
| 527 | - | let csrf_token = helpers::get_csrf_token(&session).await; | |
| 528 | - | ||
| 529 | - | let db_collections = db::collections::get_collections_by_user(&state.db, session_user.id).await?; | |
| 530 | - | let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect(); | |
| 531 | - | ||
| 532 | - | Ok(UserCollectionsTabTemplate { | |
| 533 | - | csrf_token, | |
| 534 | - | collections, | |
| 535 | - | }) | |
| 536 | - | } | |
| 537 | 520 |
| @@ -26,6 +26,7 @@ use super::build_step_nav; | |||
| 26 | 26 | pub const ITEM_STEPS: &[&str] = &[ | |
| 27 | 27 | "type", | |
| 28 | 28 | "details", | |
| 29 | + | "appearance", | |
| 29 | 30 | "content", | |
| 30 | 31 | "pricing", | |
| 31 | 32 | "distribution", | |
| @@ -36,6 +37,7 @@ pub const ITEM_STEPS: &[&str] = &[ | |||
| 36 | 37 | const ITEM_LABELS: &[&str] = &[ | |
| 37 | 38 | "Type", | |
| 38 | 39 | "Details", | |
| 40 | + | "Appearance", | |
| 39 | 41 | "Content", | |
| 40 | 42 | "Pricing", | |
| 41 | 43 | "Distribution", | |
| @@ -237,6 +239,7 @@ pub async fn step_save( | |||
| 237 | 239 | match step.as_str() { | |
| 238 | 240 | "type" => save_type(&state, &project, &item, &form).await?, | |
| 239 | 241 | "details" => save_details(&state, &item, &form).await?, | |
| 242 | + | "appearance" => save_appearance(&state, &item, &form).await?, | |
| 240 | 243 | "content" => save_content(&state, &item, &form).await?, | |
| 241 | 244 | "pricing" => save_pricing(&state, &item, &form).await?, | |
| 242 | 245 | "distribution" => save_distribution(&state, &item, &form).await?, | |
| @@ -329,6 +332,21 @@ async fn save_details( | |||
| 329 | 332 | Ok(()) | |
| 330 | 333 | } | |
| 331 | 334 | ||
| 335 | + | async fn save_appearance( | |
| 336 | + | state: &AppState, | |
| 337 | + | item: &db::DbItem, | |
| 338 | + | form: &HashMap<String, String>, | |
| 339 | + | ) -> Result<()> { | |
| 340 | + | // The cover_image_url is set via the JS upload flow (presign → S3 PUT → confirm). | |
| 341 | + | // The hidden field carries the URL so we can store it if the user uploaded one. | |
| 342 | + | if let Some(url) = form.get("cover_image_url") | |
| 343 | + | && !url.is_empty() | |
| 344 | + | { | |
| 345 | + | db::items::update_item_cover_image_url(&state.db, item.id, url).await?; | |
| 346 | + | } | |
| 347 | + | Ok(()) | |
| 348 | + | } | |
| 349 | + | ||
| 332 | 350 | async fn save_content( | |
| 333 | 351 | state: &AppState, | |
| 334 | 352 | item: &db::DbItem, | |
| @@ -602,6 +620,14 @@ async fn render_step( | |||
| 602 | 620 | } | |
| 603 | 621 | .into_response()), | |
| 604 | 622 | ||
| 623 | + | "appearance" => Ok(WizardItemAppearanceTemplate { | |
| 624 | + | nav, | |
| 625 | + | project_slug, | |
| 626 | + | item_id, | |
| 627 | + | cover_image_url: item.cover_image_url.clone(), | |
| 628 | + | } | |
| 629 | + | .into_response()), | |
| 630 | + | ||
| 605 | 631 | "content" => { | |
| 606 | 632 | let content_template = item.item_type.to_string(); | |
| 607 | 633 |
| @@ -501,6 +501,7 @@ pub(super) async fn purchase_page( | |||
| 501 | 501 | pwyw_min_cents: pwyw_min, | |
| 502 | 502 | suggested_price, | |
| 503 | 503 | pwyw_min_dollars, | |
| 504 | + | stripe_tax_enabled: db_user.stripe_tax_enabled, | |
| 504 | 505 | }) | |
| 505 | 506 | } | |
| 506 | 507 |
| @@ -46,26 +46,63 @@ pub(super) async fn index( | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | - | /// Render the authenticated user's library of purchased and claimed items. | |
| 49 | + | /// Render the authenticated user's library shell (tab content loaded via HTMX). | |
| 50 | 50 | #[tracing::instrument(skip_all, name = "landing::library")] | |
| 51 | 51 | pub(super) async fn library( | |
| 52 | - | State(state): State<AppState>, | |
| 53 | 52 | session: Session, | |
| 54 | 53 | AuthUser(user): AuthUser, | |
| 55 | 54 | ) -> Result<impl IntoResponse> { | |
| 56 | - | let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?; | |
| 57 | - | let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?; | |
| 58 | - | let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect(); | |
| 59 | - | let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?; | |
| 60 | 55 | Ok(LibraryTemplate { | |
| 61 | 56 | csrf_token: get_csrf_token(&session).await, | |
| 62 | 57 | session_user: Some(user), | |
| 63 | - | purchases, | |
| 64 | - | subscriptions, | |
| 65 | - | shared_creators, | |
| 66 | 58 | }) | |
| 67 | 59 | } | |
| 68 | 60 | ||
| 61 | + | /// HTMX partial: library purchases tab. | |
| 62 | + | #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")] | |
| 63 | + | pub(super) async fn library_tab_purchases( | |
| 64 | + | State(state): State<AppState>, | |
| 65 | + | AuthUser(user): AuthUser, | |
| 66 | + | ) -> Result<impl IntoResponse> { | |
| 67 | + | let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?; | |
| 68 | + | Ok(LibraryPurchasesTabTemplate { purchases }) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// HTMX partial: library subscriptions tab. | |
| 72 | + | #[tracing::instrument(skip_all, name = "landing::library_tab_subscriptions")] | |
| 73 | + | pub(super) async fn library_tab_subscriptions( | |
| 74 | + | State(state): State<AppState>, | |
| 75 | + | AuthUser(user): AuthUser, | |
| 76 | + | ) -> Result<impl IntoResponse> { | |
| 77 | + | let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?; | |
| 78 | + | let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect(); | |
| 79 | + | Ok(LibrarySubscriptionsTabTemplate { subscriptions }) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// HTMX partial: library collections tab. | |
| 83 | + | #[tracing::instrument(skip_all, name = "landing::library_tab_collections")] | |
| 84 | + | pub(super) async fn library_tab_collections( | |
| 85 | + | State(state): State<AppState>, | |
| 86 | + | AuthUser(user): AuthUser, | |
| 87 | + | ) -> Result<impl IntoResponse> { | |
| 88 | + | let db_collections = db::collections::get_collections_by_user(&state.db, user.id).await?; | |
| 89 | + | let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect(); | |
| 90 | + | Ok(LibraryCollectionsTabTemplate { | |
| 91 | + | collections, | |
| 92 | + | username: user.username.to_string(), | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// HTMX partial: library contacts tab. | |
| 97 | + | #[tracing::instrument(skip_all, name = "landing::library_tab_contacts")] | |
| 98 | + | pub(super) async fn library_tab_contacts( | |
| 99 | + | State(state): State<AppState>, | |
| 100 | + | AuthUser(user): AuthUser, | |
| 101 | + | ) -> Result<impl IntoResponse> { | |
| 102 | + | let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?; | |
| 103 | + | Ok(LibraryContactsTabTemplate { shared_creators }) | |
| 104 | + | } | |
| 105 | + | ||
| 69 | 106 | /// Render the login page. | |
| 70 | 107 | #[tracing::instrument(skip_all, name = "landing::login_page")] | |
| 71 | 108 | pub(super) async fn login_page(session: Session) -> impl IntoResponse { |
| @@ -38,6 +38,10 @@ pub fn public_routes() -> Router<AppState> { | |||
| 38 | 38 | Router::new() | |
| 39 | 39 | .route("/", get(landing::index)) | |
| 40 | 40 | .route("/library", get(landing::library)) | |
| 41 | + | .route("/library/tabs/purchases", get(landing::library_tab_purchases)) | |
| 42 | + | .route("/library/tabs/subscriptions", get(landing::library_tab_subscriptions)) | |
| 43 | + | .route("/library/tabs/collections", get(landing::library_tab_collections)) | |
| 44 | + | .route("/library/tabs/contacts", get(landing::library_tab_contacts)) | |
| 41 | 45 | .route("/health", get(health::health)) | |
| 42 | 46 | .route("/api/health", get(health::health_json)) | |
| 43 | 47 | .route("/login", get(landing::login_page)) |
| @@ -33,6 +33,8 @@ pub fn storage_routes() -> Router<AppState> { | |||
| 33 | 33 | .route("/api/versions/{version_id}/upload/confirm", post(version_confirm_upload)) | |
| 34 | 34 | .route("/api/projects/image/presign", post(project_image_presign)) | |
| 35 | 35 | .route("/api/projects/image/confirm", post(project_image_confirm)) | |
| 36 | + | .route("/api/items/image/presign", post(item_image_presign)) | |
| 37 | + | .route("/api/items/image/confirm", post(item_image_confirm)) | |
| 36 | 38 | .route_layer(GovernorLayer { | |
| 37 | 39 | config: upload_rate_limit, | |
| 38 | 40 | }); | |
| @@ -133,6 +135,21 @@ pub struct ProjectImageConfirmResponse { | |||
| 133 | 135 | pub image_url: String, | |
| 134 | 136 | } | |
| 135 | 137 | ||
| 138 | + | /// JSON input for requesting a presigned item image upload URL. | |
| 139 | + | #[derive(Debug, Deserialize)] | |
| 140 | + | pub struct ItemImagePresignRequest { | |
| 141 | + | pub item_id: ItemId, | |
| 142 | + | pub file_name: String, | |
| 143 | + | pub content_type: String, | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// JSON input for confirming a completed item image upload. | |
| 147 | + | #[derive(Debug, Deserialize)] | |
| 148 | + | pub struct ItemImageConfirmRequest { | |
| 149 | + | pub item_id: ItemId, | |
| 150 | + | pub s3_key: String, | |
| 151 | + | } | |
| 152 | + | ||
| 136 | 153 | // ============================================================================= | |
| 137 | 154 | // Helpers | |
| 138 | 155 | // ============================================================================= | |
| @@ -757,3 +774,131 @@ async fn project_image_confirm( | |||
| 757 | 774 | image_url, | |
| 758 | 775 | })) | |
| 759 | 776 | } | |
| 777 | + | ||
| 778 | + | /// Generate a presigned URL for uploading an item image (logo/cover) | |
| 779 | + | /// | |
| 780 | + | /// POST /api/items/image/presign | |
| 781 | + | /// | |
| 782 | + | /// Requires authentication. User must own the item. | |
| 783 | + | #[tracing::instrument(skip_all, name = "storage::item_image_presign")] | |
| 784 | + | async fn item_image_presign( | |
| 785 | + | State(state): State<AppState>, | |
| 786 | + | AuthUser(user): AuthUser, | |
| 787 | + | Json(req): Json<ItemImagePresignRequest>, | |
| 788 | + | ) -> Result<impl IntoResponse> { | |
| 789 | + | user.check_not_suspended()?; | |
| 790 | + | let s3 = state.require_s3()?; | |
| 791 | + | ||
| 792 | + | let file_type = FileType::Cover; | |
| 793 | + | S3Client::validate_content_type(file_type, &req.content_type)?; | |
| 794 | + | S3Client::validate_extension(file_type, &req.file_name)?; | |
| 795 | + | ||
| 796 | + | // Verify user owns the item | |
| 797 | + | let owner = db::items::get_item_owner(&state.db, req.item_id) | |
| 798 | + | .await? | |
| 799 | + | .ok_or(AppError::NotFound)?; | |
| 800 | + | ||
| 801 | + | if owner != user.id { | |
| 802 | + | return Err(AppError::Forbidden); | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name); | |
| 806 | + | let expires_in = 3600; | |
| 807 | + | let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE)).await?; | |
| 808 | + | ||
| 809 | + | Ok(Json(PresignUploadResponse { | |
| 810 | + | upload_url, | |
| 811 | + | s3_key, | |
| 812 | + | expires_in, | |
| 813 | + | cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()), | |
| 814 | + | })) | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | /// Confirm an item image upload, scan, store URL | |
| 818 | + | /// | |
| 819 | + | /// POST /api/items/image/confirm | |
| 820 | + | /// | |
| 821 | + | /// Requires authentication. User must own the item. | |
| 822 | + | #[tracing::instrument(skip_all, name = "storage::item_image_confirm")] | |
| 823 | + | async fn item_image_confirm( | |
| 824 | + | State(state): State<AppState>, | |
| 825 | + | AuthUser(user): AuthUser, | |
| 826 | + | Json(req): Json<ItemImageConfirmRequest>, | |
| 827 | + | ) -> Result<impl IntoResponse> { | |
| 828 | + | user.check_not_suspended()?; | |
| 829 | + | let s3 = state.require_s3()?; | |
| 830 | + | ||
| 831 | + | // Verify user owns the item | |
| 832 | + | let owner = db::items::get_item_owner(&state.db, req.item_id) | |
| 833 | + | .await? | |
| 834 | + | .ok_or(AppError::NotFound)?; | |
| 835 | + | ||
| 836 | + | if owner != user.id { | |
| 837 | + | return Err(AppError::Forbidden); | |
| 838 | + | } | |
| 839 | + | ||
| 840 | + | // Verify the object exists in S3 | |
| 841 | + | if !s3.object_exists(&req.s3_key).await? { | |
| 842 | + | return Err(AppError::BadRequest( | |
| 843 | + | "Upload not found. Please try uploading again.".to_string(), | |
| 844 | + | )); | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | // Enforce file size limit | |
| 848 | + | let file_size_bytes = s3.object_size(&req.s3_key).await?.unwrap_or(0); | |
| 849 | + | if file_size_bytes as u64 > FileType::Cover.max_size() { | |
| 850 | + | s3.delete_object(&req.s3_key).await.ok(); | |
| 851 | + | return Err(AppError::BadRequest(format!( | |
| 852 | + | "File exceeds maximum size of {} MB", | |
| 853 | + | FileType::Cover.max_size() / (1024 * 1024) | |
| 854 | + | ))); | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | // Enforce tier-based limits | |
| 858 | + | let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Cover, file_size_bytes).await { | |
| 859 | + | Ok(max) => max, | |
| 860 | + | Err(e) => { | |
| 861 | + | s3.delete_object(&req.s3_key).await.ok(); | |
| 862 | + | return Err(e); | |
| 863 | + | } | |
| 864 | + | }; | |
| 865 | + | ||
| 866 | + | // Scan + classify | |
| 867 | + | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id).await?; | |
| 868 | + | db::scanning::update_item_scan_status(&state.db, req.item_id, status).await?; | |
| 869 | + | if let Some(err) = malware_err { | |
| 870 | + | return Err(err); | |
| 871 | + | } | |
| 872 | + | ||
| 873 | + | // Build permanent URL (CDN or presigned) | |
| 874 | + | let image_url = storage::build_project_image_url( | |
| 875 | + | s3.as_ref(), | |
| 876 | + | state.config.cdn_base_url.as_deref(), | |
| 877 | + | &req.s3_key, | |
| 878 | + | ).await?; | |
| 879 | + | ||
| 880 | + | // Store URL, S3 key, and file size | |
| 881 | + | db::items::update_item_cover_image_url(&state.db, req.item_id, &image_url).await?; | |
| 882 | + | db::items::update_item_cover_s3_key(&state.db, req.item_id, &req.s3_key).await?; | |
| 883 | + | db::items::update_item_cover_file_size(&state.db, req.item_id, file_size_bytes).await?; | |
| 884 | + | ||
| 885 | + | // Atomically increment storage | |
| 886 | + | db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?; | |
| 887 | + | ||
| 888 | + | // Bump project cache | |
| 889 | + | if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? { | |
| 890 | + | let _ = db::projects::bump_cache_generation(&state.db, item.project_id).await; | |
| 891 | + | } | |
| 892 | + | ||
| 893 | + | tracing::info!( | |
| 894 | + | "Item image confirmed: item={}, key={}, size={}", | |
| 895 | + | req.item_id, | |
| 896 | + | req.s3_key, | |
| 897 | + | file_size_bytes | |
| 898 | + | ); | |
| 899 | + | ||
| 900 | + | Ok(Json(ProjectImageConfirmResponse { | |
| 901 | + | success: true, | |
| 902 | + | image_url, | |
| 903 | + | })) | |
| 904 | + | } |
| @@ -311,6 +311,7 @@ pub(super) async fn create_checkout( | |||
| 311 | 311 | success_url: &success_url, | |
| 312 | 312 | cancel_url: &cancel_url, | |
| 313 | 313 | promo_code_id, | |
| 314 | + | enable_stripe_tax: seller.stripe_tax_enabled, | |
| 314 | 315 | }; | |
| 315 | 316 | let session = stripe.create_checkout_session(&checkout_params).await?; | |
| 316 | 317 | ||
| @@ -541,6 +542,7 @@ pub(super) async fn create_subscription_checkout( | |||
| 541 | 542 | cancel_url: &cancel_url, | |
| 542 | 543 | trial_days, | |
| 543 | 544 | promo_code_id, | |
| 545 | + | enable_stripe_tax: creator.stripe_tax_enabled, | |
| 544 | 546 | }, | |
| 545 | 547 | ).await?; | |
| 546 | 548 | ||
| @@ -705,6 +707,7 @@ pub(super) async fn create_project_checkout( | |||
| 705 | 707 | success_url: &success_url, | |
| 706 | 708 | cancel_url: &cancel_url, | |
| 707 | 709 | promo_code_id: None, | |
| 710 | + | enable_stripe_tax: seller.stripe_tax_enabled, | |
| 708 | 711 | }; | |
| 709 | 712 | let session = stripe.create_checkout_session(&checkout_params).await?; | |
| 710 | 713 |
| @@ -313,6 +313,15 @@ pub struct WizardItemDetailsTemplate { | |||
| 313 | 313 | } | |
| 314 | 314 | ||
| 315 | 315 | #[derive(Template)] | |
| 316 | + | #[template(path = "wizards/steps/item/appearance.html")] | |
| 317 | + | pub struct WizardItemAppearanceTemplate { | |
| 318 | + | pub nav: Vec<StepNavItem>, | |
| 319 | + | pub project_slug: String, | |
| 320 | + | pub item_id: String, | |
| 321 | + | pub cover_image_url: Option<String>, | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | #[derive(Template)] | |
| 316 | 325 | #[template(path = "wizards/steps/item/content.html")] | |
| 317 | 326 | pub struct WizardItemContentTemplate { | |
| 318 | 327 | pub nav: Vec<StepNavItem>, |
| @@ -142,8 +142,12 @@ impl_into_response!( | |||
| 142 | 142 | // Forums (Multithreaded) | |
| 143 | 143 | UserForumsTabTemplate, | |
| 144 | 144 | // Collections | |
| 145 | - | UserCollectionsTabTemplate, | |
| 146 | 145 | CollectionTemplate, | |
| 146 | + | // Library tabs | |
| 147 | + | LibraryPurchasesTabTemplate, | |
| 148 | + | LibrarySubscriptionsTabTemplate, | |
| 149 | + | LibraryCollectionsTabTemplate, | |
| 150 | + | LibraryContactsTabTemplate, | |
| 147 | 151 | // Follow button | |
| 148 | 152 | FollowButtonTemplate, | |
| 149 | 153 | TagFollowToggleTemplate, | |
| @@ -193,6 +197,7 @@ impl_into_response!( | |||
| 193 | 197 | // Creation wizards — item step partials | |
| 194 | 198 | WizardItemTypeTemplate, | |
| 195 | 199 | WizardItemDetailsTemplate, | |
| 200 | + | WizardItemAppearanceTemplate, | |
| 196 | 201 | WizardItemContentTemplate, | |
| 197 | 202 | WizardItemPricingTemplate, | |
| 198 | 203 | WizardItemDistributionTemplate, |