Skip to main content

max / makenotwork

6.5 KB · 148 lines History Blame Raw
1 //! Public-facing page routes visible to all visitors.
2
3 pub(crate) mod content;
4 mod discover;
5 mod docs;
6 mod feed;
7 mod health;
8 pub(crate) mod join_wizard;
9 pub(crate) mod landing;
10 mod sitemap;
11 mod two_factor;
12
13 use axum::{
14 extract::State,
15 response::{IntoResponse, Redirect},
16 routing::get,
17 };
18 use tower_sessions::Session;
19
20 use crate::{
21 auth::MaybeUserUnverified,
22 constants,
23 csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter},
24 db,
25 error::Result,
26 helpers::get_csrf_token,
27 templates::*,
28 types::*,
29 AppState,
30 };
31
32 use tower_governor::GovernorLayer;
33
34 /// Register public page routes.
35 pub fn public_routes() -> CsrfRouter<AppState> {
36 let twofa_rate_limit = crate::helpers::rate_limiter_ms(constants::TWO_FACTOR_RATE_LIMIT_MS, constants::TWO_FACTOR_RATE_LIMIT_BURST);
37 let join_rate_limit = crate::helpers::rate_limiter_ms(constants::AUTH_RATE_LIMIT_MS, constants::AUTH_RATE_LIMIT_BURST);
38 // Per-IP read limiter for the unauthenticated discover SEARCH endpoints —
39 // these run ILIKE / tag-tree queries per request and are the genuine
40 // DoS-amplification surface among the public GETs (Run #12 Security MINOR).
41 // The cheap, cached content-page GETs (/u, /p, /i, ...) are left to
42 // Cloudflare edge limiting. Burst is generous (API read tier) so legitimate
43 // type-ahead on /discover/suggestions isn't throttled. One shared bucket per
44 // IP across the three search routes.
45 let search_rate_limit = crate::helpers::rate_limiter_ms(constants::API_READ_RATE_LIMIT_MS, constants::API_READ_RATE_LIMIT_BURST);
46
47 CsrfRouter::new()
48 .route_get("/", get(landing::index))
49 .route_get("/library", get(landing::library))
50 .route_get("/cart", get(landing::cart_page))
51 .route_get("/library/tabs/purchases", get(landing::library_tab_purchases))
52 .route_get("/library/tabs/feed", get(landing::library_tab_feed))
53 .route_get("/library/tabs/collections", get(landing::library_tab_collections))
54 .route_get("/library/tabs/contacts", get(landing::library_tab_contacts))
55 .route_get("/library/tabs/communities", get(landing::library_tab_communities))
56 .route_get("/health", get(health::health))
57 .route_get("/api/health", get(health::health_json))
58 .route_get("/robots.txt", get(sitemap::robots_txt))
59 .route_get("/sitemap.xml", get(sitemap::sitemap_xml))
60 // NOTE: GET /login is registered in auth_routes() alongside POST /login
61 // to avoid Axum merge conflicts that strip rate limiting layers.
62 // Join wizard
63 .route_get("/join", get(join_wizard::wizard_page))
64 .route(
65 "/join/step/account",
66 post_csrf_skip(
67 "join-wizard step 1: pre-auth signup",
68 join_wizard::step_account_create,
69 )
70 .layer(GovernorLayer { config: join_rate_limit }),
71 )
72 .route(
73 "/join/step/{step}",
74 with_csrf_skip(
75 "join-wizard: continuation of pre-auth flow",
76 get(join_wizard::step_load).post(join_wizard::step_save),
77 ),
78 )
79 .route_get("/discover", get(discover::discover))
80 .route_get("/discover/results", get(discover::discover_results).layer(GovernorLayer { config: search_rate_limit.clone() }))
81 .route_get("/discover/suggestions", get(discover::search_suggestions_handler).layer(GovernorLayer { config: search_rate_limit.clone() }))
82 .route_get("/discover/tags", get(discover::tag_tree).layer(GovernorLayer { config: search_rate_limit }))
83 .route_get("/feed", get(feed::feed_page))
84 .route_get("/u/{username}", get(content::user_page))
85 .route_get("/c/{username}/{slug}", get(content::collection_page))
86 .route_get("/p/{slug}", get(content::project_page))
87 .route_get("/i/{item_id}", get(content::item_page))
88 .route_get("/l/{item_id}", get(content::library_page))
89 .route_get("/purchase/{item_id}", get(content::purchase_page))
90 .route_get("/receipt/{transaction_id}", get(content::receipt_page))
91 .route_get("/buy/{item_id}", get(content::buy_page))
92 .route_get("/pricing", get(landing::pricing_page))
93 .route_get("/checkout/complete", get(landing::checkout_complete))
94 .route_get("/use-cases", get(landing::use_cases_page))
95 .route_get("/team", get(landing::team_page))
96 .route_get("/policy", get(landing::policy_page))
97 .route_get("/fan-plus", get(landing::fan_plus_page))
98 .route_get("/creators", get(creators_page))
99 .route_get("/docs", get(docs::docs_index))
100 .route_get("/docs/search.json", get(docs::docs_search_index))
101 // Platform economics renders as Askama (live runway disclosure); the
102 // markdown source is gone. Served top-level at /economics alongside the
103 // other landing pages. The old /docs/economics URL 301s here for
104 // continuity and must register BEFORE the catch-all `/docs/{slug}` so
105 // axum prefers the exact match.
106 .route_get("/economics", get(landing::economics_page))
107 .route_get("/docs/economics", get(|| async { Redirect::permanent("/economics") }))
108 .route_get("/docs/{slug}", get(docs::doc_page))
109 // Two-factor authentication
110 .route_get("/auth/2fa", get(two_factor::two_factor_page))
111 .route(
112 "/auth/verify-2fa",
113 post_csrf_skip(
114 "2FA verification: pre-promotion to full auth, no session yet",
115 two_factor::verify_two_factor,
116 )
117 .layer(GovernorLayer { config: twofa_rate_limit }),
118 )
119 }
120
121 /// Render the public creators page showing invite waves and waitlist stats.
122 #[tracing::instrument(skip_all, name = "pages::creators_page")]
123 async fn creators_page(
124 State(state): State<AppState>,
125 session: Session,
126 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
127 ) -> Result<impl IntoResponse> {
128 let csrf_token = get_csrf_token(&session).await;
129
130 let waves = db::waitlist::get_all_waves(&state.db).await?;
131 let total_creators = db::waitlist::count_active_creators(&state.db).await? as u32;
132 let waitlist_pending = db::waitlist::count_waitlist_pending(&state.db).await? as u32;
133
134 let is_creator = maybe_user.as_ref().map(|u| u.can_create_projects).unwrap_or(false);
135
136 let wave_stats: Vec<WaveStats> = waves.iter().map(WaveStats::from).collect();
137
138 Ok(CreatorsTemplate {
139 csrf_token,
140 session_user: maybe_user,
141 waves: wave_stats,
142 total_creators,
143 waitlist_pending,
144 is_creator,
145 tier_prices: state.tier_prices.clone(),
146 })
147 }
148