use axum::extract::{Path, Query}; use axum::http::{HeaderMap, StatusCode}; use axum::response::Html; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; use serde::Deserialize; use serde_json::{Value, json}; use sqlx::PgPool; use uuid::Uuid; use crate::shell::{escape_html, html_shell}; use crate::web::model::{ CreateMailPageRequest, CreateWebPageRequest, CreateWebPageResponse, NewWebPage, WebPage, }; use crate::web::repository::WebPageRepository; type ApiError = (StatusCode, Json); type HtmlError = (StatusCode, Html); #[derive(Clone)] pub struct WebRouteState { pub db: PgPool, } impl WebRouteState { pub fn new(db: PgPool) -> Self { Self { db } } } #[derive(Debug, Deserialize)] pub struct PageListQuery { pub page_type: Option, pub page: Option, pub per_page: Option, } pub fn routes(db: PgPool) -> Router where S: Clone + Send + Sync + 'static, { Router::new() // JSON API(内部调用用) .route("/v1/web/pages", get(list_pages).post(create_page)) .route("/v1/web/mail", post(create_mail_page)) .route("/v1/web/pages/{page_type}/{slug}", get(get_page)) .route("/v1/web/mail/{slug}", get(get_mail_page)) // HTML 页面(用户浏览用) .route("/web/{page_type}/{slug}", get(show_typed_page)) .route("/mail/{slug}", get(show_mail_page)) .layer(Extension(WebRouteState::new(db))) } // ── HTML 页面 ──────────────────────────────────────────── async fn show_mail_page( Extension(state): Extension, Path(slug): Path, ) -> Result, HtmlError> { let page = WebPageRepository::find_by_type_and_slug(&state.db, "mail", &slug) .await .map_err(html_internal_error)? .ok_or_else(html_not_found)?; Ok(Html(render_page_html(&page))) } async fn show_typed_page( Extension(state): Extension, Path((page_type, slug)): Path<(String, String)>, ) -> Result, HtmlError> { let page_type = normalize_key(&page_type, "page_type").map_err(|_| html_not_found())?; let page = WebPageRepository::find_by_type_and_slug(&state.db, &page_type, &slug) .await .map_err(html_internal_error)? .ok_or_else(html_not_found)?; Ok(Html(render_page_html(&page))) } fn render_page_html(page: &WebPage) -> String { let summary = page .summary .as_deref() .map(|s| { format!( r#"

{}

"#, escape_html(s) ) }) .unwrap_or_default(); let source = page .source_label .as_deref() .map(|s| { format!( r#"来源:{}"#, escape_html(s) ) }) .unwrap_or_default(); let created = page .created_at .map(|t| { format!( "创建时间:{}", t.format("%Y-%m-%d %H:%M:%S") ) }) .unwrap_or_default(); let metadata_html = render_metadata_html(&page.metadata); let content_html = render_text_content(&page.content); let body = format!( r#"{page_type}

{title}

