Skip to main content

max / makenotwork

29.5 KB · 802 lines History Blame Raw
1 //! JSON API endpoints for projects, items, links, and tags.
2 //!
3 //! ## Response conventions
4 //!
5 //! - **Create / Update**: return the full resource as JSON.
6 //! - **Delete**: return `204 No Content`.
7 //! - **List**: return `{"data": [...]}` via [`ListResponse`](crate::types::ListResponse).
8 //! - **Action** (no resource to return): `204` or `{"message": "..."}`.
9 //! - **Errors**: `{"error": "..."}` with appropriate HTTP status (via [`json_error_layer`]).
10 //! - **HTMX**: response pattern varies by UX need (toast, redirect, partial,
11 //! save-status); intentional, not inconsistency.
12 //! - **License key public endpoints** (`/api/keys/*`): stable API contract,
13 //! response shapes are frozen.
14 //!
15 //! List endpoints on the creator dashboard (tiers, keys, codes, chapters, etc.)
16 //! are intentionally unpaginated; each is scoped to a single project or user,
17 //! producing bounded result sets (typically <100 items).
18
19 mod blog;
20 mod cart;
21 mod categories;
22 mod collections;
23 mod content_insertions;
24 mod csp_report;
25 mod domains;
26 mod exports;
27 mod follows;
28 pub(crate) mod git_tokens;
29 mod guest_checkout;
30 mod imports;
31 mod internal;
32 mod items;
33 pub(crate) mod license_keys;
34 mod links;
35 mod passkeys;
36 mod project_sections;
37 mod projects;
38 mod promo_codes;
39 mod reports;
40 pub(crate) mod ssh_keys;
41 mod subscriptions;
42 mod tags;
43 pub(crate) mod totp;
44 mod users;
45 mod validate;
46 mod wishlists;
47
48 use axum::{
49 Json,
50 extract::{Request, State},
51 middleware::Next,
52 response::{IntoResponse, Response},
53 routing::{get, options},
54 };
55 use serde::{Deserialize, Serialize};
56 use serde_json::json;
57 use tower_governor::GovernorLayer;
58
59 use sqlx::PgPool;
60
61 use crate::{
62 AppState, constants,
63 csrf::{CsrfRouter, delete_csrf, post_csrf, post_csrf_skip, put_csrf},
64 db::{self, BlogPostId, ItemId, ProjectId, ProjectType, UserId},
65 error::{ApiErrorMessage, AppError, Result},
66 };
67
68 const LICENSE_BEARER_SKIP: &str = "license API: bearer license key, no session";
69 const GUEST_CHECKOUT_SKIP: &str = "guest checkout: pre-auth, no session";
70 const CSP_REPORT_SKIP: &str = "CSP report: browser-posted, no session";
71
72 /// Fetch a project and verify the user owns it. Shared by all ownership checks
73 /// that go through a project (items, blog posts, direct project access).
74 pub(super) async fn verify_project_ownership(
75 db: &sqlx::PgPool,
76 project_id: ProjectId,
77 user_id: UserId,
78 ) -> Result<db::DbProject> {
79 let project = db::projects::get_project_by_id(db, project_id)
80 .await?
81 .ok_or(AppError::NotFound)?;
82 if project.user_id != user_id {
83 return Err(AppError::Forbidden);
84 }
85 Ok(project)
86 }
87
88 pub(super) async fn verify_item_ownership(
89 db: &sqlx::PgPool,
90 item_id: ItemId,
91 user_id: UserId,
92 ) -> Result<(db::DbItem, db::DbProject)> {
93 let item = db::items::get_item_by_id(db, item_id)
94 .await?
95 .ok_or(AppError::NotFound)?;
96 let project = verify_project_ownership(db, item.project_id, user_id).await?;
97 Ok((item, project))
98 }
99
100 pub(super) async fn verify_blog_post_ownership(
101 db: &sqlx::PgPool,
102 blog_post_id: BlogPostId,
103 user_id: UserId,
104 ) -> Result<db::DbBlogPost> {
105 let post = db::blog_posts::get_blog_post_by_id(db, blog_post_id)
106 .await?
107 .ok_or(AppError::NotFound)?;
108 verify_project_ownership(db, post.project_id, user_id).await?;
109 Ok(post)
110 }
111
112 /// Middleware that converts HTML error responses into JSON on API routes.
113 ///
114 /// When `AppError::into_response()` fires it stashes an [`ApiErrorMessage`] in
115 /// the response extensions. This layer checks for that extension and, if
116 /// present, replaces the HTML body with `{"error": "..."}` while preserving the
117 /// original status code. Page routes never hit this layer, so they keep
118 /// getting the full HTML error template.
119 async fn json_error_layer(req: Request, next: Next) -> Response {
120 let response = next.run(req).await;
121 if let Some(ApiErrorMessage(msg)) = response.extensions().get::<ApiErrorMessage>().cloned() {
122 let status = response.status();
123 return (status, Json(json!({"error": msg}))).into_response();
124 }
125 response
126 }
127
128 // ── Public project listing (no auth) ──
129
130 #[derive(Serialize)]
131 struct PublicProject {
132 slug: String,
133 title: String,
134 description: Option<String>,
135 project_type: ProjectType,
136 username: String,
137 item_count: i64,
138 }
139
140 #[tracing::instrument(skip_all, name = "api::public_projects")]
141 async fn public_projects(State(db): State<PgPool>) -> Result<impl IntoResponse> {
142 let rows = db::discover::discover_projects(&db, None, None, None, false, 50, 0).await?;
143 let data: Vec<PublicProject> = rows
144 .into_iter()
145 .map(|r| PublicProject {
146 slug: r.slug.to_string(),
147 title: r.title,
148 description: r.description,
149 project_type: r.project_type,
150 username: r.username.to_string(),
151 item_count: r.item_count,
152 })
153 .collect();
154 Ok(Json(json!({ "data": data })))
155 }
156
157 // ── Email signup (public, no auth) ──
158
159 #[derive(Deserialize)]
160 struct EmailSignupForm {
161 email: String,
162 }
163
164 #[tracing::instrument(skip_all, name = "api::email_signup")]
165 async fn email_signup(
166 State(db): State<PgPool>,
167 Json(form): Json<EmailSignupForm>,
168 ) -> Result<impl IntoResponse> {
169 let email = db::Email::new(&form.email)?;
170 db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?;
171 Ok(Json(json!({"success": true})))
172 }
173
174 /// Register all JSON API routes for projects, items, links, tags, and account management.
175 ///
176 /// Routes are split into three tiers with different rate limits:
177 /// - Write routes (POST/PUT/DELETE): burst 10, 2/sec per IP
178 /// - Export routes: burst 3, 1/sec per IP (stricter, prevents bulk extraction)
179 /// - Read routes (GET): no rate limit (alpha scale)
180 pub fn api_routes() -> CsrfRouter<AppState> {
181 let write_rate_limit = crate::helpers::rate_limiter_ms(
182 constants::API_WRITE_RATE_LIMIT_MS,
183 constants::API_WRITE_RATE_LIMIT_BURST,
184 );
185 let export_rate_limit = crate::helpers::rate_limiter_per_sec(
186 constants::API_EXPORT_RATE_LIMIT_PER_SEC,
187 constants::API_EXPORT_RATE_LIMIT_BURST,
188 );
189
190 // Write routes, rate limited
191 let write_routes = CsrfRouter::new()
192 // User routes
193 .route("/api/users/me", put_csrf(users::update_profile))
194 .route("/api/users/me/password", put_csrf(users::update_password))
195 .route(
196 "/api/users/me/preferences",
197 put_csrf(users::update_preferences),
198 )
199 .route(
200 "/api/users/me/stripe",
201 delete_csrf(users::disconnect_stripe),
202 )
203 .route(
204 "/api/users/me/stripe-tax",
205 put_csrf(users::toggle_stripe_tax),
206 )
207 .route("/api/users/me/theme", put_csrf(users::update_profile_theme))
208 .route("/api/users/me", delete_csrf(users::delete_account))
209 .route(
210 "/api/users/me/deactivate",
211 post_csrf(users::deactivate_account),
212 )
213 .route(
214 "/api/users/me/reactivate",
215 post_csrf(users::reactivate_account),
216 )
217 .route(
218 "/api/users/me/pause-creator",
219 post_csrf(users::pause_creator),
220 )
221 // Broadcast
222 .route("/api/broadcast", post_csrf(users::broadcast_send))
223 .route(
224 "/api/support/ticket",
225 post_csrf(users::submit_support_ticket),
226 )
227 // Project routes
228 .route("/api/projects", post_csrf(projects::create_project))
229 .route("/api/projects/{id}", put_csrf(projects::update_project))
230 .route(
231 "/api/projects/{id}/theme",
232 put_csrf(projects::update_project_theme),
233 )
234 .route("/api/projects/{id}", delete_csrf(projects::delete_project))
235 // Git repo management
236 .route("/api/repos", post_csrf(projects::create_repo))
237 .route(
238 "/api/repos/{id}/visibility",
239 put_csrf(projects::update_repo_visibility),
240 )
241 .route_get(
242 "/api/repos/{id}/collaborators",
243 get(projects::list_repo_collaborators),
244 )
245 .route(
246 "/api/repos/{id}/collaborators",
247 post_csrf(projects::add_repo_collaborator),
248 )
249 .route(
250 "/api/repos/{repo_id}/collaborators/{user_id}",
251 delete_csrf(projects::remove_repo_collaborator),
252 )
253 .route("/api/projects/{id}/repos", post_csrf(projects::link_repo))
254 .route(
255 "/api/projects/{id}/repos/{repo_name}",
256 delete_csrf(projects::unlink_repo),
257 )
258 // Project members
259 .route(
260 "/api/projects/{id}/members",
261 post_csrf(projects::add_project_member),
262 )
263 .route(
264 "/api/projects/{project_id}/members/{user_id}",
265 delete_csrf(projects::remove_project_member),
266 )
267 // Item routes
268 .route("/api/projects/{id}/items", post_csrf(items::create_item))
269 .route("/api/items/{id}", put_csrf(items::update_item))
270 .route("/api/items/{id}", delete_csrf(items::delete_item))
271 .route(
272 "/api/items/{id}/duplicate",
273 post_csrf(items::duplicate_item),
274 )
275 // Bulk item operations
276 .route("/api/items/bulk/publish", post_csrf(items::bulk_publish))
277 .route(
278 "/api/items/bulk/unpublish",
279 post_csrf(items::bulk_unpublish),
280 )
281 .route("/api/items/bulk/delete", post_csrf(items::bulk_delete))
282 .route("/api/items/bulk/price", post_csrf(items::bulk_price))
283 .route("/api/items/bulk/tag", post_csrf(items::bulk_tag))
284 .route("/api/items/{id}/move", put_csrf(items::move_item))
285 // Bundle management
286 .route("/api/items/{id}/bundle/add", post_csrf(items::bundle_add))
287 .route(
288 "/api/items/{id}/bundle/create-child",
289 post_csrf(items::bundle_create_child),
290 )
291 .route(
292 "/api/items/{id}/bundle/{child_id}",
293 delete_csrf(items::bundle_remove),
294 )
295 .route(
296 "/api/items/{id}/bundle/{child_id}/listed",
297 put_csrf(items::bundle_toggle_listed),
298 )
299 // Refund
300 .route(
301 "/api/items/{id}/refund",
302 post_csrf(items::refund_transaction),
303 )
304 .route("/api/items/{id}/restore", post_csrf(items::restore_item))
305 // Tag routes (HTMX)
306 .route("/api/items/{id}/tags", post_csrf(items::add_tag))
307 .route(
308 "/api/items/{id}/tags/{tag_id}",
309 delete_csrf(items::remove_tag),
310 )
311 .route(
312 "/api/items/{id}/primary-tag",
313 put_csrf(items::set_primary_tag),
314 )
315 // Text content route
316 .route("/api/items/{id}/text", put_csrf(items::update_item_text))
317 // Version routes
318 .route("/api/items/{id}/versions", post_csrf(items::create_version))
319 .route(
320 "/api/items/{id}/versions/{version_id}",
321 delete_csrf(items::delete_version),
322 )
323 // Custom link routes
324 .route("/api/links", post_csrf(links::create_link))
325 .route("/api/links/{id}", put_csrf(links::update_link))
326 .route("/api/links/{id}", delete_csrf(links::delete_link))
327 .route("/api/links/reorder", put_csrf(links::reorder_links))
328 // Chapter routes
329 .route("/api/items/{id}/chapters", post_csrf(items::create_chapter))
330 .route("/api/chapters/{id}", put_csrf(items::update_chapter))
331 .route("/api/chapters/{id}", delete_csrf(items::delete_chapter))
332 // Section routes
333 .route("/api/items/{id}/sections", post_csrf(items::create_section))
334 .route("/api/sections/{id}", put_csrf(items::update_section))
335 .route("/api/sections/{id}", delete_csrf(items::delete_section))
336 .route(
337 "/api/items/{id}/sections/reorder",
338 put_csrf(items::reorder_sections),
339 )
340 // Project section routes
341 .route(
342 "/api/projects/{id}/sections",
343 post_csrf(project_sections::create_section),
344 )
345 .route(
346 "/api/project-sections/{id}",
347 put_csrf(project_sections::update_section),
348 )
349 .route(
350 "/api/project-sections/{id}",
351 delete_csrf(project_sections::delete_section),
352 )
353 .route(
354 "/api/projects/{id}/sections/reorder",
355 put_csrf(project_sections::reorder_sections),
356 )
357 // Library routes
358 .route(
359 "/api/library/add/{item_id}",
360 post_csrf(users::add_to_library),
361 )
362 .route(
363 "/api/library/remove/{item_id}",
364 delete_csrf(users::remove_from_library),
365 )
366 // Contact sharing revocation
367 .route(
368 "/api/contacts/{seller_id}",
369 delete_csrf(users::revoke_contact),
370 )
371 // Waitlist
372 .route("/api/waitlist/apply", post_csrf(users::waitlist_apply))
373 // Email verification
374 .route(
375 "/api/resend-verification",
376 post_csrf(users::resend_verification),
377 )
378 // Account management
379 .route(
380 "/api/account/request-deletion",
381 post_csrf(users::request_account_deletion),
382 )
383 // Suspension appeal
384 .route("/api/users/me/appeal", post_csrf(users::submit_appeal))
385 // Session management
386 .route(
387 "/api/users/me/sessions/{id}",
388 delete_csrf(users::revoke_session),
389 )
390 .route(
391 "/api/users/me/sessions",
392 delete_csrf(users::revoke_other_sessions),
393 )
394 // Blog routes
395 .route("/api/projects/{id}/blog", post_csrf(blog::create_blog_post))
396 .route("/api/blog/{id}", put_csrf(blog::update_blog_post))
397 .route("/api/blog/{id}", delete_csrf(blog::delete_blog_post))
398 // License key management (creator)
399 .route(
400 "/api/items/{id}/license-settings",
401 put_csrf(license_keys::update_license_settings),
402 )
403 .route(
404 "/api/items/{id}/keys",
405 post_csrf(license_keys::generate_key),
406 )
407 .route("/api/keys/{id}/revoke", post_csrf(license_keys::revoke_key))
408 // Promo code management (creator)
409 .route(
410 "/api/promo-codes",
411 post_csrf(promo_codes::create_promo_code),
412 )
413 .route_get("/api/promo-codes", get(promo_codes::list_promo_codes))
414 .route(
415 "/api/promo-codes/expired",
416 delete_csrf(promo_codes::delete_expired_promo_codes),
417 )
418 .route(
419 "/api/promo-codes/{id}",
420 put_csrf(promo_codes::update_promo_code),
421 )
422 .route(
423 "/api/promo-codes/{id}",
424 delete_csrf(promo_codes::delete_promo_code),
425 )
426 .route_get(
427 "/api/promo-codes/{id}/redemptions",
428 get(promo_codes::list_redemptions),
429 )
430 // Promo code claim (buyer, free_access codes)
431 .route(
432 "/api/promo-codes/claim",
433 post_csrf(promo_codes::claim_promo_code),
434 )
435 // Subscription tier management (creator)
436 .route(
437 "/api/projects/{id}/tiers",
438 post_csrf(subscriptions::create_tier),
439 )
440 .route("/api/tiers/{id}", put_csrf(subscriptions::update_tier))
441 .route("/api/tiers/{id}", delete_csrf(subscriptions::delete_tier))
442 // Follow system
443 .route(
444 "/api/follow/{target_type}/{target_id}",
445 post_csrf(follows::follow_target),
446 )
447 .route(
448 "/api/follow/{target_type}/{target_id}",
449 delete_csrf(follows::unfollow_target),
450 )
451 // Category management
452 .route("/api/categories", post_csrf(categories::create_category))
453 // TOTP 2FA management (setup only; the password/code-verifying mutations
454 // move to a stricter auth-rate-limited sub-router below).
455 .route("/api/users/me/totp/setup", post_csrf(totp::setup))
456 // Passkey management
457 .route(
458 "/api/users/me/passkeys/register/start",
459 post_csrf(passkeys::register_start),
460 )
461 .route(
462 "/api/users/me/passkeys/register/finish",
463 post_csrf(passkeys::register_finish),
464 )
465 .route("/api/users/me/passkeys/{id}", put_csrf(passkeys::rename))
466 .route("/api/users/me/passkeys/{id}", delete_csrf(passkeys::delete))
467 // Content insertion management
468 .route(
469 "/api/users/me/insertions/presign",
470 post_csrf(content_insertions::presign_insertion),
471 )
472 .route(
473 "/api/users/me/insertions/confirm",
474 post_csrf(content_insertions::confirm_insertion),
475 )
476 .route(
477 "/api/insertions/{id}",
478 put_csrf(content_insertions::rename_insertion),
479 )
480 .route(
481 "/api/insertions/{id}",
482 delete_csrf(content_insertions::delete_insertion),
483 )
484 // Content insertion placements
485 .route(
486 "/api/items/{id}/insertions",
487 post_csrf(content_insertions::create_placement),
488 )
489 .route(
490 "/api/item-insertions/{id}",
491 delete_csrf(content_insertions::delete_placement),
492 )
493 // SSH key management
494 .route_get("/api/users/me/ssh-keys", get(ssh_keys::list_keys))
495 .route("/api/users/me/ssh-keys", post_csrf(ssh_keys::add_key))
496 .route(
497 "/api/users/me/ssh-keys/{id}",
498 delete_csrf(ssh_keys::delete_key),
499 )
500 // Git access tokens (HTTPS)
501 .route("/api/users/me/git-tokens", post_csrf(git_tokens::create))
502 .route(
503 "/api/users/me/git-tokens/{id}",
504 delete_csrf(git_tokens::revoke),
505 )
506 // Reports
507 .route("/api/reports", post_csrf(reports::submit_report))
508 // Collections
509 .route(
510 "/api/collections",
511 post_csrf(collections::create_collection),
512 )
513 .route(
514 "/api/collections/{id}",
515 put_csrf(collections::update_collection),
516 )
517 .route(
518 "/api/collections/{id}",
519 delete_csrf(collections::delete_collection),
520 )
521 .route(
522 "/api/collections/{id}/items/{item_id}",
523 post_csrf(collections::add_item),
524 )
525 .route(
526 "/api/collections/{id}/items/{item_id}",
527 delete_csrf(collections::remove_item),
528 )
529 .route(
530 "/api/collections/{id}/items/reorder",
531 put_csrf(collections::reorder_items),
532 )
533 // Wishlists
534 .route(
535 "/api/wishlists/{item_id}",
536 post_csrf(wishlists::toggle_wishlist),
537 )
538 // Cart
539 .route("/api/cart/{item_id}", post_csrf(cart::toggle_cart))
540 .route("/api/cart/{item_id}", put_csrf(cart::update_cart_amount))
541 .route("/api/cart/{item_id}", delete_csrf(cart::remove_from_cart))
542 // Custom domains
543 .route("/api/domains", post_csrf(domains::add_domain))
544 .route("/api/domains/verify", post_csrf(domains::verify_domain))
545 .route("/api/domains/{id}", delete_csrf(domains::remove_domain))
546 // Invite codes
547 .route("/api/invites/create", post_csrf(users::create_invite))
548 // Email signup (public, landing page notify-me)
549 .route(
550 "/api/email-signup",
551 post_csrf_skip("pre-auth landing signup, no session", email_signup),
552 )
553 .route_layer(GovernorLayer::new(write_rate_limit));
554
555 // Password/code-verifying TOTP mutations, strict auth-strength rate limit
556 // (matching login), not the looser API-write limit, so confirm-code and
557 // disable/regenerate password checks can't be ground (ultra-fuzz Run 10 Sec M1).
558 let totp_sensitive_rate_limit = crate::helpers::rate_limiter_ms(
559 constants::AUTH_RATE_LIMIT_MS,
560 constants::AUTH_RATE_LIMIT_BURST,
561 );
562 let totp_sensitive_routes = CsrfRouter::new()
563 .route("/api/users/me/totp/confirm", post_csrf(totp::confirm))
564 .route("/api/users/me/totp/disable", post_csrf(totp::disable))
565 .route(
566 "/api/users/me/totp/backup-codes",
567 post_csrf(totp::regenerate_backup_codes),
568 )
569 .route_layer(GovernorLayer::new(totp_sensitive_rate_limit));
570
571 // Export routes, stricter rate limit
572 let export_routes = CsrfRouter::new()
573 .route("/api/export/projects", post_csrf(exports::export_projects))
574 .route("/api/export/sales", post_csrf(exports::export_sales))
575 .route(
576 "/api/export/purchases",
577 post_csrf(exports::export_purchases),
578 )
579 .route("/api/export/splits", post_csrf(exports::export_splits))
580 .route(
581 "/api/export/followers",
582 post_csrf(exports::export_followers),
583 )
584 .route(
585 "/api/export/subscriptions",
586 post_csrf(exports::export_subscriptions),
587 )
588 .route("/api/export/content", post_csrf(exports::export_content))
589 .route("/api/export/contacts", post_csrf(exports::export_contacts))
590 .route_layer(GovernorLayer::new(export_rate_limit));
591
592 let key_rate_limit = crate::helpers::rate_limiter_ms(
593 constants::LICENSE_KEY_RATE_LIMIT_MS,
594 constants::LICENSE_KEY_RATE_LIMIT_BURST,
595 );
596
597 let key_routes = CsrfRouter::new()
598 .route(
599 "/api/keys/validate",
600 post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key),
601 )
602 .route(
603 "/api/v1/keys/validate",
604 post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key),
605 )
606 .route(
607 "/api/keys/deactivate",
608 post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key),
609 )
610 .route(
611 "/api/v1/keys/deactivate",
612 post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key),
613 )
614 // Key in the POST body keeps the purchase-proof secret out of access
615 // logs. The GET-path forms below are deprecated but kept for SDK
616 // consumers that predate this endpoint.
617 .route(
618 "/api/keys/status",
619 post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::key_status_post),
620 )
621 .route(
622 "/api/v1/keys/status",
623 post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::key_status_post),
624 )
625 .route_get("/api/keys/{key_code}/status", get(license_keys::key_status))
626 .route_get(
627 "/api/v1/keys/{key_code}/status",
628 get(license_keys::key_status),
629 )
630 .route(
631 "/api/v1/license/verify",
632 post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_verify),
633 )
634 .route(
635 "/api/v1/license/deactivate",
636 post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_deactivate),
637 )
638 .route_layer(GovernorLayer::new(key_rate_limit));
639
640 let read_rate_limit = crate::helpers::rate_limiter_ms(
641 constants::API_READ_RATE_LIMIT_MS,
642 constants::API_READ_RATE_LIMIT_BURST,
643 );
644
645 // Read routes
646 let read_routes = CsrfRouter::new()
647 .route_get("/api/public/projects", get(public_projects))
648 .route_get("/api/v1/public/projects", get(public_projects))
649 .route_get("/api/projects", get(projects::list_projects))
650 .route_get("/api/items/{id}/versions", get(items::list_versions))
651 .route_get("/api/items/{id}/chapters", get(items::list_chapters))
652 .route_get("/api/items/{id}/sections", get(items::list_sections))
653 .route_get("/api/items/{id}/keys", get(license_keys::list_keys))
654 .route_get(
655 "/api/projects/{id}/sections",
656 get(project_sections::list_sections),
657 )
658 .route_get("/api/projects/{id}/blog", get(blog::list_blog_posts))
659 .route_get("/api/blog/{id}", get(blog::get_blog_post))
660 .route_get("/api/tags/search", get(tags::search_tags))
661 .route_get("/api/categories/search", get(categories::search_categories))
662 .route_get("/api/items/{id}/tag-suggestions", get(tags::suggest_tags))
663 .route_get("/api/projects/{id}/tiers", get(subscriptions::list_tiers))
664 // TOTP status (HTMX partial for dashboard)
665 .route_get("/api/users/me/totp/status", get(totp::status))
666 // Passkey list (HTMX partial for dashboard)
667 .route_get("/api/users/me/passkeys", get(passkeys::list))
668 // SSH key list (HTMX partial for dashboard)
669 .route_get("/api/users/me/ssh-keys/list", get(ssh_keys::list_keys_html))
670 .route_get("/api/users/me/git-tokens/list", get(git_tokens::list_html))
671 // Content insertion list (HTMX partials for dashboard)
672 .route_get(
673 "/api/users/me/insertions",
674 get(content_insertions::list_insertions),
675 )
676 .route_get(
677 "/api/items/{id}/insertions",
678 get(content_insertions::list_placements),
679 )
680 // Collections (read)
681 .route_get(
682 "/api/collections/for-item/{item_id}",
683 get(collections::collections_for_item),
684 )
685 // Custom domains (read)
686 .route_get("/api/domains", get(domains::get_domain))
687 .route_get("/api/domains/caddy-ask", get(domains::caddy_ask))
688 .route_get("/api/restart-status", get(internal::restart_status))
689 // Cart (read)
690 .route_get("/api/cart/count", get(cart::cart_count))
691 // Import system (read)
692 .route_get("/api/users/me/import/{id}", get(imports::get_import_status))
693 .route_get("/api/users/me/imports", get(imports::list_imports))
694 // License text (public)
695 .route_get(
696 "/api/items/{id}/license.txt",
697 get(license_keys::license_text),
698 )
699 .route_get(
700 "/api/v1/items/{id}/license.txt",
701 get(license_keys::license_text),
702 )
703 .route_layer(GovernorLayer::new(read_rate_limit));
704
705 let validate_rate_limit = crate::helpers::rate_limiter_per_sec(
706 constants::VALIDATE_RATE_LIMIT_PER_SEC,
707 constants::VALIDATE_RATE_LIMIT_BURST,
708 );
709
710 let validate_routes = CsrfRouter::new()
711 .route(
712 "/api/validate/project-slug",
713 post_csrf(validate::validate_project_slug),
714 )
715 .route(
716 "/api/validate/collection-slug",
717 post_csrf(validate::validate_collection_slug),
718 )
719 .route(
720 "/api/validate/blog-slug",
721 post_csrf(validate::validate_blog_slug),
722 )
723 .route_layer(GovernorLayer::new(validate_rate_limit));
724
725 // Import route needs a higher body limit (base64-encoded CSV up to 10 MB
726 // ≈ 14 MB encoded). The global 1 MB RequestBodyLimitLayer would reject it,
727 // so we override with a per-route layer.
728 let import_routes = CsrfRouter::new()
729 .route("/api/users/me/import", post_csrf(imports::start_import))
730 .layer(axum::extract::DefaultBodyLimit::max(15 * 1024 * 1024))
731 .route_layer(GovernorLayer::new(crate::helpers::rate_limiter_ms(
732 constants::API_WRITE_RATE_LIMIT_MS,
733 constants::API_WRITE_RATE_LIMIT_BURST,
734 )));
735
736 // Guest checkout routes, public, no auth, CORS-enabled, stricter rate limit
737 let guest_checkout_rate_limit = crate::helpers::rate_limiter_per_sec(
738 constants::GUEST_CHECKOUT_RATE_LIMIT_PER_SEC,
739 constants::GUEST_CHECKOUT_RATE_LIMIT_BURST,
740 );
741 let guest_routes = CsrfRouter::new()
742 .route(
743 "/api/checkout/guest/{item_id}",
744 post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::create_guest_checkout),
745 )
746 .route_get(
747 "/api/checkout/guest/{item_id}",
748 options(guest_checkout::guest_checkout_preflight),
749 )
750 .route(
751 "/api/checkout/guest-free/{item_id}",
752 post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::claim_free_guest),
753 )
754 .route(
755 "/api/purchases/claim",
756 post_csrf(guest_checkout::claim_purchase),
757 )
758 .route_layer(GovernorLayer::new(guest_checkout_rate_limit));
759
760 // Guest download route, separate, more lenient per-IP rate limit. Unauth'd
761 // and token-gated; the throttle is defense-in-depth against anonymous flooding
762 // (token entropy already makes enumeration impractical).
763 let download_rate_limit = crate::helpers::rate_limiter_per_sec(
764 constants::GUEST_DOWNLOAD_RATE_LIMIT_PER_SEC,
765 constants::GUEST_DOWNLOAD_RATE_LIMIT_BURST,
766 );
767 let download_routes = CsrfRouter::new()
768 .route_get("/download/{token}", get(guest_checkout::guest_download))
769 .route_layer(GovernorLayer::new(download_rate_limit));
770
771 // CSP violation intake. Posted by the browser with no session and no CSRF
772 // token, so it skips CSRF by necessity; the compensating controls are the
773 // per-IP throttle and the body cap, and the handler writes nothing but a log
774 // line.
775 let csp_report_rate_limit = crate::helpers::rate_limiter_per_sec(
776 constants::CSP_REPORT_RATE_LIMIT_PER_SEC,
777 constants::CSP_REPORT_RATE_LIMIT_BURST,
778 );
779 let csp_report_routes = CsrfRouter::new()
780 .route(
781 "/api/csp-report",
782 post_csrf_skip(CSP_REPORT_SKIP, csp_report::report_csp_violation),
783 )
784 .layer(axum::extract::DefaultBodyLimit::max(
785 constants::CSP_REPORT_BODY_LIMIT_BYTES,
786 ))
787 .route_layer(GovernorLayer::new(csp_report_rate_limit));
788
789 write_routes
790 .merge(csp_report_routes)
791 .merge(totp_sensitive_routes)
792 .merge(export_routes)
793 .merge(key_routes)
794 .merge(validate_routes)
795 .merge(read_routes)
796 .merge(import_routes)
797 .merge(guest_routes)
798 .merge(download_routes)
799 .merge(internal::internal_routes())
800 .layer(axum::middleware::from_fn(json_error_layer))
801 }
802