Skip to main content

max / makenotwork

9.8 KB · 310 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, Uri},
10 response::{IntoResponse, Response},
11 };
12 use sqlx::PgPool;
13 use tower_sessions::Session;
14
15 use crate::{
16 AppCaches, Integrations,
17 auth::{MaybeUserVerified, SessionUser},
18 config::Config,
19 db::{self, Slug},
20 error::{AppError, Result},
21 helpers::get_csrf_token,
22 };
23
24 use super::pages::public::content;
25
26 /// Extract the hostname from the request headers (without port).
27 fn extract_host(headers: &HeaderMap) -> Option<String> {
28 headers
29 .get(axum::http::header::HOST)
30 .and_then(|v| v.to_str().ok())
31 .map(|h| {
32 // Strip port if present
33 h.split(':').next().unwrap_or(h).to_lowercase()
34 })
35 }
36
37 /// Check if a hostname belongs to MNW (not a custom domain).
38 fn is_mnw_domain(host: &str) -> bool {
39 host == "makenot.work"
40 || host.ends_with(".makenot.work")
41 || host == "makenotwork.com"
42 || host.ends_with(".makenotwork.com")
43 || host == "localhost"
44 || host == "127.0.0.1"
45 }
46
47 /// Check if a request is for a custom domain and handle it.
48 ///
49 /// Returns `Some(Response)` if the Host header matches a verified custom domain
50 /// and the path was successfully routed. Returns `None` if the request is for
51 /// an MNW domain or no custom domain matches (caller should proceed normally).
52 ///
53 /// Called from named route handlers (e.g. `landing::index` for `/`) where the
54 /// fallback handler wouldn't fire because the route is already matched.
55 #[allow(clippy::too_many_arguments)]
56 pub async fn try_handle(
57 db: &PgPool,
58 caches: &AppCaches,
59 integrations: &Integrations,
60 config: &Config,
61 headers: &HeaderMap,
62 path: &str,
63 session: &Session,
64 maybe_user: Option<&SessionUser>,
65 ) -> Option<Response> {
66 let host = extract_host(headers)?;
67
68 if is_mnw_domain(&host) {
69 return None;
70 }
71
72 let user_id = caches.domain_cache.get(&host).map(|e| *e.value())?;
73
74 let segments: Vec<&str> = path
75 .trim_start_matches('/')
76 .split('/')
77 .filter(|s| !s.is_empty())
78 .collect();
79
80 let result = match segments.as_slice() {
81 [] => render_user_profile(db, config, user_id, session, maybe_user).await,
82 [project_slug] => {
83 render_project(db, config, user_id, project_slug, session, maybe_user).await
84 }
85 [project_slug, item_slug] => {
86 render_item(
87 db,
88 integrations,
89 config,
90 user_id,
91 project_slug,
92 item_slug,
93 session,
94 maybe_user,
95 )
96 .await
97 }
98 _ => return Some(AppError::NotFound.into_response()),
99 };
100
101 Some(match result {
102 Ok(response) => response,
103 // A genuine miss is a clean 404; a DB/render error must NOT masquerade as
104 // "not found" (that made custom-domain outages look like missing pages
105 // with no signal, audit Run 13 Obs). Log it and let the AppError
106 // responder pick the real status (500 for infra errors).
107 Err(AppError::NotFound) => AppError::NotFound.into_response(),
108 Err(e) => {
109 tracing::error!(error = ?e, host = %host, "custom domain render failed");
110 e.into_response()
111 }
112 })
113 }
114
115 /// Fallback handler for custom domain routing.
116 ///
117 /// If the Host header matches a verified custom domain, routes:
118 /// - `/` → user profile
119 /// - `/{project-slug}` → project page
120 /// - `/{project-slug}/{item-slug}` → item page
121 ///
122 /// MNW domains get a standard 404.
123 #[tracing::instrument(skip_all, name = "custom_domain::fallback")]
124 #[allow(clippy::too_many_arguments)]
125 pub async fn custom_domain_fallback(
126 State(db): State<PgPool>,
127 State(caches): State<AppCaches>,
128 State(integrations): State<Integrations>,
129 State(config): State<Config>,
130 headers: HeaderMap,
131 uri: Uri,
132 session: Session,
133 MaybeUserVerified(maybe_user): MaybeUserVerified,
134 ) -> Response {
135 let Some(host) = extract_host(&headers) else {
136 return AppError::NotFound.into_response();
137 };
138
139 // MNW domains fall through to the app's own branded 404. It must carry a
140 // body: Caddy's `handle_errors` fires only on errors Caddy itself generates,
141 // never on a 4xx that a healthy upstream returned, so a bare
142 // `StatusCode::NOT_FOUND` here reached the visitor as an empty page.
143 // `AppError::NotFound` renders the branded template, and `json_error_layer`
144 // still swaps it for JSON on /api routes.
145 if is_mnw_domain(&host) {
146 return AppError::NotFound.into_response();
147 }
148
149 // Look up custom domain
150 let user_id = match caches.domain_cache.get(&host) {
151 Some(entry) => *entry.value(),
152 None => return AppError::NotFound.into_response(),
153 };
154
155 // Route by path segments
156 let path = uri.path().trim_start_matches('/');
157 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
158
159 let result = match segments.as_slice() {
160 [] => render_user_profile(&db, &config, user_id, &session, maybe_user.as_ref()).await,
161 [project_slug] => {
162 render_project(
163 &db,
164 &config,
165 user_id,
166 project_slug,
167 &session,
168 maybe_user.as_ref(),
169 )
170 .await
171 }
172 [project_slug, item_slug] => {
173 render_item(
174 &db,
175 &integrations,
176 &config,
177 user_id,
178 project_slug,
179 item_slug,
180 &session,
181 maybe_user.as_ref(),
182 )
183 .await
184 }
185 _ => return AppError::NotFound.into_response(),
186 };
187
188 match result {
189 Ok(response) => response,
190 // Same split as in `try_handle` above: a genuine miss is a clean 404,
191 // anything else keeps its real status.
192 Err(AppError::NotFound) => AppError::NotFound.into_response(),
193 Err(e) => {
194 tracing::error!(error = ?e, host = %host, "custom domain render failed");
195 e.into_response()
196 }
197 }
198 }
199
200 /// Render a user profile for a custom domain.
201 async fn render_user_profile(
202 db: &PgPool,
203 config: &Config,
204 user_id: db::UserId,
205 session: &Session,
206 maybe_user: Option<&SessionUser>,
207 ) -> Result<Response> {
208 let csrf_token = get_csrf_token(session).await;
209 let db_user = db::users::get_user_by_id(db, user_id)
210 .await?
211 .ok_or(AppError::NotFound)?;
212 content::render_user_profile(db, config, &db_user, csrf_token, maybe_user.cloned()).await
213 }
214
215 /// Render a project page for a custom domain (scoped to user_id + slug).
216 async fn render_project(
217 db: &PgPool,
218 config: &Config,
219 user_id: db::UserId,
220 project_slug: &str,
221 session: &Session,
222 maybe_user: Option<&SessionUser>,
223 ) -> Result<Response> {
224 let csrf_token = get_csrf_token(session).await;
225 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
226 let db_project = db::projects::get_public_project_by_user_and_slug(db, user_id, &slug)
227 .await?
228 .ok_or(AppError::NotFound)?;
229 content::render_project_page(db, config, &db_project, csrf_token, maybe_user.cloned()).await
230 }
231
232 /// Render an item page for a custom domain (scoped to user_id + project slug + item slug).
233 #[allow(clippy::too_many_arguments)]
234 async fn render_item(
235 db: &PgPool,
236 integrations: &Integrations,
237 config: &Config,
238 user_id: db::UserId,
239 project_slug: &str,
240 item_slug: &str,
241 session: &Session,
242 maybe_user: Option<&SessionUser>,
243 ) -> Result<Response> {
244 let csrf_token = get_csrf_token(session).await;
245 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
246 let db_project = db::projects::get_public_project_by_user_and_slug(db, user_id, &slug)
247 .await?
248 .ok_or(AppError::NotFound)?;
249 let db_item = db::items::get_item_by_project_and_slug(db, db_project.id, item_slug)
250 .await?
251 .ok_or(AppError::NotFound)?;
252 let db_user = db::users::get_user_by_id(db, db_project.user_id)
253 .await?
254 .ok_or(AppError::NotFound)?;
255 content::render_item_page(
256 db,
257 integrations,
258 config,
259 &db_item,
260 &db_project,
261 &db_user,
262 csrf_token,
263 maybe_user.cloned(),
264 )
265 .await
266 }
267
268 #[cfg(test)]
269 mod tests {
270 use super::*;
271
272 #[test]
273 fn extract_host_simple() {
274 let mut headers = HeaderMap::new();
275 headers.insert(axum::http::header::HOST, "example.com".parse().unwrap());
276 assert_eq!(extract_host(&headers), Some("example.com".to_string()));
277 }
278
279 #[test]
280 fn extract_host_with_port() {
281 let mut headers = HeaderMap::new();
282 headers.insert(axum::http::header::HOST, "example.com:443".parse().unwrap());
283 assert_eq!(extract_host(&headers), Some("example.com".to_string()));
284 }
285
286 #[test]
287 fn extract_host_uppercase() {
288 let mut headers = HeaderMap::new();
289 headers.insert(axum::http::header::HOST, "Example.COM".parse().unwrap());
290 assert_eq!(extract_host(&headers), Some("example.com".to_string()));
291 }
292
293 #[test]
294 fn is_mnw_domain_true() {
295 assert!(is_mnw_domain("makenot.work"));
296 assert!(is_mnw_domain("forums.makenot.work"));
297 assert!(is_mnw_domain("cdn.makenot.work"));
298 assert!(is_mnw_domain("localhost"));
299 assert!(is_mnw_domain("127.0.0.1"));
300 assert!(is_mnw_domain("makenotwork.com"));
301 }
302
303 #[test]
304 fn is_mnw_domain_false() {
305 assert!(!is_mnw_domain("example.com"));
306 assert!(!is_mnw_domain("mycreations.com"));
307 assert!(!is_mnw_domain("not-makenot.work"));
308 }
309 }
310