feat: 模块统一 ias- 前缀、独立版本与内网地址支持 (0.1.7)
- 所有 Rust crate 统一加 ias- 前缀:ai → ias-ai、common → ias-common、context → ias-context、mail → ias-mail、service → ias-service、wechat → ias-wechat - 新增 ias-main 模块承载 CLI 入口和二进制打包 - 各模块开始独立维护版本号,ias-main 0.1.7 代表整体版本 - 新增 IA_WEB_INTERNAL_URL 环境变量,版本通知同时发送公网和内网链接 - 将已有升级日志翻译为中文 - ias_upgrade_logs 新增 modules JSONB 字段 - 新增 VERSION 文件和 CHANGELOG.md - 新增 AGENTS.md 项目规则文件
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
use axum::extract::Path;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::Html;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes<S>(db: PgPool) -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/web/pages", post(create_page))
|
||||
.route("/web/mail", post(create_mail_page))
|
||||
.route("/mail/{slug}", get(show_mail_page))
|
||||
.route("/web/{page_type}/{slug}", get(show_typed_page))
|
||||
.layer(Extension(WebRouteState::new(db)))
|
||||
}
|
||||
|
||||
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 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(&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(&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 render_page(page: &WebPage) -> String {
|
||||
let summary = page
|
||||
.summary
|
||||
.as_deref()
|
||||
.map(|summary| format!(r#"<p class="summary">{}</p>"#, escape_html(summary)))
|
||||
.unwrap_or_default();
|
||||
let source = page
|
||||
.source_label
|
||||
.as_deref()
|
||||
.map(|source| {
|
||||
format!(
|
||||
r#"<span class="meta-item">来源:{}</span>"#,
|
||||
escape_html(source)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let created_at = page
|
||||
.created_at
|
||||
.map(|time| time.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let metadata = render_metadata(&page.metadata);
|
||||
let content = render_text_content(&page.content);
|
||||
let page_type = escape_html(&page.page_type);
|
||||
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #1f2328;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f6f7f9;
|
||||
}}
|
||||
main {{
|
||||
width: min(880px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 40px 0 56px;
|
||||
}}
|
||||
.eyebrow {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #57606a;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}}
|
||||
h1 {{
|
||||
margin: 18px 0 10px;
|
||||
font-size: 32px;
|
||||
line-height: 1.18;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0;
|
||||
}}
|
||||
.summary {{
|
||||
margin: 0 0 18px;
|
||||
color: #57606a;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
.meta {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 0 0 28px;
|
||||
color: #6e7781;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.content {{
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 24px;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
.details {{
|
||||
margin: 24px 0;
|
||||
border-top: 1px solid #d8dee4;
|
||||
border-bottom: 1px solid #d8dee4;
|
||||
padding: 14px 0;
|
||||
}}
|
||||
dl {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(88px, 160px) 1fr;
|
||||
gap: 10px 16px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
dt {{
|
||||
color: #6e7781;
|
||||
}}
|
||||
dd {{
|
||||
margin: 0;
|
||||
color: #24292f;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
@media (max-width: 640px) {{
|
||||
main {{
|
||||
width: min(100% - 24px, 880px);
|
||||
padding-top: 28px;
|
||||
}}
|
||||
.content {{
|
||||
padding: 18px;
|
||||
}}
|
||||
dl {{
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px 0;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<span class="eyebrow">{page_type}</span>
|
||||
<h1>{title}</h1>
|
||||
{summary}
|
||||
<div class="meta">{source}<span class="meta-item">创建时间:{created_at}</span></div>
|
||||
{metadata}
|
||||
<article class="content">{content}</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(&page.title),
|
||||
summary = summary,
|
||||
source = source,
|
||||
created_at = escape_html(&created_at),
|
||||
metadata = metadata,
|
||||
content = content,
|
||||
page_type = page_type
|
||||
)
|
||||
}
|
||||
|
||||
fn render_metadata(metadata: &str) -> String {
|
||||
let Ok(Value::Object(values)) = serde_json::from_str::<Value>(metadata) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let items = values
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let value = metadata_value_to_text(value)?;
|
||||
Some(format!(
|
||||
"<dt>{}</dt><dd>{}</dd>",
|
||||
escape_html(metadata_label(&key)),
|
||||
escape_html(&value)
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
if items.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(r#"<section class="details"><dl>{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(value) => {
|
||||
let value = value.trim().to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
Value::Array(values) if values.is_empty() => None,
|
||||
Value::Object(values) if values.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")
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
fn api_bad_request(message: impl Into<String>) -> ApiError {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
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(render_error_page("页面不存在", "没有找到对应的内容页面")),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_internal_error(error: anyhow::Error) -> HtmlError {
|
||||
eprintln!("读取 web 页面失败: {error}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(render_error_page("页面暂时不可用", "读取页面内容失败")),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_error_page(title: &str, message: &str) -> String {
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328;">
|
||||
<main style="width:min(760px,calc(100% - 32px));margin:0 auto;padding:48px 0;">
|
||||
<h1 style="font-size:32px;line-height:1.2;margin:0 0 12px;">{title}</h1>
|
||||
<p style="font-size:16px;line-height:1.7;margin:0;color:#57606a;">{message}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(title),
|
||||
message = escape_html(message)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user