Skip to main content

max / makenotwork

6.2 KB · 170 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::config::AccessGate;
26 use crate::AppState;
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 || path.strip_prefix(p).is_some_and(|rest| rest.starts_with('/'))
38 }
39
40 // Authentication surface — without these the gate would lock out its own
41 // login page and the assets/endpoints the login flow needs.
42 hit(path, "/login")
43 || hit(path, "/logout")
44 || hit(path, "/auth") // /auth/me, /auth/2fa, /auth/passkey/*
45 || hit(path, "/oauth") // MNW-as-OAuth-provider authorize/token/userinfo
46 || hit(path, "/sso") // delegated "Sign in with Makenot.work" start + callback
47 // Static assets + browser chrome (the login page pulls CSS/JS/images).
48 || hit(path, "/static")
49 || hit(path, "/rustdoc")
50 || path == "/favicon.ico"
51 || path == "/robots.txt"
52 // Operational endpoints: the deploy smoke check and machine callers.
53 || path == "/health"
54 || path == "/metrics"
55 || hit(path, "/stripe/webhook") // inert on testnot (Stripe stubbed) but never gate a webhook
56 || hit(path, "/postmark") // inbound/webhook callbacks
57 }
58
59 /// Whether the gate permits this session through. Creators (`can_create_projects`)
60 /// and active Fan+ members pass; anonymous and plain-fan sessions do not.
61 fn session_is_allowed(user: Option<&crate::auth::SessionUser>) -> bool {
62 user.is_some_and(|u| u.can_create_projects || u.is_fan_plus)
63 }
64
65 /// Axum middleware enforcing the site access gate. No-op when the gate is
66 /// `Open` (production), so it adds a single enum comparison per request there.
67 pub async fn access_gate_middleware(
68 State(state): State<AppState>,
69 request: Request<Body>,
70 next: Next,
71 ) -> Response {
72 if state.config.access_gate == AccessGate::Open {
73 return next.run(request).await;
74 }
75
76 let path = request.uri().path();
77 if path_is_exempt(path) {
78 return next.run(request).await;
79 }
80
81 // Session is installed by the session layer (outer to this middleware).
82 let user = match request.extensions().get::<Session>() {
83 Some(session) => crate::auth::session_user(session).await,
84 None => None,
85 };
86
87 if session_is_allowed(user.as_ref()) {
88 next.run(request).await
89 } else {
90 // Coarse redirect to login with a notice flag; the per-route auth still
91 // governs anything the user reaches after authenticating.
92 Redirect::to("/login?gate=fan_plus_or_creator").into_response()
93 }
94 }
95
96 #[cfg(test)]
97 mod tests {
98 use super::*;
99
100 #[test]
101 fn exempts_auth_and_asset_paths() {
102 for p in [
103 "/login",
104 "/login?gate=fan_plus_or_creator",
105 "/logout",
106 "/auth/me",
107 "/auth/passkey/start",
108 "/oauth/authorize",
109 "/sso/login",
110 "/sso/callback",
111 "/static/style.css",
112 "/static/images/favicon.ico",
113 "/rustdoc/index.html",
114 "/favicon.ico",
115 "/robots.txt",
116 "/health",
117 "/metrics",
118 "/stripe/webhook",
119 "/postmark/inbound",
120 ] {
121 // path_is_exempt sees the path only (no query), mirroring uri().path().
122 let path = p.split('?').next().unwrap();
123 assert!(path_is_exempt(path), "expected exempt: {p}");
124 }
125 }
126
127 #[test]
128 fn gates_content_paths() {
129 for p in [
130 "/",
131 "/discover",
132 "/u/someone",
133 "/p/some-project",
134 "/changelog",
135 "/library",
136 "/loginsomething", // prefix must not over-match
137 "/authority", // ditto
138 ] {
139 assert!(!path_is_exempt(p), "expected gated: {p}");
140 }
141 }
142
143 #[test]
144 fn only_creator_or_fan_plus_passes() {
145 use crate::auth::SessionUser;
146 use crate::db::{UserId, Username};
147
148 fn user(can_create_projects: bool, is_fan_plus: bool) -> SessionUser {
149 SessionUser {
150 id: UserId::default(),
151 username: Username::from_trusted("t".into()),
152 email: "t@example.com".into(),
153 display_name: None,
154 can_create_projects,
155 suspended: false,
156 is_admin: false,
157 is_fan_plus,
158 creator_tier: None,
159 deactivated: false,
160 is_sandbox: false,
161 }
162 }
163
164 assert!(!session_is_allowed(None), "anonymous blocked");
165 assert!(!session_is_allowed(Some(&user(false, false))), "plain fan blocked");
166 assert!(session_is_allowed(Some(&user(true, false))), "creator allowed");
167 assert!(session_is_allowed(Some(&user(false, true))), "fan+ allowed");
168 }
169 }
170