Skip to main content

max / makenotwork

6.9 KB · 189 lines History Blame Raw
1 //! Site-wide access gate (the `ACCESS_GATE=fan_plus_or_creator` mode).
2 //!
3 //! When enabled, the entire site is reachable only by logged-in users who hold
4 //! a creator account or an active Fan+ subscription; everyone else is bounced
5 //! to `/login` with a notice. This backs the testnot.work staging mirror, whose
6 //! data is a daily restore of production, gating it to Fan+/creator accounts
7 //! keeps that mirror off the open internet (the "available to anyone with a
8 //! Fan+ or creator account" rule), matching the testnot Fan+ perk.
9 //!
10 //! It is a COARSE pre-filter: it reads the cached session flags only (no DB
11 //! query, no session-tracking revalidation). The per-route `AuthUser` extractor
12 //! still enforces full auth underneath, so the gate never relaxes real
13 //! authorization, it only narrows who reaches the routes at all. Default-off,
14 //! so production (`AccessGate::Open`) is completely unaffected.
15
16 use axum::{
17 body::Body,
18 extract::State,
19 http::Request,
20 middleware::Next,
21 response::{IntoResponse, Redirect, Response},
22 };
23 use tower_sessions::Session;
24
25 use crate::AppState;
26 use crate::config::AccessGate;
27
28 /// Paths that must stay reachable even when the gate is on, so an
29 /// un-authenticated visitor can still log in and so infrastructure keeps
30 /// working. Everything else requires a creator/Fan+ session.
31 ///
32 /// Matched by exact path or `<prefix>/` so `/login` and `/login/...` both pass
33 /// but a hypothetical `/loginsomething` does not.
34 fn path_is_exempt(path: &str) -> bool {
35 /// Reachable as exactly `p` or as `p` followed by `/`.
36 fn hit(path: &str, p: &str) -> bool {
37 path == p
38 || path
39 .strip_prefix(p)
40 .is_some_and(|rest| rest.starts_with('/'))
41 }
42
43 // Authentication surface, without these the gate would lock out its own
44 // login page and the assets/endpoints the login flow needs.
45 hit(path, "/login")
46 || hit(path, "/logout")
47 || hit(path, "/auth") // /auth/me, /auth/2fa, /auth/passkey/*
48 || hit(path, "/oauth") // MNW-as-OAuth-provider authorize/token/userinfo
49 || hit(path, "/sso") // delegated "Sign in with Makenotwork" start + callback
50 // Static assets + browser chrome (the login page pulls CSS/JS/images).
51 || hit(path, "/static")
52 || path == "/favicon.ico"
53 || path == "/robots.txt"
54 // Caddy proxies its own 404/500 to these. Gating them would answer a
55 // 404 with a redirect to /login, so the gated site would have no error
56 // page at all.
57 || hit(path, "/__errors")
58 // Operational endpoints: the deploy smoke check and machine callers.
59 || path == "/health"
60 || path == "/metrics"
61 || hit(path, "/stripe/webhook") // inert on testnot (Stripe stubbed) but never gate a webhook
62 || hit(path, "/postmark") // inbound/webhook callbacks
63 }
64
65 /// Whether the gate permits this session through. Creators (`can_create_projects`)
66 /// and active Fan+ members pass; anonymous and plain-fan sessions do not.
67 fn session_is_allowed(user: Option<&crate::auth::SessionUser>) -> bool {
68 user.is_some_and(|u| u.can_create_projects || u.is_fan_plus)
69 }
70
71 /// Axum middleware enforcing the site access gate. No-op when the gate is
72 /// `Open` (production), so it adds a single enum comparison per request there.
73 pub async fn access_gate_middleware(
74 State(state): State<AppState>,
75 request: Request<Body>,
76 next: Next,
77 ) -> Response {
78 if state.config.access_gate == AccessGate::Open {
79 return next.run(request).await;
80 }
81
82 let path = request.uri().path();
83 if path_is_exempt(path) {
84 return next.run(request).await;
85 }
86
87 // Session is installed by the session layer (outer to this middleware).
88 let user = match request.extensions().get::<Session>() {
89 Some(session) => crate::auth::session_user(session).await,
90 None => None,
91 };
92
93 if session_is_allowed(user.as_ref()) {
94 next.run(request).await
95 } else {
96 // Coarse redirect to login with a notice flag; the per-route auth still
97 // governs anything the user reaches after authenticating.
98 Redirect::to("/login?gate=fan_plus_or_creator").into_response()
99 }
100 }
101
102 #[cfg(test)]
103 mod tests {
104 use super::*;
105
106 #[test]
107 fn exempts_auth_and_asset_paths() {
108 for p in [
109 "/login",
110 "/login?gate=fan_plus_or_creator",
111 "/logout",
112 "/auth/me",
113 "/auth/passkey/start",
114 "/oauth/authorize",
115 "/sso/login",
116 "/sso/callback",
117 "/static/style.css",
118 // Generated by build.rs. style.css reads the bevel pair out of
119 // layout.css, so gating either would strip depth from the login
120 // page rather than merely slowing it.
121 "/static/geometry.css",
122 "/static/layout.css",
123 "/static/images/favicon.ico",
124 "/favicon.ico",
125 "/robots.txt",
126 "/health",
127 "/metrics",
128 "/stripe/webhook",
129 "/postmark/inbound",
130 "/__errors/404.html",
131 ] {
132 // path_is_exempt sees the path only (no query), mirroring uri().path().
133 let path = p.split('?').next().unwrap();
134 assert!(path_is_exempt(path), "expected exempt: {p}");
135 }
136 }
137
138 #[test]
139 fn gates_content_paths() {
140 for p in [
141 "/",
142 "/discover",
143 "/u/someone",
144 "/p/some-project",
145 "/changelog",
146 "/library",
147 "/loginsomething", // prefix must not over-match
148 "/authority", // ditto
149 ] {
150 assert!(!path_is_exempt(p), "expected gated: {p}");
151 }
152 }
153
154 #[test]
155 fn only_creator_or_fan_plus_passes() {
156 use crate::auth::SessionUser;
157 use crate::db::{UserId, Username};
158
159 fn user(can_create_projects: bool, is_fan_plus: bool) -> SessionUser {
160 SessionUser {
161 settlement_currency: crate::currency::SettlementCurrency::Usd,
162 conversion_preference: crate::currency::ConversionChoice::AtCheckout,
163 id: UserId::default(),
164 username: Username::from_trusted("t".into()),
165 email: "t@example.com".into(),
166 display_name: None,
167 can_create_projects,
168 suspended: false,
169 is_admin: false,
170 is_fan_plus,
171 creator_tier: None,
172 deactivated: false,
173 is_sandbox: false,
174 }
175 }
176
177 assert!(!session_is_allowed(None), "anonymous blocked");
178 assert!(
179 !session_is_allowed(Some(&user(false, false))),
180 "plain fan blocked"
181 );
182 assert!(
183 session_is_allowed(Some(&user(true, false))),
184 "creator allowed"
185 );
186 assert!(session_is_allowed(Some(&user(false, true))), "fan+ allowed");
187 }
188 }
189