Skip to main content

max / makenotwork

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