Skip to main content

max / makenotwork

6.6 KB · 154 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::{response::Redirect, routing::get};
15
16 use crate::{
17 AppState, constants,
18 csrf::{CsrfRouter, post_csrf, post_csrf_skip, with_csrf_skip},
19 };
20
21 use tower_governor::GovernorLayer;
22
23 /// Register public page routes.
24 pub(crate) fn public_routes(limits: constants::RateLimits) -> CsrfRouter<AppState> {
25 let twofa_rate_limit = crate::helpers::rate_limiter_ms(
26 constants::TWO_FACTOR_RATE_LIMIT_MS,
27 constants::TWO_FACTOR_RATE_LIMIT_BURST,
28 );
29 let join_rate_limit = crate::helpers::rate_limiter_ms(limits.auth_ms, limits.auth_burst);
30 // Per-IP read limiter for the unauthenticated discover SEARCH endpoints,
31 // these run ILIKE / tag-tree queries per request and are the genuine
32 // DoS-amplification surface among the public GETs (Run #12 Security MINOR).
33 // The cheap, cached content-page GETs (/u, /p, /i, ...) are left to
34 // Cloudflare edge limiting. Burst is generous (API read tier) so legitimate
35 // type-ahead on /discover/suggestions isn't throttled. One shared bucket per
36 // IP across the three search routes.
37 let search_rate_limit = crate::helpers::rate_limiter_ms(
38 constants::API_READ_RATE_LIMIT_MS,
39 constants::API_READ_RATE_LIMIT_BURST,
40 );
41
42 // The library contacts and communities tabs are deliberately absent here.
43 // `crate::quasi` serves both addresses, and registering them again would
44 // panic axum on two routes claiming one path. They were registered behind
45 // `QUASI_SCREENS` being off until `64b33b26` removed the flag.
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(
52 "/library/tabs/purchases",
53 get(landing::library_tab_purchases),
54 )
55 .route_get("/library/tabs/feed", get(landing::library_tab_feed))
56 .route_get(
57 "/library/tabs/collections",
58 get(landing::library_tab_collections),
59 )
60 // Both health endpoints run ~8 COUNT(*) queries + an S3 connectivity
61 // round-trip per hit, unauthenticated, a strictly more expensive
62 // DoS-amplification surface than discover, which is already throttled.
63 // The API-read limit (10/s sustained, burst 60) is generous enough for
64 // any real uptime monitor while capping a flood.
65 .route_get(
66 "/health",
67 get(health::health).layer(GovernorLayer::new(search_rate_limit.clone())),
68 )
69 .route_get(
70 "/api/health",
71 get(health::health_json).layer(GovernorLayer::new(search_rate_limit.clone())),
72 )
73 // Caddy's `handle_errors` proxies its own 404/500 here so the branded
74 // pages ship with the binary instead of as a per-deploy file upload.
75 // 502 is not served here on purpose. See `error_pages`.
76 .route_get("/__errors/{name}", get(error_pages::error_page))
77 .route_get("/robots.txt", get(sitemap::robots_txt))
78 .route_get("/sitemap.xml", get(sitemap::sitemap_xml))
79 // NOTE: GET /login is registered in auth_routes() alongside POST /login
80 // to avoid Axum merge conflicts that strip rate limiting layers.
81 // Join wizard
82 .route_get("/join", get(join_wizard::wizard_page))
83 .route(
84 "/join/step/account",
85 post_csrf_skip(
86 "join-wizard step 1: pre-auth signup",
87 join_wizard::step_account_create,
88 )
89 .layer(GovernorLayer::new(join_rate_limit.clone())),
90 )
91 .route(
92 "/join/step/{step}",
93 with_csrf_skip(
94 "join-wizard: continuation of pre-auth flow",
95 get(join_wizard::step_load).post(join_wizard::step_save),
96 ),
97 )
98 .route_get("/discover", get(discover::discover))
99 .route_get(
100 "/discover/results",
101 get(discover::discover_results).layer(GovernorLayer::new(search_rate_limit.clone())),
102 )
103 .route_get(
104 "/discover/suggestions",
105 get(discover::search_suggestions_handler)
106 .layer(GovernorLayer::new(search_rate_limit.clone())),
107 )
108 .route_get(
109 "/discover/tags",
110 get(discover::tag_tree).layer(GovernorLayer::new(search_rate_limit.clone())),
111 )
112 .route_get(
113 "/discover/tag-suggest",
114 get(discover::tag_suggestions_handler).layer(GovernorLayer::new(search_rate_limit)),
115 )
116 .route_get("/u/{username}", get(content::user_page))
117 .route_get("/p/{slug}", get(content::project_page))
118 .route_get("/i/{item_id}", get(content::item_page))
119 .route_get("/l/{item_id}", get(content::library_page))
120 .route_get("/purchase/{item_id}", get(content::purchase_page))
121 .route_get("/receipt/{transaction_id}", get(content::receipt_page))
122 .route_get("/buy/{item_id}", get(content::buy_page))
123 .route_get("/checkout/complete", get(landing::checkout_complete))
124 // Landing "notify me". CSRF-protected like the other public forms, and
125 // rate limited because it is unauthenticated and writes a row.
126 .route(
127 "/notify",
128 post_csrf(landing::notify).layer(GovernorLayer::new(join_rate_limit.clone())),
129 )
130 .route_get("/docs", get(docs::docs_index))
131 .route_get("/docs/search.json", get(docs::docs_search_index))
132 // Platform economics renders as Askama (live runway disclosure); the
133 // markdown source is gone. Served top-level at /economics alongside the
134 // other landing pages. The old /docs/economics URL 301s here for
135 // continuity and must register BEFORE the catch-all `/docs/{slug}` so
136 // axum prefers the exact match.
137 .route_get("/economics", get(landing::economics_page))
138 .route_get(
139 "/docs/economics",
140 get(|| async { Redirect::permanent("/economics") }),
141 )
142 .route_get("/docs/{slug}", get(docs::doc_page))
143 // Two-factor authentication
144 .route_get("/auth/2fa", get(two_factor::two_factor_page))
145 .route(
146 "/auth/verify-2fa",
147 post_csrf_skip(
148 "2FA verification: pre-promotion to full auth, no session yet",
149 two_factor::verify_two_factor,
150 )
151 .layer(GovernorLayer::new(twofa_rate_limit)),
152 )
153 }
154