Skip to main content

max / makenotwork

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