//! JSON API endpoints for projects, items, links, and tags. //! //! ## Response conventions //! //! - **Create / Update**: return the full resource as JSON. //! - **Delete**: return `204 No Content`. //! - **List**: return `{"data": [...]}` via [`ListResponse`](crate::types::ListResponse). //! - **Action** (no resource to return): `204` or `{"message": "..."}`. //! - **Errors**: `{"error": "..."}` with appropriate HTTP status (via [`json_error_layer`]). //! - **HTMX**: response pattern varies by UX need (toast, redirect, partial, //! save-status); intentional, not inconsistency. //! - **License key public endpoints** (`/api/keys/*`): stable API contract, //! response shapes are frozen. //! //! List endpoints on the creator dashboard (tiers, keys, codes, chapters, etc.) //! are intentionally unpaginated; each is scoped to a single project or user, //! producing bounded result sets (typically <100 items). mod blog; mod cart; mod categories; mod collections; mod content_insertions; mod domains; mod exports; mod follows; mod guest_checkout; mod imports; mod internal; mod items; pub(crate) mod license_keys; mod links; mod passkeys; mod project_sections; mod projects; mod promo_codes; mod reports; pub(crate) mod ssh_keys; mod subscriptions; mod tags; pub(crate) mod totp; mod users; mod validate; mod wishlists; use axum::{ extract::{Request, State}, middleware::Next, response::{IntoResponse, Response}, routing::{get, options}, Json, }; use serde::{Deserialize, Serialize}; use serde_json::json; use tower_governor::GovernorLayer; use crate::{ constants, csrf::{delete_csrf, post_csrf, post_csrf_skip, put_csrf, CsrfRouter}, db::{self, BlogPostId, ItemId, ProjectId, ProjectType, UserId}, error::{ApiErrorMessage, AppError, Result}, AppState, }; const LICENSE_BEARER_SKIP: &str = "license API: bearer license key, no session"; const GUEST_CHECKOUT_SKIP: &str = "guest checkout: pre-auth, no session"; /// Fetch a project and verify the user owns it. Shared by all ownership checks /// that go through a project (items, blog posts, direct project access). pub(super) async fn verify_project_ownership( state: &AppState, project_id: ProjectId, user_id: UserId, ) -> Result { let project = db::projects::get_project_by_id(&state.db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != user_id { return Err(AppError::Forbidden); } Ok(project) } pub(super) async fn verify_item_ownership( state: &AppState, item_id: ItemId, user_id: UserId, ) -> Result<(db::DbItem, db::DbProject)> { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let project = verify_project_ownership(state, item.project_id, user_id).await?; Ok((item, project)) } pub(super) async fn verify_blog_post_ownership( state: &AppState, blog_post_id: BlogPostId, user_id: UserId, ) -> Result { let post = db::blog_posts::get_blog_post_by_id(&state.db, blog_post_id) .await? .ok_or(AppError::NotFound)?; verify_project_ownership(state, post.project_id, user_id).await?; Ok(post) } /// Middleware that converts HTML error responses into JSON on API routes. /// /// When `AppError::into_response()` fires it stashes an [`ApiErrorMessage`] in /// the response extensions. This layer checks for that extension and, if /// present, replaces the HTML body with `{"error": "..."}` while preserving the /// original status code. Page routes never hit this layer, so they keep /// getting the full HTML error template. async fn json_error_layer(req: Request, next: Next) -> Response { let response = next.run(req).await; if let Some(ApiErrorMessage(msg)) = response.extensions().get::().cloned() { let status = response.status(); return (status, Json(json!({"error": msg}))).into_response(); } response } // ── Public project listing (no auth) ── #[derive(Serialize)] struct PublicProject { slug: String, title: String, description: Option, project_type: ProjectType, username: String, item_count: i64, } #[tracing::instrument(skip_all, name = "api::public_projects")] async fn public_projects( State(state): State, ) -> Result { let rows = db::discover::discover_projects(&state.db, None, None, None, false, 50, 0).await?; let data: Vec = rows .into_iter() .map(|r| PublicProject { slug: r.slug.to_string(), title: r.title, description: r.description, project_type: r.project_type, username: r.username.to_string(), item_count: r.item_count, }) .collect(); Ok(Json(json!({ "data": data }))) } // ── Email signup (public, no auth) ── #[derive(Deserialize)] struct EmailSignupForm { email: String, } #[tracing::instrument(skip_all, name = "api::email_signup")] async fn email_signup( State(state): State, Json(form): Json, ) -> Result { let email = db::Email::new(&form.email)?; db::email_signups::insert_email_signup(&state.db, email.as_str(), "landing").await?; Ok(Json(json!({"success": true}))) } /// Register all JSON API routes for projects, items, links, tags, and account management. /// /// Routes are split into three tiers with different rate limits: /// - Write routes (POST/PUT/DELETE): burst 10, 2/sec per IP /// - Export routes: burst 3, 1/sec per IP (stricter, prevents bulk extraction) /// - Read routes (GET): no rate limit (alpha scale) pub fn api_routes() -> CsrfRouter { let write_rate_limit = crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST); let export_rate_limit = crate::helpers::rate_limiter_per_sec(constants::API_EXPORT_RATE_LIMIT_PER_SEC, constants::API_EXPORT_RATE_LIMIT_BURST); // Write routes — rate limited let write_routes = CsrfRouter::new() // User routes .route("/api/users/me", put_csrf(users::update_profile)) .route("/api/users/me/password", put_csrf(users::update_password)) .route("/api/users/me/preferences", put_csrf(users::update_preferences)) .route("/api/users/me/stripe", delete_csrf(users::disconnect_stripe)) .route("/api/users/me/stripe-tax", put_csrf(users::toggle_stripe_tax)) .route("/api/users/me", delete_csrf(users::delete_account)) .route("/api/users/me/deactivate", post_csrf(users::deactivate_account)) .route("/api/users/me/reactivate", post_csrf(users::reactivate_account)) .route("/api/users/me/pause-creator", post_csrf(users::pause_creator)) // Broadcast .route("/api/broadcast", post_csrf(users::broadcast_send)) .route("/api/support/ticket", post_csrf(users::submit_support_ticket)) // Project routes .route("/api/projects", post_csrf(projects::create_project)) .route("/api/projects/{id}", put_csrf(projects::update_project)) .route("/api/projects/{id}", delete_csrf(projects::delete_project)) // Git repo management .route("/api/repos", post_csrf(projects::create_repo)) .route("/api/repos/{id}/visibility", put_csrf(projects::update_repo_visibility)) .route_get("/api/repos/{id}/collaborators", get(projects::list_repo_collaborators)) .route("/api/repos/{id}/collaborators", post_csrf(projects::add_repo_collaborator)) .route("/api/repos/{repo_id}/collaborators/{user_id}", delete_csrf(projects::remove_repo_collaborator)) .route("/api/projects/{id}/repos", post_csrf(projects::link_repo)) .route("/api/projects/{id}/repos/{repo_name}", delete_csrf(projects::unlink_repo)) // Project members .route("/api/projects/{id}/members", post_csrf(projects::add_project_member)) .route("/api/projects/{project_id}/members/{user_id}", delete_csrf(projects::remove_project_member)) // Item routes .route("/api/projects/{id}/items", post_csrf(items::create_item)) .route("/api/items/{id}", put_csrf(items::update_item)) .route("/api/items/{id}", delete_csrf(items::delete_item)) .route("/api/items/{id}/duplicate", post_csrf(items::duplicate_item)) // Bulk item operations .route("/api/items/bulk/publish", post_csrf(items::bulk_publish)) .route("/api/items/bulk/unpublish", post_csrf(items::bulk_unpublish)) .route("/api/items/bulk/delete", post_csrf(items::bulk_delete)) .route("/api/items/bulk/price", post_csrf(items::bulk_price)) .route("/api/items/bulk/tag", post_csrf(items::bulk_tag)) .route("/api/items/{id}/move", put_csrf(items::move_item)) // Bundle management .route("/api/items/{id}/bundle/add", post_csrf(items::bundle_add)) .route("/api/items/{id}/bundle/create-child", post_csrf(items::bundle_create_child)) .route("/api/items/{id}/bundle/{child_id}", delete_csrf(items::bundle_remove)) .route("/api/items/{id}/bundle/{child_id}/listed", put_csrf(items::bundle_toggle_listed)) // Refund .route("/api/items/{id}/refund", post_csrf(items::refund_transaction)) .route("/api/items/{id}/restore", post_csrf(items::restore_item)) // Tag routes (HTMX) .route("/api/items/{id}/tags", post_csrf(items::add_tag)) .route("/api/items/{id}/tags/{tag_id}", delete_csrf(items::remove_tag)) .route("/api/items/{id}/primary-tag", put_csrf(items::set_primary_tag)) // Text content route .route("/api/items/{id}/text", put_csrf(items::update_item_text)) // Version routes .route("/api/items/{id}/versions", post_csrf(items::create_version)) .route("/api/items/{id}/versions/{version_id}", delete_csrf(items::delete_version)) // Custom link routes .route("/api/links", post_csrf(links::create_link)) .route("/api/links/{id}", put_csrf(links::update_link)) .route("/api/links/{id}", delete_csrf(links::delete_link)) .route("/api/links/reorder", put_csrf(links::reorder_links)) // Chapter routes .route("/api/items/{id}/chapters", post_csrf(items::create_chapter)) .route("/api/chapters/{id}", put_csrf(items::update_chapter)) .route("/api/chapters/{id}", delete_csrf(items::delete_chapter)) // Section routes .route("/api/items/{id}/sections", post_csrf(items::create_section)) .route("/api/sections/{id}", put_csrf(items::update_section)) .route("/api/sections/{id}", delete_csrf(items::delete_section)) .route("/api/items/{id}/sections/reorder", put_csrf(items::reorder_sections)) // Project section routes .route("/api/projects/{id}/sections", post_csrf(project_sections::create_section)) .route("/api/project-sections/{id}", put_csrf(project_sections::update_section)) .route("/api/project-sections/{id}", delete_csrf(project_sections::delete_section)) .route("/api/projects/{id}/sections/reorder", put_csrf(project_sections::reorder_sections)) // Library routes .route("/api/library/add/{item_id}", post_csrf(users::add_to_library)) .route("/api/library/remove/{item_id}", delete_csrf(users::remove_from_library)) // Contact sharing revocation .route("/api/contacts/{seller_id}", delete_csrf(users::revoke_contact)) // Waitlist .route("/api/waitlist/apply", post_csrf(users::waitlist_apply)) // Email verification .route("/api/resend-verification", post_csrf(users::resend_verification)) // Account management .route("/api/account/request-deletion", post_csrf(users::request_account_deletion)) // Suspension appeal .route("/api/users/me/appeal", post_csrf(users::submit_appeal)) // Session management .route("/api/users/me/sessions/{id}", delete_csrf(users::revoke_session)) .route("/api/users/me/sessions", delete_csrf(users::revoke_other_sessions)) // Blog routes .route("/api/projects/{id}/blog", post_csrf(blog::create_blog_post)) .route("/api/blog/{id}", put_csrf(blog::update_blog_post)) .route("/api/blog/{id}", delete_csrf(blog::delete_blog_post)) // License key management (creator) .route("/api/items/{id}/license-settings", put_csrf(license_keys::update_license_settings)) .route("/api/items/{id}/keys", post_csrf(license_keys::generate_key)) .route("/api/keys/{id}/revoke", post_csrf(license_keys::revoke_key)) // Promo code management (creator) .route("/api/promo-codes", post_csrf(promo_codes::create_promo_code)) .route_get("/api/promo-codes", get(promo_codes::list_promo_codes)) .route("/api/promo-codes/expired", delete_csrf(promo_codes::delete_expired_promo_codes)) .route("/api/promo-codes/{id}", put_csrf(promo_codes::update_promo_code)) .route("/api/promo-codes/{id}", delete_csrf(promo_codes::delete_promo_code)) .route_get("/api/promo-codes/{id}/redemptions", get(promo_codes::list_redemptions)) // Promo code claim (buyer — free_access codes) .route("/api/promo-codes/claim", post_csrf(promo_codes::claim_promo_code)) // Subscription tier management (creator) .route("/api/projects/{id}/tiers", post_csrf(subscriptions::create_tier)) .route("/api/tiers/{id}", put_csrf(subscriptions::update_tier)) .route("/api/tiers/{id}", delete_csrf(subscriptions::delete_tier)) // Follow system .route("/api/follow/{target_type}/{target_id}", post_csrf(follows::follow_target)) .route("/api/follow/{target_type}/{target_id}", delete_csrf(follows::unfollow_target)) // Category management .route("/api/categories", post_csrf(categories::create_category)) // TOTP 2FA management .route("/api/users/me/totp/setup", post_csrf(totp::setup)) .route("/api/users/me/totp/confirm", post_csrf(totp::confirm)) .route("/api/users/me/totp/disable", post_csrf(totp::disable)) .route("/api/users/me/totp/backup-codes", post_csrf(totp::regenerate_backup_codes)) // Passkey management .route("/api/users/me/passkeys/register/start", post_csrf(passkeys::register_start)) .route("/api/users/me/passkeys/register/finish", post_csrf(passkeys::register_finish)) .route("/api/users/me/passkeys/{id}", put_csrf(passkeys::rename)) .route("/api/users/me/passkeys/{id}", delete_csrf(passkeys::delete)) // Content insertion management .route("/api/users/me/insertions/presign", post_csrf(content_insertions::presign_insertion)) .route("/api/users/me/insertions/confirm", post_csrf(content_insertions::confirm_insertion)) .route("/api/insertions/{id}", put_csrf(content_insertions::rename_insertion)) .route("/api/insertions/{id}", delete_csrf(content_insertions::delete_insertion)) // Content insertion placements .route("/api/items/{id}/insertions", post_csrf(content_insertions::create_placement)) .route("/api/item-insertions/{id}", delete_csrf(content_insertions::delete_placement)) // SSH key management .route_get("/api/users/me/ssh-keys", get(ssh_keys::list_keys)) .route("/api/users/me/ssh-keys", post_csrf(ssh_keys::add_key)) .route("/api/users/me/ssh-keys/{id}", delete_csrf(ssh_keys::delete_key)) // Reports .route("/api/reports", post_csrf(reports::submit_report)) // Collections .route("/api/collections", post_csrf(collections::create_collection)) .route("/api/collections/{id}", put_csrf(collections::update_collection)) .route("/api/collections/{id}", delete_csrf(collections::delete_collection)) .route("/api/collections/{id}/items/{item_id}", post_csrf(collections::add_item)) .route("/api/collections/{id}/items/{item_id}", delete_csrf(collections::remove_item)) .route("/api/collections/{id}/items/reorder", put_csrf(collections::reorder_items)) // Wishlists .route("/api/wishlists/{item_id}", post_csrf(wishlists::toggle_wishlist)) // Cart .route("/api/cart/{item_id}", post_csrf(cart::toggle_cart)) .route("/api/cart/{item_id}", put_csrf(cart::update_cart_amount)) .route("/api/cart/{item_id}", delete_csrf(cart::remove_from_cart)) // Custom domains .route("/api/domains", post_csrf(domains::add_domain)) .route("/api/domains/verify", post_csrf(domains::verify_domain)) .route("/api/domains/{id}", delete_csrf(domains::remove_domain)) // Invite codes .route("/api/invites/create", post_csrf(users::create_invite)) // Email signup (public, landing page notify-me) .route("/api/email-signup", post_csrf_skip("pre-auth landing signup, no session", email_signup)) .route_layer(GovernorLayer { config: write_rate_limit, }); // Export routes — stricter rate limit let export_routes = CsrfRouter::new() .route("/api/export/projects", post_csrf(exports::export_projects)) .route("/api/export/sales", post_csrf(exports::export_sales)) .route("/api/export/purchases", post_csrf(exports::export_purchases)) .route("/api/export/splits", post_csrf(exports::export_splits)) .route("/api/export/followers", post_csrf(exports::export_followers)) .route("/api/export/subscriptions", post_csrf(exports::export_subscriptions)) .route("/api/export/content", post_csrf(exports::export_content)) .route("/api/export/contacts", post_csrf(exports::export_contacts)) .route_layer(GovernorLayer { config: export_rate_limit, }); let key_rate_limit = crate::helpers::rate_limiter_ms(constants::LICENSE_KEY_RATE_LIMIT_MS, constants::LICENSE_KEY_RATE_LIMIT_BURST); let key_routes = CsrfRouter::new() .route("/api/keys/validate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key)) .route("/api/v1/keys/validate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key)) .route("/api/keys/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key)) .route("/api/v1/keys/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key)) .route_get("/api/keys/{key_code}/status", get(license_keys::key_status)) .route_get("/api/v1/keys/{key_code}/status", get(license_keys::key_status)) .route("/api/v1/license/verify", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_verify)) .route("/api/v1/license/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_deactivate)) .route_layer(GovernorLayer { config: key_rate_limit, }); let read_rate_limit = crate::helpers::rate_limiter_ms(constants::API_READ_RATE_LIMIT_MS, constants::API_READ_RATE_LIMIT_BURST); // Read routes let read_routes = CsrfRouter::new() .route_get("/api/public/projects", get(public_projects)) .route_get("/api/v1/public/projects", get(public_projects)) .route_get("/api/projects", get(projects::list_projects)) .route_get("/api/items/{id}/versions", get(items::list_versions)) .route_get("/api/items/{id}/chapters", get(items::list_chapters)) .route_get("/api/items/{id}/sections", get(items::list_sections)) .route_get("/api/items/{id}/keys", get(license_keys::list_keys)) .route_get("/api/projects/{id}/sections", get(project_sections::list_sections)) .route_get("/api/projects/{id}/blog", get(blog::list_blog_posts)) .route_get("/api/blog/{id}", get(blog::get_blog_post)) .route_get("/api/tags/search", get(tags::search_tags)) .route_get("/api/categories/search", get(categories::search_categories)) .route_get("/api/items/{id}/tag-suggestions", get(tags::suggest_tags)) .route_get("/api/projects/{id}/tiers", get(subscriptions::list_tiers)) // TOTP status (HTMX partial for dashboard) .route_get("/api/users/me/totp/status", get(totp::status)) // Passkey list (HTMX partial for dashboard) .route_get("/api/users/me/passkeys", get(passkeys::list)) // SSH key list (HTMX partial for dashboard) .route_get("/api/users/me/ssh-keys/list", get(ssh_keys::list_keys_html)) // Content insertion list (HTMX partials for dashboard) .route_get("/api/users/me/insertions", get(content_insertions::list_insertions)) .route_get("/api/items/{id}/insertions", get(content_insertions::list_placements)) // Collections (read) .route_get("/api/collections/for-item/{item_id}", get(collections::collections_for_item)) // Custom domains (read) .route_get("/api/domains", get(domains::get_domain)) .route_get("/api/domains/caddy-ask", get(domains::caddy_ask)) .route_get("/api/restart-status", get(internal::restart_status)) // Cart (read) .route_get("/api/cart/count", get(cart::cart_count)) // Import system (read) .route_get("/api/users/me/import/{id}", get(imports::get_import_status)) .route_get("/api/users/me/imports", get(imports::list_imports)) // License text (public) .route_get("/api/items/{id}/license.txt", get(license_keys::license_text)) .route_get("/api/v1/items/{id}/license.txt", get(license_keys::license_text)) .route_layer(GovernorLayer { config: read_rate_limit, }); let validate_rate_limit = crate::helpers::rate_limiter_per_sec(constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST); let validate_routes = CsrfRouter::new() .route("/api/validate/project-slug", post_csrf(validate::validate_project_slug)) .route("/api/validate/collection-slug", post_csrf(validate::validate_collection_slug)) .route("/api/validate/blog-slug", post_csrf(validate::validate_blog_slug)) .route_layer(GovernorLayer { config: validate_rate_limit, }); // Import route needs a higher body limit (base64-encoded CSV up to 10 MB // ≈ 14 MB encoded). The global 1 MB RequestBodyLimitLayer would reject it, // so we override with a per-route layer. let import_routes = CsrfRouter::new() .route("/api/users/me/import", post_csrf(imports::start_import)) .layer(axum::extract::DefaultBodyLimit::max(15 * 1024 * 1024)) .route_layer(GovernorLayer { config: crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST), }); // Guest checkout routes — public, no auth, CORS-enabled, stricter rate limit let guest_checkout_rate_limit = crate::helpers::rate_limiter_per_sec( constants::GUEST_CHECKOUT_RATE_LIMIT_PER_SEC, constants::GUEST_CHECKOUT_RATE_LIMIT_BURST, ); let guest_routes = CsrfRouter::new() .route("/api/checkout/guest/{item_id}", post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::create_guest_checkout)) .route_get("/api/checkout/guest/{item_id}", options(guest_checkout::guest_checkout_preflight)) .route("/api/checkout/guest-free/{item_id}", post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::claim_free_guest)) .route("/api/purchases/claim", post_csrf(guest_checkout::claim_purchase)) .route_layer(GovernorLayer { config: guest_checkout_rate_limit, }); // Guest download route — separate, more lenient rate limit let download_routes = CsrfRouter::new() .route_get("/download/{token}", get(guest_checkout::guest_download)); write_routes .merge(export_routes) .merge(key_routes) .merge(validate_routes) .merge(read_routes) .merge(import_routes) .merge(guest_routes) .merge(download_routes) .merge(internal::internal_routes()) .layer(axum::middleware::from_fn(json_error_layer)) }