Skip to main content

max / makenotwork

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