Skip to main content

max / makenotwork

7.8 KB · 247 lines History Blame Raw
1 //! Custom domain fallback handler.
2 //!
3 //! Catches all routes not matched by MNW's named routes and checks the Host
4 //! header to determine if the request is for a custom domain. If so, routes
5 //! to the appropriate user profile, project, or item page.
6
7 use axum::{
8 extract::State,
9 http::{HeaderMap, StatusCode, Uri},
10 response::{IntoResponse, Response},
11 };
12 use tower_sessions::Session;
13
14 use crate::{
15 auth::{MaybeUserVerified, SessionUser},
16 db::{self, Slug},
17 error::{AppError, Result},
18 helpers::get_csrf_token,
19 AppState,
20 };
21
22 use super::pages::public::content;
23
24 /// Extract the hostname from the request headers (without port).
25 fn extract_host(headers: &HeaderMap) -> Option<String> {
26 headers
27 .get(axum::http::header::HOST)
28 .and_then(|v| v.to_str().ok())
29 .map(|h| {
30 // Strip port if present
31 h.split(':').next().unwrap_or(h).to_lowercase()
32 })
33 }
34
35 /// Check if a hostname belongs to MNW (not a custom domain).
36 fn is_mnw_domain(host: &str) -> bool {
37 host == "makenot.work"
38 || host.ends_with(".makenot.work")
39 || host == "makenotwork.com"
40 || host.ends_with(".makenotwork.com")
41 || host == "localhost"
42 || host == "127.0.0.1"
43 }
44
45 /// Check if a request is for a custom domain and handle it.
46 ///
47 /// Returns `Some(Response)` if the Host header matches a verified custom domain
48 /// and the path was successfully routed. Returns `None` if the request is for
49 /// an MNW domain or no custom domain matches (caller should proceed normally).
50 ///
51 /// Called from named route handlers (e.g. `landing::index` for `/`) where the
52 /// fallback handler wouldn't fire because the route is already matched.
53 pub async fn try_handle(
54 state: &AppState,
55 headers: &HeaderMap,
56 path: &str,
57 session: &Session,
58 maybe_user: &Option<SessionUser>,
59 ) -> Option<Response> {
60 let host = extract_host(headers)?;
61
62 if is_mnw_domain(&host) {
63 return None;
64 }
65
66 let user_id = state.domain_cache.get(&host).map(|e| *e.value())?;
67
68 let segments: Vec<&str> = path
69 .trim_start_matches('/')
70 .split('/')
71 .filter(|s| !s.is_empty())
72 .collect();
73
74 let result = match segments.as_slice() {
75 [] => render_user_profile(state, user_id, session, maybe_user).await,
76 [project_slug] => render_project(state, user_id, project_slug, session, maybe_user).await,
77 [project_slug, item_slug] => {
78 render_item(state, user_id, project_slug, item_slug, session, maybe_user).await
79 }
80 _ => return Some(StatusCode::NOT_FOUND.into_response()),
81 };
82
83 Some(match result {
84 Ok(response) => response,
85 Err(_) => StatusCode::NOT_FOUND.into_response(),
86 })
87 }
88
89 /// Fallback handler for custom domain routing.
90 ///
91 /// If the Host header matches a verified custom domain, routes:
92 /// - `/` → user profile
93 /// - `/{project-slug}` → project page
94 /// - `/{project-slug}/{item-slug}` → item page
95 ///
96 /// MNW domains get a standard 404.
97 #[tracing::instrument(skip_all, name = "custom_domain::fallback")]
98 pub async fn custom_domain_fallback(
99 State(state): State<AppState>,
100 headers: HeaderMap,
101 uri: Uri,
102 session: Session,
103 MaybeUserVerified(maybe_user): MaybeUserVerified,
104 ) -> Response {
105 let Some(host) = extract_host(&headers) else {
106 return StatusCode::NOT_FOUND.into_response();
107 };
108
109 // MNW domains → standard 404
110 if is_mnw_domain(&host) {
111 return StatusCode::NOT_FOUND.into_response();
112 }
113
114 // Look up custom domain
115 let user_id = match state.domain_cache.get(&host) {
116 Some(entry) => *entry.value(),
117 None => return StatusCode::NOT_FOUND.into_response(),
118 };
119
120 // Route by path segments
121 let path = uri.path().trim_start_matches('/');
122 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
123
124 let result = match segments.as_slice() {
125 [] => render_user_profile(&state, user_id, &session, &maybe_user).await,
126 [project_slug] => {
127 render_project(&state, user_id, project_slug, &session, &maybe_user).await
128 }
129 [project_slug, item_slug] => {
130 render_item(&state, user_id, project_slug, item_slug, &session, &maybe_user).await
131 }
132 _ => return StatusCode::NOT_FOUND.into_response(),
133 };
134
135 match result {
136 Ok(response) => response,
137 Err(_) => StatusCode::NOT_FOUND.into_response(),
138 }
139 }
140
141 /// Render a user profile for a custom domain.
142 async fn render_user_profile(
143 state: &AppState,
144 user_id: db::UserId,
145 session: &Session,
146 maybe_user: &Option<SessionUser>,
147 ) -> Result<Response> {
148 let csrf_token = get_csrf_token(session).await;
149 let db_user = db::users::get_user_by_id(&state.db, user_id)
150 .await?
151 .ok_or(AppError::NotFound)?;
152 content::render_user_profile(state, &db_user, csrf_token, maybe_user.clone()).await
153 }
154
155 /// Render a project page for a custom domain (scoped to user_id + slug).
156 async fn render_project(
157 state: &AppState,
158 user_id: db::UserId,
159 project_slug: &str,
160 session: &Session,
161 maybe_user: &Option<SessionUser>,
162 ) -> Result<Response> {
163 let csrf_token = get_csrf_token(session).await;
164 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
165 let db_project =
166 db::projects::get_public_project_by_user_and_slug(&state.db, user_id, &slug)
167 .await?
168 .ok_or(AppError::NotFound)?;
169 content::render_project_page(state, &db_project, csrf_token, maybe_user.clone()).await
170 }
171
172 /// Render an item page for a custom domain (scoped to user_id + project slug + item slug).
173 async fn render_item(
174 state: &AppState,
175 user_id: db::UserId,
176 project_slug: &str,
177 item_slug: &str,
178 session: &Session,
179 maybe_user: &Option<SessionUser>,
180 ) -> Result<Response> {
181 let csrf_token = get_csrf_token(session).await;
182 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
183 let db_project =
184 db::projects::get_public_project_by_user_and_slug(&state.db, user_id, &slug)
185 .await?
186 .ok_or(AppError::NotFound)?;
187 let db_item = db::items::get_item_by_project_and_slug(&state.db, db_project.id, item_slug)
188 .await?
189 .ok_or(AppError::NotFound)?;
190 let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
191 .await?
192 .ok_or(AppError::NotFound)?;
193 content::render_item_page(state, &db_item, &db_project, &db_user, csrf_token, maybe_user.clone()).await
194 }
195
196 #[cfg(test)]
197 mod tests {
198 use super::*;
199
200 #[test]
201 fn extract_host_simple() {
202 let mut headers = HeaderMap::new();
203 headers.insert(
204 axum::http::header::HOST,
205 "example.com".parse().unwrap(),
206 );
207 assert_eq!(extract_host(&headers), Some("example.com".to_string()));
208 }
209
210 #[test]
211 fn extract_host_with_port() {
212 let mut headers = HeaderMap::new();
213 headers.insert(
214 axum::http::header::HOST,
215 "example.com:443".parse().unwrap(),
216 );
217 assert_eq!(extract_host(&headers), Some("example.com".to_string()));
218 }
219
220 #[test]
221 fn extract_host_uppercase() {
222 let mut headers = HeaderMap::new();
223 headers.insert(
224 axum::http::header::HOST,
225 "Example.COM".parse().unwrap(),
226 );
227 assert_eq!(extract_host(&headers), Some("example.com".to_string()));
228 }
229
230 #[test]
231 fn is_mnw_domain_true() {
232 assert!(is_mnw_domain("makenot.work"));
233 assert!(is_mnw_domain("forums.makenot.work"));
234 assert!(is_mnw_domain("cdn.makenot.work"));
235 assert!(is_mnw_domain("localhost"));
236 assert!(is_mnw_domain("127.0.0.1"));
237 assert!(is_mnw_domain("makenotwork.com"));
238 }
239
240 #[test]
241 fn is_mnw_domain_false() {
242 assert!(!is_mnw_domain("example.com"));
243 assert!(!is_mnw_domain("mycreations.com"));
244 assert!(!is_mnw_domain("not-makenot.work"));
245 }
246 }
247