//! Custom domain fallback handler. //! //! Catches all routes not matched by MNW's named routes and checks the Host //! header to determine if the request is for a custom domain. If so, routes //! to the appropriate user profile, project, or item page. use axum::{ extract::State, http::{HeaderMap, StatusCode, Uri}, response::{IntoResponse, Response}, }; use tower_sessions::Session; use crate::{ auth::{MaybeUserVerified, SessionUser}, db::{self, Slug}, error::{AppError, Result}, helpers::get_csrf_token, AppState, }; use super::pages::public::content; /// Extract the hostname from the request headers (without port). fn extract_host(headers: &HeaderMap) -> Option { headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .map(|h| { // Strip port if present h.split(':').next().unwrap_or(h).to_lowercase() }) } /// Check if a hostname belongs to MNW (not a custom domain). fn is_mnw_domain(host: &str) -> bool { host == "makenot.work" || host.ends_with(".makenot.work") || host == "makenotwork.com" || host.ends_with(".makenotwork.com") || host == "localhost" || host == "127.0.0.1" } /// Check if a request is for a custom domain and handle it. /// /// Returns `Some(Response)` if the Host header matches a verified custom domain /// and the path was successfully routed. Returns `None` if the request is for /// an MNW domain or no custom domain matches (caller should proceed normally). /// /// Called from named route handlers (e.g. `landing::index` for `/`) where the /// fallback handler wouldn't fire because the route is already matched. pub async fn try_handle( state: &AppState, headers: &HeaderMap, path: &str, session: &Session, maybe_user: &Option, ) -> Option { let host = extract_host(headers)?; if is_mnw_domain(&host) { return None; } let user_id = state.domain_cache.get(&host).map(|e| *e.value())?; let segments: Vec<&str> = path .trim_start_matches('/') .split('/') .filter(|s| !s.is_empty()) .collect(); let result = match segments.as_slice() { [] => render_user_profile(state, user_id, session, maybe_user).await, [project_slug] => render_project(state, user_id, project_slug, session, maybe_user).await, [project_slug, item_slug] => { render_item(state, user_id, project_slug, item_slug, session, maybe_user).await } _ => return Some(StatusCode::NOT_FOUND.into_response()), }; Some(match result { Ok(response) => response, Err(_) => StatusCode::NOT_FOUND.into_response(), }) } /// Fallback handler for custom domain routing. /// /// If the Host header matches a verified custom domain, routes: /// - `/` → user profile /// - `/{project-slug}` → project page /// - `/{project-slug}/{item-slug}` → item page /// /// MNW domains get a standard 404. #[tracing::instrument(skip_all, name = "custom_domain::fallback")] pub async fn custom_domain_fallback( State(state): State, headers: HeaderMap, uri: Uri, session: Session, MaybeUserVerified(maybe_user): MaybeUserVerified, ) -> Response { let Some(host) = extract_host(&headers) else { return StatusCode::NOT_FOUND.into_response(); }; // MNW domains → standard 404 if is_mnw_domain(&host) { return StatusCode::NOT_FOUND.into_response(); } // Look up custom domain let user_id = match state.domain_cache.get(&host) { Some(entry) => *entry.value(), None => return StatusCode::NOT_FOUND.into_response(), }; // Route by path segments let path = uri.path().trim_start_matches('/'); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let result = match segments.as_slice() { [] => render_user_profile(&state, user_id, &session, &maybe_user).await, [project_slug] => { render_project(&state, user_id, project_slug, &session, &maybe_user).await } [project_slug, item_slug] => { render_item(&state, user_id, project_slug, item_slug, &session, &maybe_user).await } _ => return StatusCode::NOT_FOUND.into_response(), }; match result { Ok(response) => response, Err(_) => StatusCode::NOT_FOUND.into_response(), } } /// Render a user profile for a custom domain. async fn render_user_profile( state: &AppState, user_id: db::UserId, session: &Session, maybe_user: &Option, ) -> Result { let csrf_token = get_csrf_token(session).await; let db_user = db::users::get_user_by_id(&state.db, user_id) .await? .ok_or(AppError::NotFound)?; content::render_user_profile(state, &db_user, csrf_token, maybe_user.clone()).await } /// Render a project page for a custom domain (scoped to user_id + slug). async fn render_project( state: &AppState, user_id: db::UserId, project_slug: &str, session: &Session, maybe_user: &Option, ) -> Result { let csrf_token = get_csrf_token(session).await; let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_public_project_by_user_and_slug(&state.db, user_id, &slug) .await? .ok_or(AppError::NotFound)?; content::render_project_page(state, &db_project, csrf_token, maybe_user.clone()).await } /// Render an item page for a custom domain (scoped to user_id + project slug + item slug). async fn render_item( state: &AppState, user_id: db::UserId, project_slug: &str, item_slug: &str, session: &Session, maybe_user: &Option, ) -> Result { let csrf_token = get_csrf_token(session).await; let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_public_project_by_user_and_slug(&state.db, user_id, &slug) .await? .ok_or(AppError::NotFound)?; let db_item = db::items::get_item_by_project_and_slug(&state.db, db_project.id, item_slug) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; content::render_item_page(state, &db_item, &db_project, &db_user, csrf_token, maybe_user.clone()).await } #[cfg(test)] mod tests { use super::*; #[test] fn extract_host_simple() { let mut headers = HeaderMap::new(); headers.insert( axum::http::header::HOST, "example.com".parse().unwrap(), ); assert_eq!(extract_host(&headers), Some("example.com".to_string())); } #[test] fn extract_host_with_port() { let mut headers = HeaderMap::new(); headers.insert( axum::http::header::HOST, "example.com:443".parse().unwrap(), ); assert_eq!(extract_host(&headers), Some("example.com".to_string())); } #[test] fn extract_host_uppercase() { let mut headers = HeaderMap::new(); headers.insert( axum::http::header::HOST, "Example.COM".parse().unwrap(), ); assert_eq!(extract_host(&headers), Some("example.com".to_string())); } #[test] fn is_mnw_domain_true() { assert!(is_mnw_domain("makenot.work")); assert!(is_mnw_domain("forums.makenot.work")); assert!(is_mnw_domain("cdn.makenot.work")); assert!(is_mnw_domain("localhost")); assert!(is_mnw_domain("127.0.0.1")); assert!(is_mnw_domain("makenotwork.com")); } #[test] fn is_mnw_domain_false() { assert!(!is_mnw_domain("example.com")); assert!(!is_mnw_domain("mycreations.com")); assert!(!is_mnw_domain("not-makenot.work")); } }