//! Site-wide access gate (the `ACCESS_GATE=fan_plus_or_creator` mode). //! //! When enabled, the entire site is reachable only by logged-in users who hold //! a creator account or an active Fan+ subscription; everyone else is bounced //! to `/login` with a notice. This backs the testnot.work staging mirror, whose //! data is a daily restore of production — gating it to Fan+/creator accounts //! keeps that mirror off the open internet (the "available to anyone with a //! Fan+ or creator account" rule), matching the testnot Fan+ perk. //! //! It is a COARSE pre-filter: it reads the cached session flags only (no DB //! query, no session-tracking revalidation). The per-route `AuthUser` extractor //! still enforces full auth underneath, so the gate never relaxes real //! authorization — it only narrows who reaches the routes at all. Default-off, //! so production (`AccessGate::Open`) is completely unaffected. use axum::{ body::Body, extract::State, http::Request, middleware::Next, response::{IntoResponse, Redirect, Response}, }; use tower_sessions::Session; use crate::config::AccessGate; use crate::AppState; /// Paths that must stay reachable even when the gate is on, so an /// un-authenticated visitor can still log in and so infrastructure keeps /// working. Everything else requires a creator/Fan+ session. /// /// Matched by exact path or `/` so `/login` and `/login/...` both pass /// but a hypothetical `/loginsomething` does not. fn path_is_exempt(path: &str) -> bool { /// Reachable as exactly `p` or as `p` followed by `/`. fn hit(path: &str, p: &str) -> bool { path == p || path.strip_prefix(p).is_some_and(|rest| rest.starts_with('/')) } // Authentication surface — without these the gate would lock out its own // login page and the assets/endpoints the login flow needs. hit(path, "/login") || hit(path, "/logout") || hit(path, "/auth") // /auth/me, /auth/2fa, /auth/passkey/* || hit(path, "/oauth") // MNW-as-OAuth-provider authorize/token/userinfo || hit(path, "/sso") // delegated "Sign in with Makenot.work" start + callback // Static assets + browser chrome (the login page pulls CSS/JS/images). || hit(path, "/static") || hit(path, "/rustdoc") || path == "/favicon.ico" || path == "/robots.txt" // Operational endpoints: the deploy smoke check and machine callers. || path == "/health" || path == "/metrics" || hit(path, "/stripe/webhook") // inert on testnot (Stripe stubbed) but never gate a webhook || hit(path, "/postmark") // inbound/webhook callbacks } /// Whether the gate permits this session through. Creators (`can_create_projects`) /// and active Fan+ members pass; anonymous and plain-fan sessions do not. fn session_is_allowed(user: Option<&crate::auth::SessionUser>) -> bool { user.is_some_and(|u| u.can_create_projects || u.is_fan_plus) } /// Axum middleware enforcing the site access gate. No-op when the gate is /// `Open` (production), so it adds a single enum comparison per request there. pub async fn access_gate_middleware( State(state): State, request: Request, next: Next, ) -> Response { if state.config.access_gate == AccessGate::Open { return next.run(request).await; } let path = request.uri().path(); if path_is_exempt(path) { return next.run(request).await; } // Session is installed by the session layer (outer to this middleware). let user = match request.extensions().get::() { Some(session) => crate::auth::session_user(session).await, None => None, }; if session_is_allowed(user.as_ref()) { next.run(request).await } else { // Coarse redirect to login with a notice flag; the per-route auth still // governs anything the user reaches after authenticating. Redirect::to("/login?gate=fan_plus_or_creator").into_response() } } #[cfg(test)] mod tests { use super::*; #[test] fn exempts_auth_and_asset_paths() { for p in [ "/login", "/login?gate=fan_plus_or_creator", "/logout", "/auth/me", "/auth/passkey/start", "/oauth/authorize", "/sso/login", "/sso/callback", "/static/style.css", "/static/images/favicon.ico", "/rustdoc/index.html", "/favicon.ico", "/robots.txt", "/health", "/metrics", "/stripe/webhook", "/postmark/inbound", ] { // path_is_exempt sees the path only (no query), mirroring uri().path(). let path = p.split('?').next().unwrap(); assert!(path_is_exempt(path), "expected exempt: {p}"); } } #[test] fn gates_content_paths() { for p in [ "/", "/discover", "/u/someone", "/p/some-project", "/changelog", "/library", "/loginsomething", // prefix must not over-match "/authority", // ditto ] { assert!(!path_is_exempt(p), "expected gated: {p}"); } } #[test] fn only_creator_or_fan_plus_passes() { use crate::auth::SessionUser; use crate::db::{UserId, Username}; fn user(can_create_projects: bool, is_fan_plus: bool) -> SessionUser { SessionUser { id: UserId::default(), username: Username::from_trusted("t".into()), email: "t@example.com".into(), display_name: None, can_create_projects, suspended: false, is_admin: false, is_fan_plus, creator_tier: None, deactivated: false, is_sandbox: false, } } assert!(!session_is_allowed(None), "anonymous blocked"); assert!(!session_is_allowed(Some(&user(false, false))), "plain fan blocked"); assert!(session_is_allowed(Some(&user(true, false))), "creator allowed"); assert!(session_is_allowed(Some(&user(false, true))), "fan+ allowed"); } }