Skip to main content

max / makenotwork

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