Skip to main content

max / makenotwork

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