Skip to main content

max / makenotwork

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