{summary}
{source}{created}
{metadata_html}
{content_html}
"#, page_type = escape_html(&page.page_type), title = escape_html(&page.title), summary = summary, source = source, created = created, metadata_html = metadata_html, content_html = content_html, ); html_shell(&page.title, &body) } fn render_metadata_html(metadata: &str) -> String { let Ok(Value::Object(values)) = serde_json::from_str::(metadata) else { return String::new(); }; let items: String = values .into_iter() .filter_map(|(key, value)| { let val = metadata_value_to_text(value)?; Some(format!( r#"
{}
{}
"#, escape_html(metadata_label(&key)), escape_html(&val), )) }) .collect(); if items.is_empty() { String::new() } else { format!( r#"
{items}
"# ) } } fn metadata_label(key: &str) -> &str { match key { "from" => "发件人", "subject" => "主题", "message_id" => "邮件 ID", "mailbox" => "邮箱目录", "uid" => "UID", "reminder" => "提醒", "received_at" => "收件时间", other => other, } } fn metadata_value_to_text(value: Value) -> Option { match value { Value::Null => None, Value::String(s) => { let s = s.trim().to_string(); (!s.is_empty()).then_some(s) } Value::Array(v) if v.is_empty() => None, Value::Object(v) if v.is_empty() => None, other => Some(other.to_string()), } } fn render_text_content(content: &str) -> String { content .lines() .map(|line| { if line.trim().is_empty() { "
".to_string() } else { escape_html(line) } }) .collect::>() .join("
\n") } // ── JSON API ───────────────────────────────────────────── async fn create_page( Extension(state): Extension, headers: HeaderMap, Json(request): Json, ) -> Result, ApiError> { let page_type = normalize_key(request.page_type.as_deref().unwrap_or("page"), "page_type")?; let title = required_text(&request.title, "title")?; let content = required_text(&request.content, "content")?; let metadata = request.metadata.unwrap_or_else(|| json!({})).to_string(); let page = NewWebPage { slug: generate_slug(), page_type, title, summary: optional_text(request.summary), content, source_label: optional_text(request.source_label), metadata, }; let page = WebPageRepository::create(&state.db, page) .await .map_err(api_internal_error)?; Ok(Json(response_for_page(&page, &headers))) } async fn create_mail_page( Extension(state): Extension, headers: HeaderMap, Json(request): Json, ) -> Result, ApiError> { let from = required_text(&request.from, "from")?; let subject = required_text(&request.subject, "subject")?; let content = required_text(&request.content, "content")?; let reminder = optional_text(request.reminder); let received_at = optional_text(request.received_at); let summary = reminder .clone() .unwrap_or_else(|| format!("您收到了一份来自 {from} 的邮件提醒")); let metadata = json!({ "from": from, "subject": subject, "reminder": reminder, "received_at": received_at, }) .to_string(); let page = NewWebPage { slug: generate_slug(), page_type: "mail".to_string(), title: subject, summary: Some(summary), content, source_label: Some(from), metadata, }; let page = WebPageRepository::create(&state.db, page) .await .map_err(api_internal_error)?; Ok(Json(response_for_page(&page, &headers))) } async fn list_pages( Extension(state): Extension, Query(query): Query, ) -> Result, ApiError> { let page_type = query .page_type .map(|v| normalize_key(&v, "page_type")) .transpose()?; let per_page = query.per_page.unwrap_or(20).clamp(1, 100); let page = query.page.unwrap_or(1).max(1); let offset = (page - 1) * per_page; let (pages, total) = WebPageRepository::list_pages(&state.db, page_type.as_deref(), per_page, offset) .await .map_err(api_internal_error)?; Ok(Json(json!({ "success": true, "page": page, "per_page": per_page, "total": total, "pages": pages, }))) } async fn get_page( Extension(state): Extension, Path((page_type, slug)): Path<(String, String)>, ) -> Result, ApiError> { let page_type = normalize_key(&page_type, "page_type").map_err(|_| api_not_found("页面不存在"))?; let page = WebPageRepository::find_by_type_and_slug(&state.db, &page_type, &slug) .await .map_err(api_internal_error)? .ok_or_else(|| api_not_found("页面不存在"))?; Ok(Json(json!({ "success": true, "page": &page, }))) } async fn get_mail_page( Extension(state): Extension, Path(slug): Path, ) -> Result, ApiError> { let page = WebPageRepository::find_by_type_and_slug(&state.db, "mail", &slug) .await .map_err(api_internal_error)? .ok_or_else(|| api_not_found("页面不存在"))?; Ok(Json(json!({ "success": true, "page": &page, }))) } // ── 辅助函数 ───────────────────────────────────────────── fn response_for_page(page: &WebPage, headers: &HeaderMap) -> CreateWebPageResponse { let path = public_path(page); let url = format!("{}{}", request_base_url(headers), path); CreateWebPageResponse { success: true, id: page.id, slug: page.slug.clone(), page_type: page.page_type.clone(), title: page.title.clone(), summary: page.summary.clone(), path, url, } } pub fn public_path(page: &WebPage) -> String { if page.page_type == "mail" { format!("/mail/{}", page.slug) } else { format!("/web/{}/{}", page.page_type, page.slug) } } pub fn public_url_for_path(path: &str) -> String { format!("{}{}", configured_base_url(), path) } pub fn internal_url_for_path(path: &str) -> String { format!("{}{}", configured_internal_url(), path) } fn request_base_url(headers: &HeaderMap) -> String { if let Some(base_url) = configured_base_url_from_env() { return base_url; } let host = headers .get("host") .and_then(|value| value.to_str().ok()) .filter(|value| !value.trim().is_empty()) .unwrap_or("127.0.0.1:9003"); let proto = headers .get("x-forwarded-proto") .and_then(|value| value.to_str().ok()) .filter(|value| !value.trim().is_empty()) .unwrap_or("http"); format!("{}://{}", proto.trim(), host.trim()).replace("0.0.0.0", "127.0.0.1") } fn configured_base_url() -> String { configured_base_url_from_env().unwrap_or_else(|| "http://127.0.0.1:9003".to_string()) } fn configured_internal_url() -> String { std::env::var("IA_WEB_INTERNAL_URL") .ok() .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()) .map(|value| value.replace("0.0.0.0", "127.0.0.1")) .unwrap_or_else(|| configured_base_url()) } fn configured_base_url_from_env() -> Option { std::env::var("IA_WEB_BASE_URL") .or_else(|_| std::env::var("WEB_BASE_URL")) .or_else(|_| std::env::var("HOST").map(|host| format!("http://{host}"))) .ok() .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()) .map(|value| value.replace("0.0.0.0", "127.0.0.1")) } pub fn generate_slug() -> String { Uuid::new_v4().simple().to_string() } fn required_text(value: &str, field: &str) -> Result { let value = value.trim(); if value.is_empty() { return Err(api_bad_request(format!("{field} 不能为空"))); } Ok(value.to_string()) } fn optional_text(value: Option) -> Option { value .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn normalize_key(value: &str, field: &str) -> Result { let normalized = value.trim().to_ascii_lowercase(); if normalized.is_empty() || normalized.len() > 64 { return Err(api_bad_request(format!("{field} 长度必须在 1 到 64 之间"))); } if !normalized .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') { return Err(api_bad_request(format!( "{field} 只能包含字母、数字、下划线或连字符" ))); } Ok(normalized) } fn api_bad_request(message: impl Into) -> ApiError { ( StatusCode::BAD_REQUEST, Json(json!({ "success": false, "message": message.into(), })), ) } fn api_not_found(message: impl Into) -> ApiError { ( StatusCode::NOT_FOUND, Json(json!({ "success": false, "message": message.into(), })), ) } fn api_internal_error(error: anyhow::Error) -> ApiError { eprintln!("web 接口失败: {error}"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "success": false, "message": "web 接口失败", })), ) } fn html_not_found() -> HtmlError { ( StatusCode::NOT_FOUND, Html(html_shell( "页面不存在", r#"

页面不存在

没有找到对应的内容页面

"#, )), ) } fn html_internal_error(error: anyhow::Error) -> HtmlError { eprintln!("读取 web 页面失败: {error}"); ( StatusCode::INTERNAL_SERVER_ERROR, Html(html_shell( "页面暂时不可用", r#"

页面暂时不可用

读取页面内容失败,请稍后重试

"#, )), ) } #[cfg(test)] mod tests { use super::*; #[test] fn html_not_found_returns_html_response() { let (status, Html(body)) = html_not_found(); assert_eq!(status, StatusCode::NOT_FOUND); assert!(body.contains("")); assert!(body.contains("页面不存在")); } }