//! Public-facing page routes visible to all visitors. pub(crate) mod content; mod discover; mod docs; mod error_pages; mod health; pub(crate) mod join_wizard; pub(crate) mod landing; pub(crate) mod pagination; mod sitemap; mod two_factor; use axum::{response::Redirect, routing::get}; use crate::{ AppState, constants, csrf::{CsrfRouter, post_csrf, post_csrf_skip, with_csrf_skip}, }; use tower_governor::GovernorLayer; /// Register public page routes. pub(crate) fn public_routes(limits: constants::RateLimits) -> CsrfRouter { let twofa_rate_limit = crate::helpers::rate_limiter_ms( constants::TWO_FACTOR_RATE_LIMIT_MS, constants::TWO_FACTOR_RATE_LIMIT_BURST, ); let join_rate_limit = crate::helpers::rate_limiter_ms(limits.auth_ms, limits.auth_burst); // Per-IP read limiter for the unauthenticated discover SEARCH endpoints, // these run ILIKE / tag-tree queries per request and are the genuine // DoS-amplification surface among the public GETs (Run #12 Security MINOR). // The cheap, cached content-page GETs (/u, /p, /i, ...) are left to // Cloudflare edge limiting. Burst is generous (API read tier) so legitimate // type-ahead on /discover/suggestions isn't throttled. One shared bucket per // IP across the three search routes. let search_rate_limit = crate::helpers::rate_limiter_ms( constants::API_READ_RATE_LIMIT_MS, constants::API_READ_RATE_LIMIT_BURST, ); // The library contacts and communities tabs are deliberately absent here. // `crate::quasi` serves both addresses, and registering them again would // panic axum on two routes claiming one path. They were registered behind // `QUASI_SCREENS` being off until `64b33b26` removed the flag. CsrfRouter::new() .route_get("/", get(landing::index)) .route_get("/library", get(landing::library)) .route_get("/cart", get(landing::cart_page)) .route_get( "/library/tabs/purchases", get(landing::library_tab_purchases), ) .route_get("/library/tabs/feed", get(landing::library_tab_feed)) .route_get( "/library/tabs/collections", get(landing::library_tab_collections), ) // Both health endpoints run ~8 COUNT(*) queries + an S3 connectivity // round-trip per hit, unauthenticated, a strictly more expensive // DoS-amplification surface than discover, which is already throttled. // The API-read limit (10/s sustained, burst 60) is generous enough for // any real uptime monitor while capping a flood. .route_get( "/health", get(health::health).layer(GovernorLayer::new(search_rate_limit.clone())), ) .route_get( "/api/health", get(health::health_json).layer(GovernorLayer::new(search_rate_limit.clone())), ) // Caddy's `handle_errors` proxies its own 404/500 here so the branded // pages ship with the binary instead of as a per-deploy file upload. // 502 is not served here on purpose. See `error_pages`. .route_get("/__errors/{name}", get(error_pages::error_page)) .route_get("/robots.txt", get(sitemap::robots_txt)) .route_get("/sitemap.xml", get(sitemap::sitemap_xml)) // NOTE: GET /login is registered in auth_routes() alongside POST /login // to avoid Axum merge conflicts that strip rate limiting layers. // Join wizard .route_get("/join", get(join_wizard::wizard_page)) .route( "/join/step/account", post_csrf_skip( "join-wizard step 1: pre-auth signup", join_wizard::step_account_create, ) .layer(GovernorLayer::new(join_rate_limit.clone())), ) .route( "/join/step/{step}", with_csrf_skip( "join-wizard: continuation of pre-auth flow", get(join_wizard::step_load).post(join_wizard::step_save), ), ) .route_get("/discover", get(discover::discover)) .route_get( "/discover/results", get(discover::discover_results).layer(GovernorLayer::new(search_rate_limit.clone())), ) .route_get( "/discover/suggestions", get(discover::search_suggestions_handler) .layer(GovernorLayer::new(search_rate_limit.clone())), ) .route_get( "/discover/tags", get(discover::tag_tree).layer(GovernorLayer::new(search_rate_limit.clone())), ) .route_get( "/discover/tag-suggest", get(discover::tag_suggestions_handler).layer(GovernorLayer::new(search_rate_limit)), ) .route_get("/u/{username}", get(content::user_page)) .route_get("/p/{slug}", get(content::project_page)) .route_get("/i/{item_id}", get(content::item_page)) .route_get("/l/{item_id}", get(content::library_page)) .route_get("/purchase/{item_id}", get(content::purchase_page)) .route_get("/receipt/{transaction_id}", get(content::receipt_page)) .route_get("/buy/{item_id}", get(content::buy_page)) .route_get("/checkout/complete", get(landing::checkout_complete)) // Landing "notify me". CSRF-protected like the other public forms, and // rate limited because it is unauthenticated and writes a row. .route( "/notify", post_csrf(landing::notify).layer(GovernorLayer::new(join_rate_limit.clone())), ) .route_get("/docs", get(docs::docs_index)) .route_get("/docs/search.json", get(docs::docs_search_index)) // Platform economics renders as Askama (live runway disclosure); the // markdown source is gone. Served top-level at /economics alongside the // other landing pages. The old /docs/economics URL 301s here for // continuity and must register BEFORE the catch-all `/docs/{slug}` so // axum prefers the exact match. .route_get("/economics", get(landing::economics_page)) .route_get( "/docs/economics", get(|| async { Redirect::permanent("/economics") }), ) .route_get("/docs/{slug}", get(docs::doc_page)) // Two-factor authentication .route_get("/auth/2fa", get(two_factor::two_factor_page)) .route( "/auth/verify-2fa", post_csrf_skip( "2FA verification: pre-promotion to full auth, no session yet", two_factor::verify_two_factor, ) .layer(GovernorLayer::new(twofa_rate_limit)), ) }