Skip to main content

max / makenotwork

8.0 KB · 203 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 error_pages;
7 mod feed;
8 mod health;
9 pub(crate) mod join_wizard;
10 pub(crate) mod landing;
11 mod pagination;
12 mod sitemap;
13 mod two_factor;
14
15 use axum::{
16 extract::State,
17 response::{IntoResponse, Redirect},
18 routing::get,
19 };
20 use sqlx::PgPool;
21 use tower_sessions::Session;
22
23 use crate::{
24 AppState, Billing,
25 auth::MaybeUserUnverified,
26 constants,
27 csrf::{CsrfRouter, post_csrf_skip, with_csrf_skip},
28 db,
29 error::Result,
30 helpers::get_csrf_token,
31 templates::CreatorsTemplate,
32 types::WaveStats,
33 };
34
35 use tower_governor::GovernorLayer;
36
37 /// Register public page routes.
38 pub(crate) fn public_routes() -> CsrfRouter<AppState> {
39 let twofa_rate_limit = crate::helpers::rate_limiter_ms(
40 constants::TWO_FACTOR_RATE_LIMIT_MS,
41 constants::TWO_FACTOR_RATE_LIMIT_BURST,
42 );
43 let join_rate_limit = crate::helpers::rate_limiter_ms(
44 constants::AUTH_RATE_LIMIT_MS,
45 constants::AUTH_RATE_LIMIT_BURST,
46 );
47 // Per-IP read limiter for the unauthenticated discover SEARCH endpoints,
48 // these run ILIKE / tag-tree queries per request and are the genuine
49 // DoS-amplification surface among the public GETs (Run #12 Security MINOR).
50 // The cheap, cached content-page GETs (/u, /p, /i, ...) are left to
51 // Cloudflare edge limiting. Burst is generous (API read tier) so legitimate
52 // type-ahead on /discover/suggestions isn't throttled. One shared bucket per
53 // IP across the three search routes.
54 let search_rate_limit = crate::helpers::rate_limiter_ms(
55 constants::API_READ_RATE_LIMIT_MS,
56 constants::API_READ_RATE_LIMIT_BURST,
57 );
58
59 CsrfRouter::new()
60 .route_get("/", get(landing::index))
61 .route_get("/library", get(landing::library))
62 .route_get("/cart", get(landing::cart_page))
63 .route_get(
64 "/library/tabs/purchases",
65 get(landing::library_tab_purchases),
66 )
67 .route_get("/library/tabs/feed", get(landing::library_tab_feed))
68 .route_get(
69 "/library/tabs/collections",
70 get(landing::library_tab_collections),
71 )
72 .route_get("/library/tabs/contacts", get(landing::library_tab_contacts))
73 .route_get(
74 "/library/tabs/communities",
75 get(landing::library_tab_communities),
76 )
77 // Both health endpoints run ~8 COUNT(*) queries + an S3 connectivity
78 // round-trip per hit, unauthenticated, a strictly more expensive
79 // DoS-amplification surface than discover, which is already throttled.
80 // The API-read limit (10/s sustained, burst 60) is generous enough for
81 // any real uptime monitor while capping a flood.
82 .route_get(
83 "/health",
84 get(health::health).layer(GovernorLayer::new(search_rate_limit.clone())),
85 )
86 .route_get(
87 "/api/health",
88 get(health::health_json).layer(GovernorLayer::new(search_rate_limit.clone())),
89 )
90 // Caddy's `handle_errors` proxies its own 404/500 here so the branded
91 // pages ship with the binary instead of as a per-deploy file upload.
92 // 502 is not served here on purpose. See `error_pages`.
93 .route_get("/__errors/{name}", get(error_pages::error_page))
94 .route_get("/robots.txt", get(sitemap::robots_txt))
95 .route_get("/sitemap.xml", get(sitemap::sitemap_xml))
96 // NOTE: GET /login is registered in auth_routes() alongside POST /login
97 // to avoid Axum merge conflicts that strip rate limiting layers.
98 // Join wizard
99 .route_get("/join", get(join_wizard::wizard_page))
100 .route(
101 "/join/step/account",
102 post_csrf_skip(
103 "join-wizard step 1: pre-auth signup",
104 join_wizard::step_account_create,
105 )
106 .layer(GovernorLayer::new(join_rate_limit)),
107 )
108 .route(
109 "/join/step/{step}",
110 with_csrf_skip(
111 "join-wizard: continuation of pre-auth flow",
112 get(join_wizard::step_load).post(join_wizard::step_save),
113 ),
114 )
115 .route_get("/discover", get(discover::discover))
116 .route_get(
117 "/discover/results",
118 get(discover::discover_results).layer(GovernorLayer::new(search_rate_limit.clone())),
119 )
120 .route_get(
121 "/discover/suggestions",
122 get(discover::search_suggestions_handler)
123 .layer(GovernorLayer::new(search_rate_limit.clone())),
124 )
125 .route_get(
126 "/discover/tags",
127 get(discover::tag_tree).layer(GovernorLayer::new(search_rate_limit.clone())),
128 )
129 .route_get(
130 "/discover/tag-suggest",
131 get(discover::tag_suggestions_handler).layer(GovernorLayer::new(search_rate_limit)),
132 )
133 .route_get("/feed", get(feed::feed_page))
134 .route_get("/u/{username}", get(content::user_page))
135 .route_get("/c/{username}/{slug}", get(content::collection_page))
136 .route_get("/p/{slug}", get(content::project_page))
137 .route_get("/i/{item_id}", get(content::item_page))
138 .route_get("/l/{item_id}", get(content::library_page))
139 .route_get("/purchase/{item_id}", get(content::purchase_page))
140 .route_get("/receipt/{transaction_id}", get(content::receipt_page))
141 .route_get("/buy/{item_id}", get(content::buy_page))
142 .route_get("/pricing", get(landing::pricing_page))
143 .route_get("/pricing/compare", get(landing::pricing_compare))
144 .route_get("/checkout/complete", get(landing::checkout_complete))
145 .route_get("/use-cases", get(landing::use_cases_page))
146 .route_get("/team", get(landing::team_page))
147 .route_get("/policy", get(landing::policy_page))
148 .route_get("/fan-plus", get(landing::fan_plus_page))
149 .route_get("/creators", get(creators_page))
150 .route_get("/docs", get(docs::docs_index))
151 .route_get("/docs/search.json", get(docs::docs_search_index))
152 // Platform economics renders as Askama (live runway disclosure); the
153 // markdown source is gone. Served top-level at /economics alongside the
154 // other landing pages. The old /docs/economics URL 301s here for
155 // continuity and must register BEFORE the catch-all `/docs/{slug}` so
156 // axum prefers the exact match.
157 .route_get("/economics", get(landing::economics_page))
158 .route_get(
159 "/docs/economics",
160 get(|| async { Redirect::permanent("/economics") }),
161 )
162 .route_get("/docs/{slug}", get(docs::doc_page))
163 // Two-factor authentication
164 .route_get("/auth/2fa", get(two_factor::two_factor_page))
165 .route(
166 "/auth/verify-2fa",
167 post_csrf_skip(
168 "2FA verification: pre-promotion to full auth, no session yet",
169 two_factor::verify_two_factor,
170 )
171 .layer(GovernorLayer::new(twofa_rate_limit)),
172 )
173 }
174
175 /// Render the public creators page showing invite waves and waitlist stats.
176 #[tracing::instrument(skip_all, name = "pages::creators_page")]
177 async fn creators_page(
178 State(db): State<PgPool>,
179 State(payments): State<Billing>,
180 session: Session,
181 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
182 ) -> Result<impl IntoResponse> {
183 let csrf_token = get_csrf_token(&session).await;
184
185 let waves = db::waitlist::get_all_waves(&db).await?;
186 let total_creators = db::waitlist::count_active_creators(&db).await? as u32;
187 let waitlist_pending = db::waitlist::count_waitlist_pending(&db).await? as u32;
188
189 let is_creator = maybe_user.as_ref().is_some_and(|u| u.can_create_projects);
190
191 let wave_stats: Vec<WaveStats> = waves.iter().map(WaveStats::from).collect();
192
193 Ok(CreatorsTemplate {
194 csrf_token,
195 session_user: maybe_user,
196 waves: wave_stats,
197 total_creators,
198 waitlist_pending,
199 is_creator,
200 tier_prices: payments.tier_prices.clone(),
201 })
202 }
203