3d77733e11
- 邮件列表/详情页显示已读/未读状态,支持按状态筛选 - 邮件 API GET /v1/mail/messages 支持 seen 过滤 - 数据库 ias_mail_messages 新增 seen 字段 - 新增邮件查询 YAML 工具 query_mail_messages - 新增 /tools 和 /configs Web 页面 - 新增 ias-http shell 和 ias-service console 模块 - 补充历史版本升级日志迁移 SQL
505 lines
15 KiB
Rust
505 lines
15 KiB
Rust
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<Value>);
|
|
type HtmlError = (StatusCode, Html<String>);
|
|
|
|
#[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<String>,
|
|
pub page: Option<i64>,
|
|
pub per_page: Option<i64>,
|
|
}
|
|
|
|
pub fn routes<S>(db: PgPool) -> Router<S>
|
|
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<WebRouteState>,
|
|
Path(slug): Path<String>,
|
|
) -> Result<Html<String>, 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<WebRouteState>,
|
|
Path((page_type, slug)): Path<(String, String)>,
|
|
) -> Result<Html<String>, 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#"<p class="text-gray-500 text-base mt-1.5">{}</p>"#,
|
|
escape_html(s)
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let source = page
|
|
.source_label
|
|
.as_deref()
|
|
.map(|s| {
|
|
format!(
|
|
r#"<span class="text-gray-400 text-sm">来源:{}</span>"#,
|
|
escape_html(s)
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let created = page
|
|
.created_at
|
|
.map(|t| {
|
|
format!(
|
|
"<span class=\"text-gray-400 text-sm\">创建时间:{}</span>",
|
|
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#"<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 mb-3">{page_type}</span>
|
|
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
|
|
{summary}
|
|
<div class="flex flex-wrap gap-x-5 gap-y-1 mt-3 mb-6">{source}{created}</div>
|
|
{metadata_html}
|
|
<article class="bg-white rounded-lg border border-gray-200 p-6 text-base leading-relaxed whitespace-normal break-words">{content_html}</article>"#,
|
|
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::<Value>(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#"<dt class="text-gray-400 text-sm">{}</dt><dd class="text-gray-700 text-sm break-all">{}</dd>"#,
|
|
escape_html(metadata_label(&key)),
|
|
escape_html(&val),
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
if items.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
r#"<section class="bg-white rounded-lg border border-gray-200 mb-5">
|
|
<dl class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 p-4">{items}</dl>
|
|
</section>"#
|
|
)
|
|
}
|
|
}
|
|
|
|
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<String> {
|
|
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() {
|
|
"<br>".to_string()
|
|
} else {
|
|
escape_html(line)
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("<br>\n")
|
|
}
|
|
|
|
// ── JSON API ─────────────────────────────────────────────
|
|
|
|
async fn create_page(
|
|
Extension(state): Extension<WebRouteState>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<CreateWebPageRequest>,
|
|
) -> Result<Json<CreateWebPageResponse>, 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<WebRouteState>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<CreateMailPageRequest>,
|
|
) -> Result<Json<CreateWebPageResponse>, 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<WebRouteState>,
|
|
Query(query): Query<PageListQuery>,
|
|
) -> Result<Json<Value>, 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<WebRouteState>,
|
|
Path((page_type, slug)): Path<(String, String)>,
|
|
) -> Result<Json<Value>, 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<WebRouteState>,
|
|
Path(slug): Path<String>,
|
|
) -> Result<Json<Value>, 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<String> {
|
|
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<String, ApiError> {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return Err(api_bad_request(format!("{field} 不能为空")));
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
fn optional_text(value: Option<String>) -> Option<String> {
|
|
value
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn normalize_key(value: &str, field: &str) -> Result<String, ApiError> {
|
|
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<String>) -> ApiError {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({
|
|
"success": false,
|
|
"message": message.into(),
|
|
})),
|
|
)
|
|
}
|
|
|
|
fn api_not_found(message: impl Into<String>) -> 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#"<h1 class="text-2xl font-bold">页面不存在</h1><p class="mt-2 text-gray-500">没有找到对应的内容页面</p>"#,
|
|
)),
|
|
)
|
|
}
|
|
|
|
fn html_internal_error(error: anyhow::Error) -> HtmlError {
|
|
eprintln!("读取 web 页面失败: {error}");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Html(html_shell(
|
|
"页面暂时不可用",
|
|
r#"<h1 class="text-2xl font-bold">页面暂时不可用</h1><p class="mt-2 text-gray-500">读取页面内容失败,请稍后重试</p>"#,
|
|
)),
|
|
)
|
|
}
|
|
|
|
#[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("<!doctype html>"));
|
|
assert!(body.contains("页面不存在"));
|
|
}
|
|
}
|