Skip to main content

max / makenotwork

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