style: 全项目格式规范化(cargo fmt)+ Docker daemon 断连容错
- 全项目 .rs 统一 cargo fmt 排版(换行/缩进/参数分行/导入排序) - infra/docker: DOCKER_CLIENT 连接失败不 panic,降级返回 None (博客不依赖 Docker,panic=abort 下会导致整个进程崩溃) - infra/mod: 去除多余空行 - database/mod: 模块声明按字母序重新排序
This commit is contained in:
parent
71f3351c99
commit
65a7b1226f
6
build.rs
6
build.rs
@ -51,7 +51,11 @@ fn git_output(args: &[&str]) -> Option<String> {
|
|||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
String::from_utf8(out.stdout).ok()?.trim().to_string().into()
|
String::from_utf8(out.stdout)
|
||||||
|
.ok()?
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `rustc --version`,采集编译工具链。
|
/// `rustc --version`,采集编译工具链。
|
||||||
|
|||||||
@ -159,9 +159,7 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
|
|||||||
final_limits,
|
final_limits,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let duration_ms = (chrono::Utc::now() - start_time)
|
let duration_ms = (chrono::Utc::now() - start_time).num_milliseconds().max(0) as u64;
|
||||||
.num_milliseconds()
|
|
||||||
.max(0) as u64;
|
|
||||||
|
|
||||||
drop(ticket); // 显式释放信号量
|
drop(ticket); // 显式释放信号量
|
||||||
|
|
||||||
@ -287,9 +285,7 @@ pub async fn start_exec_stream(req: ExecRequest) -> Result<String, ServerFnError
|
|||||||
tx,
|
tx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let duration_ms = (chrono::Utc::now() - start_time)
|
let duration_ms = (chrono::Utc::now() - start_time).num_milliseconds().max(0) as u64;
|
||||||
.num_milliseconds()
|
|
||||||
.max(0) as u64;
|
|
||||||
|
|
||||||
drop(ticket); // 显式释放信号量
|
drop(ticket); // 显式释放信号量
|
||||||
|
|
||||||
|
|||||||
@ -172,8 +172,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_fence_info_with_overrides() {
|
fn parse_fence_info_with_overrides() {
|
||||||
let (lang, runnable, overrides) =
|
let (lang, runnable, overrides) = parse_fence_info(
|
||||||
parse_fence_info(r#"node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":true,"cpu_cores":1.0,"output_bytes":1024}"#);
|
r#"node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":true,"cpu_cores":1.0,"output_bytes":1024}"#,
|
||||||
|
);
|
||||||
assert_eq!(lang, "node");
|
assert_eq!(lang, "node");
|
||||||
assert!(runnable);
|
assert!(runnable);
|
||||||
let limits = overrides.unwrap();
|
let limits = overrides.unwrap();
|
||||||
|
|||||||
@ -73,10 +73,10 @@ pub struct ExecTask {
|
|||||||
|
|
||||||
// execute.rs 含 server function,需对双目标可见(不能 cfg-gate);其 server-only
|
// execute.rs 含 server function,需对双目标可见(不能 cfg-gate);其 server-only
|
||||||
// 依赖在文件内单独 gate。languages / progress 是纯 server 辅助,整体 gate。
|
// 依赖在文件内单独 gate。languages / progress 是纯 server 辅助,整体 gate。
|
||||||
|
pub mod execute;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod languages;
|
pub mod languages;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod execute;
|
|
||||||
|
|||||||
@ -85,10 +85,7 @@ mod tests {
|
|||||||
let task_id = "test-task-progress-123".to_string();
|
let task_id = "test-task-progress-123".to_string();
|
||||||
insert_task(task_id.clone());
|
insert_task(task_id.clone());
|
||||||
assert!(EXEC_TASKS.contains_key(&task_id));
|
assert!(EXEC_TASKS.contains_key(&task_id));
|
||||||
assert_eq!(
|
assert_eq!(EXEC_TASKS.get(&task_id).unwrap().status, ExecStatus::Queued);
|
||||||
EXEC_TASKS.get(&task_id).unwrap().status,
|
|
||||||
ExecStatus::Queued
|
|
||||||
);
|
|
||||||
|
|
||||||
update_task_stage(&task_id, ExecStatus::Running, "运行中");
|
update_task_stage(&task_id, ExecStatus::Running, "运行中");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -76,13 +76,19 @@ pub async fn create_comment(
|
|||||||
|
|
||||||
match post_row {
|
match post_row {
|
||||||
None => {
|
None => {
|
||||||
return Ok(CommentResponse::error("post_not_found", "文章不存在".to_string()));
|
return Ok(CommentResponse::error(
|
||||||
|
"post_not_found",
|
||||||
|
"文章不存在".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Some(row) => {
|
Some(row) => {
|
||||||
let status: String = row.get("status");
|
let status: String = row.get("status");
|
||||||
let deleted_at: Option<chrono::DateTime<chrono::Utc>> = row.get("deleted_at");
|
let deleted_at: Option<chrono::DateTime<chrono::Utc>> = row.get("deleted_at");
|
||||||
if status != "published" || deleted_at.is_some() {
|
if status != "published" || deleted_at.is_some() {
|
||||||
return Ok(CommentResponse::error("post_not_found", "文章不存在".to_string()));
|
return Ok(CommentResponse::error(
|
||||||
|
"post_not_found",
|
||||||
|
"文章不存在".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,7 +106,10 @@ pub async fn create_comment(
|
|||||||
|
|
||||||
match parent_row {
|
match parent_row {
|
||||||
None => {
|
None => {
|
||||||
return Ok(CommentResponse::error("parent_not_found", "父评论不存在".to_string()));
|
return Ok(CommentResponse::error(
|
||||||
|
"parent_not_found",
|
||||||
|
"父评论不存在".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Some(row) => {
|
Some(row) => {
|
||||||
let parent_post_id: i32 = row.get("post_id");
|
let parent_post_id: i32 = row.get("post_id");
|
||||||
@ -108,15 +117,24 @@ pub async fn create_comment(
|
|||||||
let parent_depth: i32 = row.get("depth");
|
let parent_depth: i32 = row.get("depth");
|
||||||
|
|
||||||
if parent_post_id != post_id {
|
if parent_post_id != post_id {
|
||||||
return Ok(CommentResponse::error("parent_not_found", "父评论不存在".to_string()));
|
return Ok(CommentResponse::error(
|
||||||
|
"parent_not_found",
|
||||||
|
"父评论不存在".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if parent_status != "approved" {
|
if parent_status != "approved" {
|
||||||
return Ok(CommentResponse::error("parent_not_approved", "父评论未通过审核".to_string()));
|
return Ok(CommentResponse::error(
|
||||||
|
"parent_not_approved",
|
||||||
|
"父评论未通过审核".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
depth = parent_depth + 1;
|
depth = parent_depth + 1;
|
||||||
if depth > 20 {
|
if depth > 20 {
|
||||||
return Ok(CommentResponse::error("too_deep", "评论嵌套层级过深".to_string()));
|
return Ok(CommentResponse::error(
|
||||||
|
"too_deep",
|
||||||
|
"评论嵌套层级过深".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -175,7 +193,10 @@ pub async fn create_comment(
|
|||||||
if dup.is_some() {
|
if dup.is_some() {
|
||||||
// 重复:回滚(释放 advisory 锁)后返回。
|
// 重复:回滚(释放 advisory 锁)后返回。
|
||||||
tx.rollback().await.ok();
|
tx.rollback().await.ok();
|
||||||
return Ok(CommentResponse::error("duplicate", "请勿重复提交".to_string()));
|
return Ok(CommentResponse::error(
|
||||||
|
"duplicate",
|
||||||
|
"请勿重复提交".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 插入评论,默认状态为 pending,等待管理员审核。
|
// 插入评论,默认状态为 pending,等待管理员审核。
|
||||||
@ -214,7 +235,12 @@ pub async fn create_comment(
|
|||||||
cache::invalidate_comments_by_post(post_id).await;
|
cache::invalidate_comments_by_post(post_id).await;
|
||||||
cache::invalidate_pending_count().await;
|
cache::invalidate_pending_count().await;
|
||||||
|
|
||||||
Ok(CommentResponse::created("评论已提交,等待审核".to_string(), comment_id, avatar_url, depth))
|
Ok(CommentResponse::created(
|
||||||
|
"评论已提交,等待审核".to_string(),
|
||||||
|
comment_id,
|
||||||
|
avatar_url,
|
||||||
|
depth,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
unreachable!()
|
unreachable!()
|
||||||
|
|||||||
@ -58,7 +58,10 @@ pub fn render_comment_markdown(md: &str) -> String {
|
|||||||
crate::highlight::server::highlight_code(&code_buffer, Some(lang));
|
crate::highlight::server::highlight_code(&code_buffer, Some(lang));
|
||||||
format!("<pre><code>{}</code></pre>", highlighted)
|
format!("<pre><code>{}</code></pre>", highlighted)
|
||||||
} else {
|
} else {
|
||||||
format!("<pre><code>{}</code></pre>", crate::utils::html::escape_html(&code_buffer))
|
format!(
|
||||||
|
"<pre><code>{}</code></pre>",
|
||||||
|
crate::utils::html::escape_html(&code_buffer)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
events.push(Event::Html(html.into()));
|
events.push(Event::Html(html.into()));
|
||||||
in_codeblock = false;
|
in_codeblock = false;
|
||||||
|
|||||||
@ -34,7 +34,10 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
|
|||||||
let post_id: i32 = match row {
|
let post_id: i32 = match row {
|
||||||
Some(r) => r.get("post_id"),
|
Some(r) => r.get("post_id"),
|
||||||
None => {
|
None => {
|
||||||
return Ok(CommentResponse::error("not_found", "评论不存在".to_string()));
|
return Ok(CommentResponse::error(
|
||||||
|
"not_found",
|
||||||
|
"评论不存在".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -363,7 +363,9 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, S
|
|||||||
// 签名校验:首行需含签名
|
// 签名校验:首行需含签名
|
||||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
if !has_valid_signature(&content) {
|
if !has_valid_signature(&content) {
|
||||||
return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into());
|
return Err(
|
||||||
|
AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let task_id = uuid::Uuid::new_v4().to_string();
|
let task_id = uuid::Uuid::new_v4().to_string();
|
||||||
@ -680,10 +682,7 @@ mod tests {
|
|||||||
"a.sql",
|
"a.sql",
|
||||||
"A-B_C.123",
|
"A-B_C.123",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(is_valid_backup_filename(name), "正常文件名应通过: {name}");
|
||||||
is_valid_backup_filename(name),
|
|
||||||
"正常文件名应通过: {name}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -697,10 +696,7 @@ mod tests {
|
|||||||
"a/../../b",
|
"a/../../b",
|
||||||
"backup.sql/../../etc",
|
"backup.sql/../../etc",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(!is_valid_backup_filename(evil), "路径穿越应被拒: {evil}");
|
||||||
!is_valid_backup_filename(evil),
|
|
||||||
"路径穿越应被拒: {evil}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -715,10 +711,7 @@ mod tests {
|
|||||||
"a`b`.sql",
|
"a`b`.sql",
|
||||||
"",
|
"",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(!is_valid_backup_filename(evil), "特殊字符应被拒: {evil:?}");
|
||||||
!is_valid_backup_filename(evil),
|
|
||||||
"特殊字符应被拒: {evil:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -728,7 +721,10 @@ mod tests {
|
|||||||
fn backup_path_stays_in_backup_dir_for_normal_name() {
|
fn backup_path_stays_in_backup_dir_for_normal_name() {
|
||||||
let p = backup_path("backup_20260702.sql");
|
let p = backup_path("backup_20260702.sql");
|
||||||
assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下");
|
assert!(p.starts_with(BACKUP_DIR), "应在 {BACKUP_DIR}/ 下");
|
||||||
assert_eq!(p.file_name().and_then(|n| n.to_str()), Some("backup_20260702.sql"));
|
assert_eq!(
|
||||||
|
p.file_name().and_then(|n| n.to_str()),
|
||||||
|
Some("backup_20260702.sql")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -739,7 +735,8 @@ mod tests {
|
|||||||
let p = backup_path(evil);
|
let p = backup_path(evil);
|
||||||
// 不应逃出 BACKUP_DIR(应为 BACKUP_DIR 本身,不含文件名)
|
// 不应逃出 BACKUP_DIR(应为 BACKUP_DIR 本身,不含文件名)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
p, PathBuf::from(BACKUP_DIR),
|
p,
|
||||||
|
PathBuf::from(BACKUP_DIR),
|
||||||
"穿越应被规约回 {BACKUP_DIR}: {evil}"
|
"穿越应被规约回 {BACKUP_DIR}: {evil}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,9 +107,7 @@ fn parse_source(source: &str) -> Result<(String, String), (StatusCode, String)>
|
|||||||
|
|
||||||
/// 简单标识符校验(字母数字下划线,防 SQL 注入与路径穿越)。
|
/// 简单标识符校验(字母数字下划线,防 SQL 注入与路径穿越)。
|
||||||
fn is_simple_ident(s: &str) -> bool {
|
fn is_simple_ident(s: &str) -> bool {
|
||||||
!s.is_empty()
|
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
&& s.chars()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判断 AST 是否只读(SELECT/EXPLAIN)。
|
/// 判断 AST 是否只读(SELECT/EXPLAIN)。
|
||||||
@ -169,9 +167,7 @@ async fn export_sql(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for r in &rows {
|
for r in &rows {
|
||||||
let vals: Vec<String> = (0..r.len())
|
let vals: Vec<String> = (0..r.len()).map(|i| sql_quote_cell(r, i)).collect();
|
||||||
.map(|i| sql_quote_cell(r, i))
|
|
||||||
.collect();
|
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"INSERT INTO {} {} VALUES ({});\n",
|
"INSERT INTO {} {} VALUES ({});\n",
|
||||||
&table_name,
|
&table_name,
|
||||||
@ -190,7 +186,11 @@ async fn export_sql(
|
|||||||
|
|
||||||
/// 把一个单元格值转成 SQL 字面量(字符串单引号转义,数字原样,NULL)。
|
/// 把一个单元格值转成 SQL 字面量(字符串单引号转义,数字原样,NULL)。
|
||||||
fn sql_quote_cell(row: &tokio_postgres::Row, idx: usize) -> String {
|
fn sql_quote_cell(row: &tokio_postgres::Row, idx: usize) -> String {
|
||||||
let ty = row.columns().get(idx).map(|c| c.type_().name()).unwrap_or("");
|
let ty = row
|
||||||
|
.columns()
|
||||||
|
.get(idx)
|
||||||
|
.map(|c| c.type_().name())
|
||||||
|
.unwrap_or("");
|
||||||
match ty {
|
match ty {
|
||||||
"int2" => row
|
"int2" => row
|
||||||
.try_get::<_, Option<i16>>(idx)
|
.try_get::<_, Option<i16>>(idx)
|
||||||
|
|||||||
@ -6,18 +6,18 @@
|
|||||||
//! 后续 task 会新增:`system_status`(服务器状态)、`sql_console`(SQL 执行+护栏)、
|
//! 后续 task 会新增:`system_status`(服务器状态)、`sql_console`(SQL 执行+护栏)、
|
||||||
//! `schema`(SQL 补全数据)、`export`(流式导出)、`backup`/`tasks`(备份恢复+进度)。
|
//! `schema`(SQL 补全数据)、`export`(流式导出)、`backup`/`tasks`(备份恢复+进度)。
|
||||||
|
|
||||||
|
/// 备份/恢复(双模式 + 任务进度)。
|
||||||
|
pub mod backup;
|
||||||
|
/// 数据导出 Axum 流式处理器(仅 server:纯 Axum 路由,无 WASM 消费者)。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub mod export;
|
||||||
|
/// SQL 补全用 schema 拉取。
|
||||||
|
pub mod schema;
|
||||||
|
/// SQL 控制台执行(全读写 + 4 护栏)。
|
||||||
|
pub mod sql_console;
|
||||||
/// 数据库运行状态聚合查询。
|
/// 数据库运行状态聚合查询。
|
||||||
pub mod status;
|
pub mod status;
|
||||||
/// 服务器状态聚合查询(应用内 + 主机层)。
|
/// 服务器状态聚合查询(应用内 + 主机层)。
|
||||||
pub mod system_status;
|
pub mod system_status;
|
||||||
/// SQL 控制台执行(全读写 + 4 护栏)。
|
|
||||||
pub mod sql_console;
|
|
||||||
/// SQL 补全用 schema 拉取。
|
|
||||||
pub mod schema;
|
|
||||||
/// 数据导出 Axum 流式处理器(仅 server:纯 Axum 路由,无 WASM 消费者)。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub mod export;
|
|
||||||
/// 备份/恢复(双模式 + 任务进度)。
|
|
||||||
pub mod backup;
|
|
||||||
/// 备份/恢复异步任务进度表。
|
/// 备份/恢复异步任务进度表。
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
|
|||||||
@ -112,10 +112,7 @@ enum GuardResult {
|
|||||||
|
|
||||||
/// 护栏 1+2:sqlparser 解析后遍历 AST,检查高危语句与无 WHERE 的 UPDATE/DELETE。
|
/// 护栏 1+2:sqlparser 解析后遍历 AST,检查高危语句与无 WHERE 的 UPDATE/DELETE。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn check_guards(
|
fn check_guards(asts: &[sqlparser::ast::Statement], confirm_dangerous: bool) -> GuardResult {
|
||||||
asts: &[sqlparser::ast::Statement],
|
|
||||||
confirm_dangerous: bool,
|
|
||||||
) -> GuardResult {
|
|
||||||
use sqlparser::ast::{ObjectType, Statement};
|
use sqlparser::ast::{ObjectType, Statement};
|
||||||
|
|
||||||
for stmt in asts {
|
for stmt in asts {
|
||||||
@ -147,13 +144,17 @@ fn check_guards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 护栏 2:UPDATE 无 WHERE
|
// 护栏 2:UPDATE 无 WHERE
|
||||||
Statement::Update(sqlparser::ast::Update { selection: None, .. }) => {
|
Statement::Update(sqlparser::ast::Update {
|
||||||
|
selection: None, ..
|
||||||
|
}) => {
|
||||||
return GuardResult::Forbidden(
|
return GuardResult::Forbidden(
|
||||||
"UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
|
"UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 护栏 2:DELETE 无 WHERE
|
// 护栏 2:DELETE 无 WHERE
|
||||||
Statement::Delete(sqlparser::ast::Delete { selection: None, .. }) => {
|
Statement::Delete(sqlparser::ast::Delete {
|
||||||
|
selection: None, ..
|
||||||
|
}) => {
|
||||||
return GuardResult::Forbidden(
|
return GuardResult::Forbidden(
|
||||||
"DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
|
"DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
|
||||||
);
|
);
|
||||||
@ -187,17 +188,18 @@ fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String {
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
|
fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
|
||||||
use sqlparser::ast::Statement;
|
use sqlparser::ast::Statement;
|
||||||
matches!(
|
matches!(stmt, Statement::Query(_) | Statement::Explain { .. })
|
||||||
stmt,
|
|
||||||
Statement::Query(_) | Statement::Explain { .. }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把一列的值转成 JSON(按 PG 类型名分发)。
|
/// 把一列的值转成 JSON(按 PG 类型名分发)。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
|
fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
let ty = row.columns().get(idx).map(|c| c.type_().name()).unwrap_or("");
|
let ty = row
|
||||||
|
.columns()
|
||||||
|
.get(idx)
|
||||||
|
.map(|c| c.type_().name())
|
||||||
|
.unwrap_or("");
|
||||||
match ty {
|
match ty {
|
||||||
"int2" => row
|
"int2" => row
|
||||||
.try_get::<_, Option<i16>>(idx)
|
.try_get::<_, Option<i16>>(idx)
|
||||||
@ -349,18 +351,10 @@ async fn execute_one(
|
|||||||
|
|
||||||
if read_only {
|
if read_only {
|
||||||
// 只读:取结果集。列名从第一行取(空结果集时无列名,前端容错)。
|
// 只读:取结果集。列名从第一行取(空结果集时无列名,前端容错)。
|
||||||
let rows = client
|
let rows = client.query(stmt_sql, &[]).await.map_err(AppError::query)?;
|
||||||
.query(stmt_sql, &[])
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
let columns: Vec<String> = rows
|
let columns: Vec<String> = rows
|
||||||
.first()
|
.first()
|
||||||
.map(|r| {
|
.map(|r| r.columns().iter().map(|c| c.name().to_string()).collect())
|
||||||
r.columns()
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.name().to_string())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut data: Vec<Vec<serde_json::Value>> = Vec::new();
|
let mut data: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||||
let mut truncated = false;
|
let mut truncated = false;
|
||||||
@ -431,10 +425,7 @@ mod tests {
|
|||||||
"CREATE DATABASE evil",
|
"CREATE DATABASE evil",
|
||||||
"Drop Database x",
|
"Drop Database x",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(is_absolutely_forbidden(sql).is_some(), "应拦截: {sql:?}");
|
||||||
is_absolutely_forbidden(sql).is_some(),
|
|
||||||
"应拦截: {sql:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -458,9 +449,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn precheck_returns_canonical_name_for_error_message() {
|
fn precheck_returns_canonical_name_for_error_message() {
|
||||||
assert_eq!(is_absolutely_forbidden("DROP DATABASE x"), Some("DROP DATABASE"));
|
assert_eq!(
|
||||||
assert_eq!(is_absolutely_forbidden("drop schema public"), Some("DROP SCHEMA"));
|
is_absolutely_forbidden("DROP DATABASE x"),
|
||||||
assert_eq!(is_absolutely_forbidden("CREATE DATABASE evil"), Some("CREATE DATABASE"));
|
Some("DROP DATABASE")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
is_absolutely_forbidden("drop schema public"),
|
||||||
|
Some("DROP SCHEMA")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
is_absolutely_forbidden("CREATE DATABASE evil"),
|
||||||
|
Some("CREATE DATABASE")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -499,10 +499,7 @@ mod tests {
|
|||||||
"DELETE FROM t WHERE id = 1",
|
"DELETE FROM t WHERE id = 1",
|
||||||
"CREATE INDEX idx ON t (col)",
|
"CREATE INDEX idx ON t (col)",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(is_absolutely_forbidden(sql).is_none(), "不应误拦: {sql:?}");
|
||||||
is_absolutely_forbidden(sql).is_none(),
|
|
||||||
"不应误拦: {sql:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -513,10 +510,9 @@ mod tests {
|
|||||||
// 即使字符串预检被某种方式绕过,AST 层仍绝禁 DROP SCHEMA。
|
// 即使字符串预检被某种方式绕过,AST 层仍绝禁 DROP SCHEMA。
|
||||||
let asts = parse("DROP SCHEMA public");
|
let asts = parse("DROP SCHEMA public");
|
||||||
match check_guards(&asts, true) {
|
match check_guards(&asts, true) {
|
||||||
GuardResult::Forbidden(msg) => assert!(
|
GuardResult::Forbidden(msg) => {
|
||||||
msg.contains("SCHEMA"),
|
assert!(msg.contains("SCHEMA"), "DROP SCHEMA 应被禁止, 得到: {msg}")
|
||||||
"DROP SCHEMA 应被禁止, 得到: {msg}"
|
}
|
||||||
),
|
|
||||||
other => panic!("DROP SCHEMA 应 Forbidden, 得到 {other:?}"),
|
other => panic!("DROP SCHEMA 应 Forbidden, 得到 {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,7 +71,10 @@ pub async fn get_server_status() -> Result<ServerStatus, ServerFnError> {
|
|||||||
// 活跃会话数
|
// 活跃会话数
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
let active_sessions: i64 = client
|
let active_sessions: i64 = client
|
||||||
.query_one("SELECT count(*) FROM sessions WHERE expires_at > now()", &[])
|
.query_one(
|
||||||
|
"SELECT count(*) FROM sessions WHERE expires_at > now()",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?
|
.map_err(AppError::query)?
|
||||||
.get(0);
|
.get(0);
|
||||||
|
|||||||
@ -15,9 +15,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::api::auth::get_current_admin_user;
|
use crate::api::auth::get_current_admin_user;
|
||||||
// DashMap / LazyLock 仅 server 构建持有任务进度表;WASM 端只序列化 TaskProgress。
|
// DashMap / LazyLock 仅 server 构建持有任务进度表;WASM 端只序列化 TaskProgress。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use std::sync::LazyLock;
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||||
pub enum TaskKind {
|
pub enum TaskKind {
|
||||||
@ -93,7 +93,9 @@ pub(super) fn update(
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn gc_old() {
|
fn gc_old() {
|
||||||
let cutoff = Utc::now() - chrono::Duration::hours(1);
|
let cutoff = Utc::now() - chrono::Duration::hours(1);
|
||||||
TASKS.retain(|_, p| !(matches!(p.status, TaskStatus::Done | TaskStatus::Failed) && p.created_at < cutoff));
|
TASKS.retain(|_, p| {
|
||||||
|
!(matches!(p.status, TaskStatus::Done | TaskStatus::Failed) && p.created_at < cutoff)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询任务进度(轮询用)。
|
/// 查询任务进度(轮询用)。
|
||||||
@ -103,7 +105,8 @@ pub async fn get_task_progress(task_id: String) -> Result<TaskProgress, ServerFn
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
gc_old();
|
gc_old();
|
||||||
TASKS.get(&task_id)
|
TASKS
|
||||||
|
.get(&task_id)
|
||||||
.map(|p| p.clone())
|
.map(|p| p.clone())
|
||||||
.ok_or_else(|| crate::api::error::AppError::NotFound("任务不存在").into())
|
.ok_or_else(|| crate::api::error::AppError::NotFound("任务不存在").into())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,19 +45,13 @@ impl AppError {
|
|||||||
|
|
||||||
/// 记录并包装 SQL 查询错误。
|
/// 记录并包装 SQL 查询错误。
|
||||||
pub fn query(e: impl std::error::Error) -> Self {
|
pub fn query(e: impl std::error::Error) -> Self {
|
||||||
tracing::error!(
|
tracing::error!("Query failed: {}", crate::db::format_with_sources(&e));
|
||||||
"Query failed: {}",
|
|
||||||
crate::db::format_with_sources(&e)
|
|
||||||
);
|
|
||||||
AppError::Query("query error".to_string())
|
AppError::Query("query error".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录并包装数据库事务错误。
|
/// 记录并包装数据库事务错误。
|
||||||
pub fn tx(e: impl std::error::Error) -> Self {
|
pub fn tx(e: impl std::error::Error) -> Self {
|
||||||
tracing::error!(
|
tracing::error!("Transaction failed: {}", crate::db::format_with_sources(&e));
|
||||||
"Transaction failed: {}",
|
|
||||||
crate::db::format_with_sources(&e)
|
|
||||||
);
|
|
||||||
AppError::Transaction("transaction error".to_string())
|
AppError::Transaction("transaction error".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -475,7 +475,11 @@ mod tests {
|
|||||||
*received.borrow_mut() = p.to_string();
|
*received.borrow_mut() = p.to_string();
|
||||||
Some((100, 100))
|
Some((100, 100))
|
||||||
});
|
});
|
||||||
assert_eq!(received.borrow().as_str(), "2026/x.webp", "rel_path 应去 query");
|
assert_eq!(
|
||||||
|
received.borrow().as_str(),
|
||||||
|
"2026/x.webp",
|
||||||
|
"rel_path 应去 query"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -493,10 +497,7 @@ mod tests {
|
|||||||
fn wrap_images_with_blur_omits_alt_attr_when_empty() {
|
fn wrap_images_with_blur_omits_alt_attr_when_empty() {
|
||||||
let html = r#"<img src="/uploads/x.webp">"#;
|
let html = r#"<img src="/uploads/x.webp">"#;
|
||||||
let result = wrap_images_with_blur_with(html, |_| None);
|
let result = wrap_images_with_blur_with(html, |_| None);
|
||||||
assert!(
|
assert!(!result.contains("alt=\""), "无 alt 时不应生成空 alt 属性");
|
||||||
!result.contains("alt=\""),
|
|
||||||
"无 alt 时不应生成空 alt 属性"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -717,7 +718,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// data-source 携带 HTML 转义后的原始源码(单引号转义为 ')。
|
// data-source 携带 HTML 转义后的原始源码(单引号转义为 ')。
|
||||||
assert!(
|
assert!(
|
||||||
result.html.contains(r#"data-source="print('hi')"#),
|
result
|
||||||
|
.html
|
||||||
|
.contains(r#"data-source="print('hi')"#),
|
||||||
"data-source 应含转义后的源码, got: {}",
|
"data-source 应含转义后的源码, got: {}",
|
||||||
result.html
|
result.html
|
||||||
);
|
);
|
||||||
@ -740,7 +743,9 @@ console.log(1)
|
|||||||
// overrides JSON 应被 HTML 转义后放入属性(双引号变 "),不得出现裸引号越界。
|
// overrides JSON 应被 HTML 转义后放入属性(双引号变 "),不得出现裸引号越界。
|
||||||
// 字段顺序按 serde 派生默认(字母序),cpu_cores 在前。
|
// 字段顺序按 serde 派生默认(字母序),cpu_cores 在前。
|
||||||
assert!(
|
assert!(
|
||||||
result.html.contains("data-overrides=\"{"cpu_cores":1.0"),
|
result
|
||||||
|
.html
|
||||||
|
.contains("data-overrides=\"{"cpu_cores":1.0"),
|
||||||
"overrides 应 HTML 转义, got: {}",
|
"overrides 应 HTML 转义, got: {}",
|
||||||
result.html
|
result.html
|
||||||
);
|
);
|
||||||
@ -767,7 +772,9 @@ console.log(1)
|
|||||||
fn render_markdown_plain_fence_without_runnable_no_data_attrs() {
|
fn render_markdown_plain_fence_without_runnable_no_data_attrs() {
|
||||||
let result = render_markdown_enhanced("```python\nprint(1)\n```");
|
let result = render_markdown_enhanced("```python\nprint(1)\n```");
|
||||||
assert!(!result.html.contains("data-runnable"));
|
assert!(!result.html.contains("data-runnable"));
|
||||||
assert!(result.html.contains(r#"<pre><code class="language-python">"#));
|
assert!(result
|
||||||
|
.html
|
||||||
|
.contains(r#"<pre><code class="language-python">"#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
/// 认证相关的 Dioxus server function。
|
/// 认证相关的 Dioxus server function。
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
/// 代码运行接口与数据结构。
|
||||||
|
pub mod code_runner;
|
||||||
/// 评论相关接口。
|
/// 评论相关接口。
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
/// CSRF 防护中间件。
|
/// CSRF 防护中间件。
|
||||||
@ -32,5 +34,3 @@ pub mod settings;
|
|||||||
pub mod slug;
|
pub mod slug;
|
||||||
/// 图片上传的 Axum 处理器。
|
/// 图片上传的 Axum 处理器。
|
||||||
pub mod upload;
|
pub mod upload;
|
||||||
/// 代码运行接口与数据结构。
|
|
||||||
pub mod code_runner;
|
|
||||||
|
|||||||
@ -50,7 +50,9 @@ pub async fn create_post(
|
|||||||
Some(ref s) if !s.trim().is_empty() => {
|
Some(ref s) if !s.trim().is_empty() => {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
if !crate::api::slug::is_valid_slug(s) {
|
if !crate::api::slug::is_valid_slug(s) {
|
||||||
return Ok(CreatePostResponse::err("slug 格式无效,只能包含字母、数字、连字符和下划线".to_string()));
|
return Ok(CreatePostResponse::err(
|
||||||
|
"slug 格式无效,只能包含字母、数字、连字符和下划线".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
s.to_string()
|
s.to_string()
|
||||||
}
|
}
|
||||||
@ -139,7 +141,11 @@ pub async fn create_post(
|
|||||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
|
|
||||||
Ok(CreatePostResponse::ok("创建成功".to_string(), post_id, final_slug))
|
Ok(CreatePostResponse::ok(
|
||||||
|
"创建成功".to_string(),
|
||||||
|
post_id,
|
||||||
|
final_slug,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
|
|||||||
@ -74,7 +74,11 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
|
|||||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
|
|
||||||
Ok(CreatePostResponse::ok("删除成功".to_string(), post_id, slug))
|
Ok(CreatePostResponse::ok(
|
||||||
|
"删除成功".to_string(),
|
||||||
|
post_id,
|
||||||
|
slug,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
|
|||||||
@ -53,4 +53,3 @@ pub use types::*;
|
|||||||
/// 更新指定文章。
|
/// 更新指定文章。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use update::update_post;
|
pub use update::update_post;
|
||||||
|
|
||||||
|
|||||||
@ -157,9 +157,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
|
|||||||
/// 在阻塞线程池渲染 HTML,并更新 content_html / toc_html / word_count / reading_time。
|
/// 在阻塞线程池渲染 HTML,并更新 content_html / toc_html / word_count / reading_time。
|
||||||
/// 仅 admin 可调用;成功后按影响范围失效文章列表、slug 单篇与搜索缓存。
|
/// 仅 admin 可调用;成功后按影响范围失效文章列表、slug 单篇与搜索缓存。
|
||||||
#[server(RebuildPostContentHtml, "/api")]
|
#[server(RebuildPostContentHtml, "/api")]
|
||||||
pub async fn rebuild_post_content_html(
|
pub async fn rebuild_post_content_html(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
||||||
post_id: i32,
|
|
||||||
) -> Result<CreatePostResponse, ServerFnError> {
|
|
||||||
let _user = get_current_admin_user().await?;
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -224,7 +222,11 @@ pub async fn rebuild_post_content_html(
|
|||||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
|
|
||||||
Ok(CreatePostResponse::ok("重建成功".to_string(), post_id, slug))
|
Ok(CreatePostResponse::ok(
|
||||||
|
"重建成功".to_string(),
|
||||||
|
post_id,
|
||||||
|
slug,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
|
|||||||
@ -85,7 +85,11 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
|||||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
|
|
||||||
Ok(CreatePostResponse::ok("恢复成功".to_string(), post_id, new_slug))
|
Ok(CreatePostResponse::ok(
|
||||||
|
"恢复成功".to_string(),
|
||||||
|
post_id,
|
||||||
|
new_slug,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
@ -152,7 +156,11 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
|
|||||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
|
|
||||||
Ok(CreatePostResponse::ok("彻底删除成功".to_string(), post_id, slug))
|
Ok(CreatePostResponse::ok(
|
||||||
|
"彻底删除成功".to_string(),
|
||||||
|
post_id,
|
||||||
|
slug,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
@ -334,7 +342,9 @@ pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse,
|
|||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CreatePostResponse::ok_msg(format!("已彻底删除 {result}/{total} 篇")))
|
Ok(CreatePostResponse::ok_msg(format!(
|
||||||
|
"已彻底删除 {result}/{total} 篇"
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
@ -407,7 +417,9 @@ pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
|
|||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CreatePostResponse::ok_msg(format!("已清空回收站({result} 篇)")))
|
Ok(CreatePostResponse::ok_msg(format!(
|
||||||
|
"已清空回收站({result} 篇)"
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
|
|||||||
@ -208,7 +208,11 @@ pub async fn update_post(
|
|||||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||||
crate::ssr_cache::bump_global_generation();
|
crate::ssr_cache::bump_global_generation();
|
||||||
|
|
||||||
Ok(CreatePostResponse::ok("更新成功".to_string(), post_id, final_slug))
|
Ok(CreatePostResponse::ok(
|
||||||
|
"更新成功".to_string(),
|
||||||
|
post_id,
|
||||||
|
final_slug,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
|
|||||||
@ -636,7 +636,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn clean_html_input_rejects_type_image() {
|
fn clean_html_input_rejects_type_image() {
|
||||||
// type=image 是已知的 input 滥用面(可配合 src 触发请求),必须整体移除
|
// type=image 是已知的 input 滥用面(可配合 src 触发请求),必须整体移除
|
||||||
let input = r#"<ul><li><input type="image" src="https://evil.example/x.png">文本</li></ul>"#;
|
let input =
|
||||||
|
r#"<ul><li><input type="image" src="https://evil.example/x.png">文本</li></ul>"#;
|
||||||
let result = clean_html(input);
|
let result = clean_html(input);
|
||||||
assert!(
|
assert!(
|
||||||
!result.contains("input"),
|
!result.contains("input"),
|
||||||
@ -703,7 +704,10 @@ mod tests {
|
|||||||
// 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick)应被剥离。
|
// 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick)应被剥离。
|
||||||
let input = r#"<pre data-runnable="true" data-evil="x"><code>x</code></pre>"#;
|
let input = r#"<pre data-runnable="true" data-evil="x"><code>x</code></pre>"#;
|
||||||
let result = clean_html(input);
|
let result = clean_html(input);
|
||||||
assert!(result.contains("data-runnable"), "白名单 data-runnable 应保留");
|
assert!(
|
||||||
|
result.contains("data-runnable"),
|
||||||
|
"白名单 data-runnable 应保留"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!result.contains("data-evil"),
|
!result.contains("data-evil"),
|
||||||
"未知 data-* 应被剥离, got: {result}"
|
"未知 data-* 应被剥离, got: {result}"
|
||||||
|
|||||||
@ -139,12 +139,18 @@ pub async fn upload_image(
|
|||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Read file error: {:?}", e);
|
tracing::error!("Read file error: {:?}", e);
|
||||||
return Err(upload_error(StatusCode::INTERNAL_SERVER_ERROR, "文件读取失败"));
|
return Err(upload_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"文件读取失败",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if data.len() > MAX_FILE_SIZE {
|
if data.len() > MAX_FILE_SIZE {
|
||||||
return Err(upload_error(StatusCode::PAYLOAD_TOO_LARGE, "文件超过大小限制"));
|
return Err(upload_error(
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"文件超过大小限制",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验文件头 magic bytes,防止仅修改扩展名/Content-Type 上传非图片文件。
|
// 校验文件头 magic bytes,防止仅修改扩展名/Content-Type 上传非图片文件。
|
||||||
@ -174,7 +180,10 @@ pub async fn upload_image(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| upload_error(StatusCode::INTERNAL_SERVER_ERROR, "图片校验任务失败"))?;
|
.map_err(|_| upload_error(StatusCode::INTERNAL_SERVER_ERROR, "图片校验任务失败"))?;
|
||||||
if !is_valid {
|
if !is_valid {
|
||||||
return Err(upload_error(StatusCode::BAD_REQUEST, "图片文件损坏或格式不正确"));
|
return Err(upload_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"图片文件损坏或格式不正确",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,12 +275,18 @@ pub async fn upload_image(
|
|||||||
|
|
||||||
if let Err(e) = tokio::fs::create_dir_all(&dir_path).await {
|
if let Err(e) = tokio::fs::create_dir_all(&dir_path).await {
|
||||||
tracing::error!("Create dir error: {:?}", e);
|
tracing::error!("Create dir error: {:?}", e);
|
||||||
return Err(upload_error(StatusCode::INTERNAL_SERVER_ERROR, "文件保存失败"));
|
return Err(upload_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"文件保存失败",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = tokio::fs::write(&file_path, &final_data).await {
|
if let Err(e) = tokio::fs::write(&file_path, &final_data).await {
|
||||||
tracing::error!("Write file error: {:?}", e);
|
tracing::error!("Write file error: {:?}", e);
|
||||||
return Err(upload_error(StatusCode::INTERNAL_SERVER_ERROR, "文件保存失败"));
|
return Err(upload_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"文件保存失败",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len());
|
tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len());
|
||||||
|
|||||||
@ -91,8 +91,15 @@ mod tests {
|
|||||||
fn build_time_parses_as_unix_seconds() {
|
fn build_time_parses_as_unix_seconds() {
|
||||||
// build.rs 存的是 Unix 秒,运行时应能解析回时间戳。
|
// build.rs 存的是 Unix 秒,运行时应能解析回时间戳。
|
||||||
let parsed = BUILD_INFO.build_time.parse::<i64>();
|
let parsed = BUILD_INFO.build_time.parse::<i64>();
|
||||||
assert!(parsed.is_ok(), "build_time not a unix second: {}", BUILD_INFO.build_time);
|
assert!(
|
||||||
assert!(parsed.unwrap() > 1_600_000_000, "build_time implausibly old");
|
parsed.is_ok(),
|
||||||
|
"build_time not a unix second: {}",
|
||||||
|
BUILD_INFO.build_time
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed.unwrap() > 1_600_000_000,
|
||||||
|
"build_time implausibly old"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
14
src/cache.rs
14
src/cache.rs
@ -228,7 +228,8 @@ impl CacheStats {
|
|||||||
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
fn record_miss(&self) {
|
fn record_miss(&self) {
|
||||||
self.misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
self.misses
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,14 +267,15 @@ pub struct CacheStatSnapshot {
|
|||||||
/// 聚合所有缓存的统计快照(供 get_server_status 调用)。
|
/// 聚合所有缓存的统计快照(供 get_server_status 调用)。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn cache_stats() -> Vec<CacheStatSnapshot> {
|
pub fn cache_stats() -> Vec<CacheStatSnapshot> {
|
||||||
fn snap(
|
fn snap(stats: &CacheStats, entry_count: u64) -> CacheStatSnapshot {
|
||||||
stats: &CacheStats,
|
|
||||||
entry_count: u64,
|
|
||||||
) -> CacheStatSnapshot {
|
|
||||||
let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
|
let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
|
let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
let total = hits + misses;
|
let total = hits + misses;
|
||||||
let hit_rate = if total == 0 { 0.0 } else { hits as f64 / total as f64 };
|
let hit_rate = if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
hits as f64 / total as f64
|
||||||
|
};
|
||||||
CacheStatSnapshot {
|
CacheStatSnapshot {
|
||||||
name: stats.name,
|
name: stats.name,
|
||||||
entry_count,
|
entry_count,
|
||||||
|
|||||||
@ -20,7 +20,6 @@ pub fn AdminLayout() -> Element {
|
|||||||
let navigator = dioxus::router::navigator();
|
let navigator = dioxus::router::navigator();
|
||||||
let route = use_route::<Route>();
|
let route = use_route::<Route>();
|
||||||
|
|
||||||
|
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
if !(ctx.checked)() {
|
if !(ctx.checked)() {
|
||||||
(ctx.checked).set(true);
|
(ctx.checked).set(true);
|
||||||
@ -58,14 +57,19 @@ pub fn AdminLayout() -> Element {
|
|||||||
// write 路由例外:卡片不滚动(overflow-hidden),main 作为 flex 容器不带头尾 padding,
|
// write 路由例外:卡片不滚动(overflow-hidden),main 作为 flex 容器不带头尾 padding,
|
||||||
// 由 write 页面自身组织 [内容区 flex-1 overflow-y-auto] + [底栏 flex-shrink-0] 的分区布局,
|
// 由 write 页面自身组织 [内容区 flex-1 overflow-y-auto] + [底栏 flex-shrink-0] 的分区布局,
|
||||||
// 这样底栏永远贴卡片底部不随内容滚动,也不会出现 sticky + 负 margin 的跳动。
|
// 这样底栏永远贴卡片底部不随内容滚动,也不会出现 sticky + 负 margin 的跳动。
|
||||||
let card_overflow = if is_write_route { "overflow-hidden" } else { "overflow-y-auto" };
|
let card_overflow = if is_write_route {
|
||||||
|
"overflow-hidden"
|
||||||
|
} else {
|
||||||
|
"overflow-y-auto"
|
||||||
|
};
|
||||||
let main_class = if is_write_route {
|
let main_class = if is_write_route {
|
||||||
"flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0"
|
"flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0"
|
||||||
} else {
|
} else {
|
||||||
"flex-1 w-full max-w-7xl mx-auto px-6 py-12"
|
"flex-1 w-full max-w-7xl mx-auto px-6 py-12"
|
||||||
};
|
};
|
||||||
|
|
||||||
let root_class = "min-h-dvh flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans";
|
let root_class =
|
||||||
|
"min-h-dvh flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans";
|
||||||
|
|
||||||
let nav_content = rsx! {
|
let nav_content = rsx! {
|
||||||
aside { class: "w-64 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-4 bg-[var(--color-paper-entry)]",
|
aside { class: "w-64 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-4 bg-[var(--color-paper-entry)]",
|
||||||
|
|||||||
@ -11,10 +11,10 @@
|
|||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::api::code_runner::execute::{get_exec_result, start_exec};
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::code_runner::execute::start_exec_stream;
|
use crate::api::code_runner::execute::start_exec_stream;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::api::code_runner::execute::{get_exec_result, start_exec};
|
||||||
use crate::api::code_runner::{ExecRequest, ExecStatus};
|
use crate::api::code_runner::{ExecRequest, ExecStatus};
|
||||||
use crate::components::ui::SPINNER_SVG;
|
use crate::components::ui::SPINNER_SVG;
|
||||||
use crate::infra::runner_config::ResourceLimits;
|
use crate::infra::runner_config::ResourceLimits;
|
||||||
@ -272,9 +272,7 @@ pub fn CodeRunner(
|
|||||||
opts.set_font_size(13);
|
opts.set_font_size(13);
|
||||||
opts.set_on_ready(&on_ready);
|
opts.set_on_ready(&on_ready);
|
||||||
|
|
||||||
if let Ok(Some(inst)) =
|
if let Ok(Some(inst)) = xterm_bridge::get_module().create(&mount_container_id, &opts) {
|
||||||
xterm_bridge::get_module().create(&mount_container_id, &opts)
|
|
||||||
{
|
|
||||||
let handle = xterm_bridge::TerminalHandle::new(inst, on_ready);
|
let handle = xterm_bridge::TerminalHandle::new(inst, on_ready);
|
||||||
term_handle.set(Some(handle));
|
term_handle.set(Some(handle));
|
||||||
}
|
}
|
||||||
@ -736,13 +734,12 @@ mod sse_consumer {
|
|||||||
match get_exec_result(task_id.to_string()).await {
|
match get_exec_result(task_id.to_string()).await {
|
||||||
Ok(task) => {
|
Ok(task) => {
|
||||||
stage.set(task.stage.clone());
|
stage.set(task.stage.clone());
|
||||||
let terminal = task.status != ExecStatus::Queued
|
let terminal =
|
||||||
&& task.status != ExecStatus::Running;
|
task.status != ExecStatus::Queued && task.status != ExecStatus::Running;
|
||||||
if terminal {
|
if terminal {
|
||||||
running.set(false);
|
running.set(false);
|
||||||
if let Some(res) = task.result {
|
if let Some(res) = task.result {
|
||||||
let out =
|
let out = format!("Stdout:\n{}\nStderr:\n{}", res.stdout, res.stderr);
|
||||||
format!("Stdout:\n{}\nStderr:\n{}", res.stdout, res.stderr);
|
|
||||||
output.set(out);
|
output.set(out);
|
||||||
exit_info.set(format!(
|
exit_info.set(format!(
|
||||||
"耗时: {}ms · 状态: {}",
|
"耗时: {}ms · 状态: {}",
|
||||||
|
|||||||
@ -6,8 +6,8 @@ use dioxus::prelude::*;
|
|||||||
|
|
||||||
use crate::components::comments::item::CommentItem;
|
use crate::components::comments::item::CommentItem;
|
||||||
use crate::components::comments::pending_item::PendingCommentItem;
|
use crate::components::comments::pending_item::PendingCommentItem;
|
||||||
use crate::utils::comment_storage::PendingComment;
|
|
||||||
use crate::models::comment::PublicComment;
|
use crate::models::comment::PublicComment;
|
||||||
|
use crate::utils::comment_storage::PendingComment;
|
||||||
|
|
||||||
/// 合并后的评论节点,可能是已审核或待审核评论。
|
/// 合并后的评论节点,可能是已审核或待审核评论。
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@ -40,12 +40,10 @@ pub struct CommentContext {
|
|||||||
/// - 空评论时展示提示文案
|
/// - 空评论时展示提示文案
|
||||||
#[component]
|
#[component]
|
||||||
pub fn CommentSection(post_id: i32) -> Element {
|
pub fn CommentSection(post_id: i32) -> Element {
|
||||||
let mut ctx = use_context_provider(|| {
|
let mut ctx = use_context_provider(|| CommentContext {
|
||||||
CommentContext {
|
|
||||||
active_reply: Signal::new(None),
|
active_reply: Signal::new(None),
|
||||||
refresh_trigger: Signal::new(false),
|
refresh_trigger: Signal::new(false),
|
||||||
pending_comments: Signal::new(Vec::new()),
|
pending_comments: Signal::new(Vec::new()),
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 挂载后从本地存储异步加载待审核评论以防 SSR Hydration Mismatch
|
// 挂载后从本地存储异步加载待审核评论以防 SSR Hydration Mismatch
|
||||||
@ -72,7 +70,9 @@ pub fn CommentSection(post_id: i32) -> Element {
|
|||||||
.collect();
|
.collect();
|
||||||
if !to_remove.is_empty() {
|
if !to_remove.is_empty() {
|
||||||
comment_storage::remove_pending_ids(post_id, &to_remove);
|
comment_storage::remove_pending_ids(post_id, &to_remove);
|
||||||
ctx.pending_comments.write().retain(|c| !to_remove.contains(&c.id));
|
ctx.pending_comments
|
||||||
|
.write()
|
||||||
|
.retain(|c| !to_remove.contains(&c.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
@ -85,9 +85,7 @@ pub fn CommentSection(post_id: i32) -> Element {
|
|||||||
// 评论数据资源,refresh_trigger 变化时自动重新加载
|
// 评论数据资源,refresh_trigger 变化时自动重新加载
|
||||||
let comments_resource = use_resource(move || {
|
let comments_resource = use_resource(move || {
|
||||||
let _ = (ctx.refresh_trigger)();
|
let _ = (ctx.refresh_trigger)();
|
||||||
async move {
|
async move { get_comments(post_id).await }
|
||||||
get_comments(post_id).await
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let data = comments_resource.read();
|
let data = comments_resource.read();
|
||||||
|
|||||||
@ -262,9 +262,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn decode_html_entities_roundtrip() {
|
fn decode_html_entities_roundtrip() {
|
||||||
assert_eq!(decode_html_entities("print('hi')"), "print('hi')");
|
assert_eq!(decode_html_entities("print('hi')"), "print('hi')");
|
||||||
assert_eq!(
|
assert_eq!(decode_html_entities(""<>&"), "\"<>&");
|
||||||
decode_html_entities(""<>&"),
|
|
||||||
"\"<>&"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -317,7 +317,8 @@ pub fn Popover(
|
|||||||
use dioxus::prelude::{use_drop, use_effect, use_hook};
|
use dioxus::prelude::{use_drop, use_effect, use_hook};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
type EscState = Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
|
type EscState =
|
||||||
|
Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
|
||||||
let state: EscState = use_hook(|| Rc::new(RefCell::new(None)));
|
let state: EscState = use_hook(|| Rc::new(RefCell::new(None)));
|
||||||
let state_for_drop = state.clone();
|
let state_for_drop = state.clone();
|
||||||
let open_for_effect = open;
|
let open_for_effect = open;
|
||||||
@ -332,7 +333,8 @@ pub fn Popover(
|
|||||||
let on_close_for_esc = on_close_for_esc;
|
let on_close_for_esc = on_close_for_esc;
|
||||||
// Closure 带 KeyboardEvent 参数:浏览器调用 handler 时传入事件对象,
|
// Closure 带 KeyboardEvent 参数:浏览器调用 handler 时传入事件对象,
|
||||||
// 无需依赖已废弃的 window.event()。as_ref + unchecked_ref 转成 JS Function。
|
// 无需依赖已废弃的 window.event()。as_ref + unchecked_ref 转成 JS Function。
|
||||||
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
|
let closure =
|
||||||
|
wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
|
||||||
if ev.key() == "Escape" {
|
if ev.key() == "Escape" {
|
||||||
on_close_for_esc.call(());
|
on_close_for_esc.call(());
|
||||||
}
|
}
|
||||||
@ -362,7 +364,11 @@ pub fn Popover(
|
|||||||
|
|
||||||
// 面板定位:top/bottom 决定垂直方向;水平统一居中于点击点。
|
// 面板定位:top/bottom 决定垂直方向;水平统一居中于点击点。
|
||||||
let style = if placement == "bottom" {
|
let style = if placement == "bottom" {
|
||||||
format!("top: {y}px; left: {x}px; transform: translateX(-50%);", x = anchor_x, y = anchor_y + 8)
|
format!(
|
||||||
|
"top: {y}px; left: {x}px; transform: translateX(-50%);",
|
||||||
|
x = anchor_x,
|
||||||
|
y = anchor_y + 8
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// top:面板在点击点上方——用 bottom 锚定 viewport 底,差值即视口高度 - y + 间隙。
|
// top:面板在点击点上方——用 bottom 锚定 viewport 底,差值即视口高度 - y + 间隙。
|
||||||
// 视口高度用 100vh,纯 CSS 无需 JS 读取 scrollHeight。
|
// 视口高度用 100vh,纯 CSS 无需 JS 读取 scrollHeight。
|
||||||
|
|||||||
@ -95,10 +95,18 @@ impl std::fmt::Display for MigrateError {
|
|||||||
// 无信息量的 `db error`,真正的 message/SQLSTATE/约束名藏在 source() 里。
|
// 无信息量的 `db error`,真正的 message/SQLSTATE/约束名藏在 source() 里。
|
||||||
match self {
|
match self {
|
||||||
MigrateError::Pool(e) => {
|
MigrateError::Pool(e) => {
|
||||||
write!(f, "database pool error: {}", crate::db::format_with_sources(e))
|
write!(
|
||||||
|
f,
|
||||||
|
"database pool error: {}",
|
||||||
|
crate::db::format_with_sources(e)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
MigrateError::Query(e) => {
|
MigrateError::Query(e) => {
|
||||||
write!(f, "database query error: {}", crate::db::format_with_sources(e))
|
write!(
|
||||||
|
f,
|
||||||
|
"database query error: {}",
|
||||||
|
crate::db::format_with_sources(e)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
MigrateError::Apply { version, source } => {
|
MigrateError::Apply { version, source } => {
|
||||||
write!(
|
write!(
|
||||||
|
|||||||
@ -302,10 +302,7 @@ pub async fn ensure_database() -> Result<(), String> {
|
|||||||
// 5. 不存在则创建。db_name 已通过 is_simple_ident 校验,可安全拼到 SQL。
|
// 5. 不存在则创建。db_name 已通过 is_simple_ident 校验,可安全拼到 SQL。
|
||||||
tracing::info!("target database {:?} does not exist, creating", db_name);
|
tracing::info!("target database {:?} does not exist, creating", db_name);
|
||||||
let stmt = format!("CREATE DATABASE {db_name}");
|
let stmt = format!("CREATE DATABASE {db_name}");
|
||||||
client
|
client.batch_execute(&stmt).await.map_err(|e| {
|
||||||
.batch_execute(&stmt)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
format!(
|
format!(
|
||||||
"failed to create database {db_name:?}: {}",
|
"failed to create database {db_name:?}: {}",
|
||||||
crate::db::format_with_sources(&e)
|
crate::db::format_with_sources(&e)
|
||||||
|
|||||||
@ -42,8 +42,7 @@ where
|
|||||||
|
|
||||||
// 用 use_hook 持有 (Closure, target),在整个组件生命周期内复用;
|
// 用 use_hook 持有 (Closure, target),在整个组件生命周期内复用;
|
||||||
// use_drop 时 take 出来移除监听,防止泄漏。
|
// use_drop 时 take 出来移除监听,防止泄漏。
|
||||||
type ListenerState<T> =
|
type ListenerState<T> = Rc<RefCell<Option<(wasm_bindgen::prelude::Closure<dyn FnMut()>, T)>>>;
|
||||||
Rc<RefCell<Option<(wasm_bindgen::prelude::Closure<dyn FnMut()>, T)>>>;
|
|
||||||
let state: ListenerState<T> = use_hook(|| Rc::new(RefCell::new(None)));
|
let state: ListenerState<T> = use_hook(|| Rc::new(RefCell::new(None)));
|
||||||
let state_for_drop = state.clone();
|
let state_for_drop = state.clone();
|
||||||
|
|
||||||
@ -53,14 +52,17 @@ where
|
|||||||
let mut handler = Some(handler);
|
let mut handler = Some(handler);
|
||||||
|
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let Some(acquire) = acquire.take() else { return };
|
let Some(acquire) = acquire.take() else {
|
||||||
let Some(mut handler) = handler.take() else { return };
|
return;
|
||||||
|
};
|
||||||
|
let Some(mut handler) = handler.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let Some(target) = acquire() else { return };
|
let Some(target) = acquire() else { return };
|
||||||
let target_clone = target.clone();
|
let target_clone = target.clone();
|
||||||
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
|
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
|
||||||
handler();
|
handler();
|
||||||
})
|
}) as Box<dyn FnMut()>);
|
||||||
as Box<dyn FnMut()>);
|
|
||||||
let _ = target_clone.as_ref().add_event_listener_with_callback(
|
let _ = target_clone.as_ref().add_event_listener_with_callback(
|
||||||
event,
|
event,
|
||||||
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
|
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
|
||||||
|
|||||||
@ -48,11 +48,7 @@ pub struct PaginatedState<T> {
|
|||||||
/// ```
|
/// ```
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
pub fn use_paginated<T, P, F, Fut, E>(
|
pub fn use_paginated<T, P, F, Fut, E>(page: P, per_page: i32, fetch: F) -> PaginatedState<T>
|
||||||
page: P,
|
|
||||||
per_page: i32,
|
|
||||||
fetch: F,
|
|
||||||
) -> PaginatedState<T>
|
|
||||||
where
|
where
|
||||||
T: PartialEq + Clone + 'static,
|
T: PartialEq + Clone + 'static,
|
||||||
P: Fn() -> i32 + Copy + 'static,
|
P: Fn() -> i32 + Copy + 'static,
|
||||||
|
|||||||
@ -1,23 +1,59 @@
|
|||||||
|
use futures::StreamExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use futures::StreamExt;
|
|
||||||
|
|
||||||
use bollard::Docker;
|
|
||||||
use bollard::container::LogOutput;
|
|
||||||
use bollard::models::{HostConfig, ResourcesUlimits, ContainerCreateBody};
|
|
||||||
use bollard::query_parameters::{
|
|
||||||
CreateContainerOptions, StartContainerOptions, RemoveContainerOptions, WaitContainerOptions,
|
|
||||||
AttachContainerOptions,
|
|
||||||
};
|
|
||||||
use crate::infra::runner_config::{ResourceLimits, RUNNER_CONFIG};
|
use crate::infra::runner_config::{ResourceLimits, RUNNER_CONFIG};
|
||||||
|
use bollard::container::LogOutput;
|
||||||
|
use bollard::models::{ContainerCreateBody, HostConfig, ResourcesUlimits};
|
||||||
|
use bollard::query_parameters::{
|
||||||
|
AttachContainerOptions, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
||||||
|
WaitContainerOptions,
|
||||||
|
};
|
||||||
|
use bollard::Docker;
|
||||||
|
|
||||||
pub static DOCKER_CLIENT: LazyLock<Docker> = LazyLock::new(|| {
|
/// 共享的 Docker 客户端。
|
||||||
Docker::connect_with_unix(&RUNNER_CONFIG.docker_socket_path, 120, bollard::API_DEFAULT_VERSION)
|
///
|
||||||
.expect("Failed to connect to Docker daemon via unix socket")
|
/// `connect_with_unix` 会立即探测 socket 是否存在:缺失(未安装 / 未运行 Docker,
|
||||||
|
/// 或 `DOCKER_SOCKET_PATH` 指错)时返回 `SocketNotFoundError`。
|
||||||
|
///
|
||||||
|
/// 连接失败不 panic:release profile 是 `panic = "abort"`,会让整个服务进程一起挂掉,
|
||||||
|
/// 而博客本身并不依赖 Docker。改为记 error 日志后返回 `None`,代码运行器把
|
||||||
|
/// `None` 转成普通 bollard 错误向上冒泡,最终在 execute.rs 的错误脱敏层统一映射为
|
||||||
|
/// 「系统暂时不可用」,其余功能不受影响。
|
||||||
|
pub static DOCKER_CLIENT: LazyLock<Option<Docker>> = LazyLock::new(|| {
|
||||||
|
match Docker::connect_with_unix(
|
||||||
|
&RUNNER_CONFIG.docker_socket_path,
|
||||||
|
120,
|
||||||
|
bollard::API_DEFAULT_VERSION,
|
||||||
|
) {
|
||||||
|
Ok(d) => Some(d),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = ?e,
|
||||||
|
socket = %RUNNER_CONFIG.docker_socket_path,
|
||||||
|
"无法连接 Docker daemon,代码运行功能将不可用。\
|
||||||
|
请确认 Docker 已安装并运行,或设置正确的 DOCKER_SOCKET_PATH",
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 取共享 Docker 客户端;daemon 不可用时返回 IOError(NotFound)。
|
||||||
|
///
|
||||||
|
/// `NotFound` 既不命中 `TimedOut` 也不命中超时判断,会走 execute.rs 的通用失败路径
|
||||||
|
/// (`ExecStatus::Failed` + 「系统暂时不可用」),不会误报成超时。
|
||||||
|
fn get_docker() -> Result<&'static Docker, bollard::errors::Error> {
|
||||||
|
DOCKER_CLIENT.as_ref().ok_or_else(|| bollard::errors::Error::IOError {
|
||||||
|
err: std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"Docker daemon 不可用(未安装或未运行)",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_host_config(limits: &ResourceLimits) -> HostConfig {
|
pub fn build_host_config(limits: &ResourceLimits) -> HostConfig {
|
||||||
let mut tmpfs = HashMap::new();
|
let mut tmpfs = HashMap::new();
|
||||||
// /code 用 mode=1777(sticky + all-rwx)让容器内 1000:1000 用户可写。
|
// /code 用 mode=1777(sticky + all-rwx)让容器内 1000:1000 用户可写。
|
||||||
@ -38,7 +74,11 @@ pub fn build_host_config(limits: &ResourceLimits) -> HostConfig {
|
|||||||
cpu_period: Some(100_000),
|
cpu_period: Some(100_000),
|
||||||
memory: Some(memory),
|
memory: Some(memory),
|
||||||
memory_swap: Some(memory), // = memory, disable swap
|
memory_swap: Some(memory), // = memory, disable swap
|
||||||
network_mode: Some(if limits.allow_network { "bridge".to_string() } else { "none".to_string() }),
|
network_mode: Some(if limits.allow_network {
|
||||||
|
"bridge".to_string()
|
||||||
|
} else {
|
||||||
|
"none".to_string()
|
||||||
|
}),
|
||||||
readonly_rootfs: Some(true),
|
readonly_rootfs: Some(true),
|
||||||
tmpfs: Some(tmpfs),
|
tmpfs: Some(tmpfs),
|
||||||
pids_limit: Some(64),
|
pids_limit: Some(64),
|
||||||
@ -46,9 +86,11 @@ pub fn build_host_config(limits: &ResourceLimits) -> HostConfig {
|
|||||||
// 不设 nproc:RLIMIT_NPROC 在 setrlimit 时按 UID 计数,配合 non-root 用户会让
|
// 不设 nproc:RLIMIT_NPROC 在 setrlimit 时按 UID 计数,配合 non-root 用户会让
|
||||||
// 容器初始 exec /bin/sh 直接 EAGAIN("exec: resource temporarily unavailable"),
|
// 容器初始 exec /bin/sh 直接 EAGAIN("exec: resource temporarily unavailable"),
|
||||||
// 与容器内实际进程数无关。pids_limit 已在 cgroup 层兜底,nproc 是冗余且有害的双重约束。
|
// 与容器内实际进程数无关。pids_limit 已在 cgroup 层兜底,nproc 是冗余且有害的双重约束。
|
||||||
ulimits: Some(vec![
|
ulimits: Some(vec![ResourcesUlimits {
|
||||||
ResourcesUlimits { name: Some("nofile".to_string()), soft: Some(64), hard: Some(64) },
|
name: Some("nofile".to_string()),
|
||||||
]),
|
soft: Some(64),
|
||||||
|
hard: Some(64),
|
||||||
|
}]),
|
||||||
cap_drop: Some(vec!["ALL".to_string()]),
|
cap_drop: Some(vec!["ALL".to_string()]),
|
||||||
security_opt: Some(vec!["no-new-privileges".to_string()]),
|
security_opt: Some(vec!["no-new-privileges".to_string()]),
|
||||||
auto_remove: Some(false), // must be false to avoid premature removal before getting logs
|
auto_remove: Some(false), // must be false to avoid premature removal before getting logs
|
||||||
@ -112,7 +154,7 @@ pub async fn run_in_container(
|
|||||||
ext: &str,
|
ext: &str,
|
||||||
limits: ResourceLimits,
|
limits: ResourceLimits,
|
||||||
) -> Result<(Option<i64>, String, String, bool), bollard::errors::Error> {
|
) -> Result<(Option<i64>, String, String, bool), bollard::errors::Error> {
|
||||||
let docker = &*DOCKER_CLIENT;
|
let docker = get_docker()?;
|
||||||
let host_config = build_host_config(&limits);
|
let host_config = build_host_config(&limits);
|
||||||
|
|
||||||
// Source injection script: use sh -c to first receive stdin and write to file, then exec the actual command
|
// Source injection script: use sh -c to first receive stdin and write to file, then exec the actual command
|
||||||
@ -133,10 +175,9 @@ pub async fn run_in_container(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let container = docker.create_container(
|
let container = docker
|
||||||
None::<CreateContainerOptions>,
|
.create_container(None::<CreateContainerOptions>, config)
|
||||||
config
|
.await?;
|
||||||
).await?;
|
|
||||||
|
|
||||||
let container_id = container.id;
|
let container_id = container.id;
|
||||||
let _guard = ContainerGuard {
|
let _guard = ContainerGuard {
|
||||||
@ -145,7 +186,8 @@ pub async fn run_in_container(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Attach to container to stream stdin, stdout, and stderr
|
// Attach to container to stream stdin, stdout, and stderr
|
||||||
let attach_res = docker.attach_container(
|
let attach_res = docker
|
||||||
|
.attach_container(
|
||||||
&container_id,
|
&container_id,
|
||||||
Some(AttachContainerOptions {
|
Some(AttachContainerOptions {
|
||||||
stdin: true,
|
stdin: true,
|
||||||
@ -154,8 +196,9 @@ pub async fn run_in_container(
|
|||||||
stream: true,
|
stream: true,
|
||||||
logs: false,
|
logs: false,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
}),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let (mut writer, mut stream) = match attach_res {
|
let (mut writer, mut stream) = match attach_res {
|
||||||
Ok(res) => (res.input, res.output),
|
Ok(res) => (res.input, res.output),
|
||||||
@ -177,7 +220,7 @@ pub async fn run_in_container(
|
|||||||
|
|
||||||
if timeout(Duration::from_secs(5), write_fut).await.is_err() {
|
if timeout(Duration::from_secs(5), write_fut).await.is_err() {
|
||||||
return Err(bollard::errors::Error::IOError {
|
return Err(bollard::errors::Error::IOError {
|
||||||
err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out")
|
err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
drop(writer);
|
drop(writer);
|
||||||
@ -214,14 +257,16 @@ pub async fn run_in_container(
|
|||||||
Ok(chunk) => {
|
Ok(chunk) => {
|
||||||
match chunk {
|
match chunk {
|
||||||
LogOutput::StdOut { message } => {
|
LogOutput::StdOut { message } => {
|
||||||
let remaining = (limits.output_bytes as usize).saturating_sub(stdout_buf.len() + stderr_buf.len());
|
let remaining = (limits.output_bytes as usize)
|
||||||
|
.saturating_sub(stdout_buf.len() + stderr_buf.len());
|
||||||
if remaining > 0 {
|
if remaining > 0 {
|
||||||
let to_add = message.len().min(remaining);
|
let to_add = message.len().min(remaining);
|
||||||
stdout_buf.extend_from_slice(&message[..to_add]);
|
stdout_buf.extend_from_slice(&message[..to_add]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LogOutput::StdErr { message } => {
|
LogOutput::StdErr { message } => {
|
||||||
let remaining = (limits.output_bytes as usize).saturating_sub(stdout_buf.len() + stderr_buf.len());
|
let remaining = (limits.output_bytes as usize)
|
||||||
|
.saturating_sub(stdout_buf.len() + stderr_buf.len());
|
||||||
if remaining > 0 {
|
if remaining > 0 {
|
||||||
let to_add = message.len().min(remaining);
|
let to_add = message.len().min(remaining);
|
||||||
stderr_buf.extend_from_slice(&message[..to_add]);
|
stderr_buf.extend_from_slice(&message[..to_add]);
|
||||||
@ -242,9 +287,10 @@ pub async fn run_in_container(
|
|||||||
|
|
||||||
// Check OOM status
|
// Check OOM status
|
||||||
let inspect = docker.inspect_container(&container_id, None).await;
|
let inspect = docker.inspect_container(&container_id, None).await;
|
||||||
let oom_killed = inspect.ok().and_then(|info| {
|
let oom_killed = inspect
|
||||||
info.state.and_then(|s| s.oom_killed)
|
.ok()
|
||||||
}).unwrap_or(false);
|
.and_then(|info| info.state.and_then(|s| s.oom_killed))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Truncate output to limits.output_bytes
|
// Truncate output to limits.output_bytes
|
||||||
let limit_bytes = limits.output_bytes as usize;
|
let limit_bytes = limits.output_bytes as usize;
|
||||||
@ -256,7 +302,7 @@ pub async fn run_in_container(
|
|||||||
|
|
||||||
if timed_out {
|
if timed_out {
|
||||||
return Err(bollard::errors::Error::IOError {
|
return Err(bollard::errors::Error::IOError {
|
||||||
err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out")
|
err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -301,7 +347,7 @@ pub async fn run_in_container_stream(
|
|||||||
limits: ResourceLimits,
|
limits: ResourceLimits,
|
||||||
tx: tokio::sync::mpsc::Sender<OutputChunk>,
|
tx: tokio::sync::mpsc::Sender<OutputChunk>,
|
||||||
) -> Result<(Option<i64>, String, String, bool, bool), bollard::errors::Error> {
|
) -> Result<(Option<i64>, String, String, bool, bool), bollard::errors::Error> {
|
||||||
let docker = &*DOCKER_CLIENT;
|
let docker = get_docker()?;
|
||||||
let host_config = build_host_config(&limits);
|
let host_config = build_host_config(&limits);
|
||||||
|
|
||||||
// 与 run_in_container 相同的 stdin 注入脚本。
|
// 与 run_in_container 相同的 stdin 注入脚本。
|
||||||
@ -399,7 +445,8 @@ pub async fn run_in_container_stream(
|
|||||||
match item {
|
match item {
|
||||||
Ok(chunk) => match chunk {
|
Ok(chunk) => match chunk {
|
||||||
LogOutput::StdOut { message } => {
|
LogOutput::StdOut { message } => {
|
||||||
let remaining = limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
|
let remaining =
|
||||||
|
limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
|
||||||
if remaining > 0 {
|
if remaining > 0 {
|
||||||
let to_add = message.len().min(remaining);
|
let to_add = message.len().min(remaining);
|
||||||
let slice = &message[..to_add];
|
let slice = &message[..to_add];
|
||||||
@ -413,7 +460,8 @@ pub async fn run_in_container_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LogOutput::StdErr { message } => {
|
LogOutput::StdErr { message } => {
|
||||||
let remaining = limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
|
let remaining =
|
||||||
|
limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
|
||||||
if remaining > 0 {
|
if remaining > 0 {
|
||||||
let to_add = message.len().min(remaining);
|
let to_add = message.len().min(remaining);
|
||||||
let slice = &message[..to_add];
|
let slice = &message[..to_add];
|
||||||
@ -625,14 +673,7 @@ mod tests {
|
|||||||
output_bytes: 1024,
|
output_bytes: 1024,
|
||||||
allow_network: false,
|
allow_network: false,
|
||||||
};
|
};
|
||||||
let res = run_in_container(
|
let res = run_in_container("alpine:latest", "sleep 10", "", "txt", limits).await;
|
||||||
"alpine:latest",
|
|
||||||
"sleep 10",
|
|
||||||
"",
|
|
||||||
"txt",
|
|
||||||
limits,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(res.is_err());
|
assert!(res.is_err());
|
||||||
let err = res.unwrap_err();
|
let err = res.unwrap_err();
|
||||||
@ -648,13 +689,17 @@ mod tests {
|
|||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_run_in_container_cancellation() {
|
async fn test_run_in_container_cancellation() {
|
||||||
use bollard::query_parameters::ListContainersOptions;
|
use bollard::query_parameters::ListContainersOptions;
|
||||||
let docker = &*DOCKER_CLIENT;
|
let docker = get_docker().expect("test requires a running Docker daemon");
|
||||||
|
|
||||||
let before = docker.list_containers(Some(ListContainersOptions {
|
let before = docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
all: true,
|
all: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})).await.unwrap();
|
}))
|
||||||
let before_ids: std::collections::HashSet<String> = before.into_iter().map(|c| c.id.unwrap()).collect();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let before_ids: std::collections::HashSet<String> =
|
||||||
|
before.into_iter().map(|c| c.id.unwrap()).collect();
|
||||||
|
|
||||||
let limits = ResourceLimits {
|
let limits = ResourceLimits {
|
||||||
cpu_cores: 1.0,
|
cpu_cores: 1.0,
|
||||||
@ -664,13 +709,7 @@ mod tests {
|
|||||||
allow_network: false,
|
allow_network: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let run_fut = run_in_container(
|
let run_fut = run_in_container("alpine:latest", "sleep 100", "", "txt", limits);
|
||||||
"alpine:latest",
|
|
||||||
"sleep 100",
|
|
||||||
"",
|
|
||||||
"txt",
|
|
||||||
limits,
|
|
||||||
);
|
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = run_fut => {
|
_ = run_fut => {
|
||||||
@ -683,10 +722,13 @@ mod tests {
|
|||||||
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
let after = docker.list_containers(Some(ListContainersOptions {
|
let after = docker
|
||||||
|
.list_containers(Some(ListContainersOptions {
|
||||||
all: true,
|
all: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})).await.unwrap();
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let mut leaked = Vec::new();
|
let mut leaked = Vec::new();
|
||||||
for c in after {
|
for c in after {
|
||||||
@ -698,10 +740,15 @@ mod tests {
|
|||||||
|
|
||||||
let leaked_count = leaked.len();
|
let leaked_count = leaked.len();
|
||||||
for id in leaked {
|
for id in leaked {
|
||||||
let _ = docker.remove_container(&id, Some(RemoveContainerOptions {
|
let _ = docker
|
||||||
|
.remove_container(
|
||||||
|
&id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
force: true,
|
force: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})).await;
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(leaked_count, 0, "Found {} leaked containers", leaked_count);
|
assert_eq!(leaked_count, 0, "Found {} leaked containers", leaked_count);
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod docker;
|
pub mod docker;
|
||||||
pub mod runner_config;
|
pub mod runner_config;
|
||||||
|
|
||||||
|
|||||||
@ -41,20 +41,51 @@ pub static RUNNER_CONFIG: LazyLock<RunnerConfig> = LazyLock::new(|| {
|
|||||||
// CODE_RUNNER_LANGUAGES 未设置时默认全开(None):注册表里的语言均可用,
|
// CODE_RUNNER_LANGUAGES 未设置时默认全开(None):注册表里的语言均可用,
|
||||||
// 新增语言无需同步白名单。设置为逗号分隔列表则收窄到这些语言。
|
// 新增语言无需同步白名单。设置为逗号分隔列表则收窄到这些语言。
|
||||||
let languages = env::var("CODE_RUNNER_LANGUAGES").ok().map(|s| {
|
let languages = env::var("CODE_RUNNER_LANGUAGES").ok().map(|s| {
|
||||||
s.split(',').map(|t| t.trim().to_lowercase()).filter(|t| !t.is_empty()).collect()
|
s.split(',')
|
||||||
|
.map(|t| t.trim().to_lowercase())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
RunnerConfig {
|
RunnerConfig {
|
||||||
max_cpu_cores: env::var("CODE_RUNNER_MAX_CPU_CORES").ok().and_then(|v| v.parse().ok()).unwrap_or(2.0),
|
max_cpu_cores: env::var("CODE_RUNNER_MAX_CPU_CORES")
|
||||||
max_memory_mb: env::var("CODE_RUNNER_MAX_MEMORY_MB").ok().and_then(|v| v.parse().ok()).unwrap_or(1024),
|
.ok()
|
||||||
max_timeout_secs: env::var("CODE_RUNNER_MAX_TIMEOUT_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(30),
|
.and_then(|v| v.parse().ok())
|
||||||
max_output_bytes: env::var("CODE_RUNNER_MAX_OUTPUT_BYTES").ok().and_then(|v| v.parse().ok()).unwrap_or(1048576),
|
.unwrap_or(2.0),
|
||||||
max_source_bytes: env::var("CODE_RUNNER_MAX_SOURCE_BYTES").ok().and_then(|v| v.parse().ok()).unwrap_or(65536),
|
max_memory_mb: env::var("CODE_RUNNER_MAX_MEMORY_MB")
|
||||||
allow_network: env::var("CODE_RUNNER_ALLOW_NETWORK").ok().map(|v| parse_allow_network(&v)).unwrap_or(false),
|
.ok()
|
||||||
max_concurrent: env::var("CODE_RUNNER_MAX_CONCURRENT").ok().and_then(|v| v.parse().ok()).unwrap_or(4),
|
.and_then(|v| v.parse().ok())
|
||||||
queue_timeout_secs: env::var("CODE_RUNNER_QUEUE_TIMEOUT_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(30),
|
.unwrap_or(1024),
|
||||||
task_ttl_secs: env::var("CODE_RUNNER_TASK_TTL_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(300),
|
max_timeout_secs: env::var("CODE_RUNNER_MAX_TIMEOUT_SECS")
|
||||||
docker_socket_path: env::var("DOCKER_SOCKET_PATH").unwrap_or_else(|_| "/var/run/docker.sock".to_string()),
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(30),
|
||||||
|
max_output_bytes: env::var("CODE_RUNNER_MAX_OUTPUT_BYTES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(1048576),
|
||||||
|
max_source_bytes: env::var("CODE_RUNNER_MAX_SOURCE_BYTES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(65536),
|
||||||
|
allow_network: env::var("CODE_RUNNER_ALLOW_NETWORK")
|
||||||
|
.ok()
|
||||||
|
.map(|v| parse_allow_network(&v))
|
||||||
|
.unwrap_or(false),
|
||||||
|
max_concurrent: env::var("CODE_RUNNER_MAX_CONCURRENT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(4),
|
||||||
|
queue_timeout_secs: env::var("CODE_RUNNER_QUEUE_TIMEOUT_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(30),
|
||||||
|
task_ttl_secs: env::var("CODE_RUNNER_TASK_TTL_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(300),
|
||||||
|
docker_socket_path: env::var("DOCKER_SOCKET_PATH")
|
||||||
|
.unwrap_or_else(|_| "/var/run/docker.sock".to_string()),
|
||||||
languages,
|
languages,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -65,8 +96,16 @@ pub fn clamp_limits(merged: ResourceLimits, lang_allows_network: bool) -> Resour
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn clamp_limits_impl(merged: ResourceLimits, lang_allows_network: bool, config: &RunnerConfig) -> ResourceLimits {
|
fn clamp_limits_impl(
|
||||||
let max_cpu = if config.max_cpu_cores.is_nan() { 2.0 } else { config.max_cpu_cores };
|
merged: ResourceLimits,
|
||||||
|
lang_allows_network: bool,
|
||||||
|
config: &RunnerConfig,
|
||||||
|
) -> ResourceLimits {
|
||||||
|
let max_cpu = if config.max_cpu_cores.is_nan() {
|
||||||
|
2.0
|
||||||
|
} else {
|
||||||
|
config.max_cpu_cores
|
||||||
|
};
|
||||||
let min_cpu = 0.1f64.min(max_cpu);
|
let min_cpu = 0.1f64.min(max_cpu);
|
||||||
let cpu_cores = if merged.cpu_cores.is_nan() {
|
let cpu_cores = if merged.cpu_cores.is_nan() {
|
||||||
min_cpu
|
min_cpu
|
||||||
@ -78,7 +117,9 @@ fn clamp_limits_impl(merged: ResourceLimits, lang_allows_network: bool, config:
|
|||||||
let memory_mb = merged.memory_mb.clamp(min_mem, config.max_memory_mb);
|
let memory_mb = merged.memory_mb.clamp(min_mem, config.max_memory_mb);
|
||||||
|
|
||||||
let min_timeout = 1.min(config.max_timeout_secs);
|
let min_timeout = 1.min(config.max_timeout_secs);
|
||||||
let timeout_secs = merged.timeout_secs.clamp(min_timeout, config.max_timeout_secs);
|
let timeout_secs = merged
|
||||||
|
.timeout_secs
|
||||||
|
.clamp(min_timeout, config.max_timeout_secs);
|
||||||
|
|
||||||
ResourceLimits {
|
ResourceLimits {
|
||||||
cpu_cores,
|
cpu_cores,
|
||||||
|
|||||||
@ -22,8 +22,8 @@ use crate::components::empty_state::{EmptyState, EmptyStateAction};
|
|||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
||||||
use crate::components::ui::{
|
use crate::components::ui::{
|
||||||
Pagination, StatusBadge, Tooltip, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_OUTLINE,
|
Pagination, StatusBadge, Tooltip, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_OUTLINE, BTN_PRIMARY,
|
||||||
BTN_PRIMARY, BTN_TEXT_ACCENT, BTN_TEXT_RED, SPINNER_SVG,
|
BTN_TEXT_ACCENT, BTN_TEXT_RED, SPINNER_SVG,
|
||||||
};
|
};
|
||||||
use crate::hooks::query::use_paginated;
|
use crate::hooks::query::use_paginated;
|
||||||
use crate::models::post::PostListItem;
|
use crate::models::post::PostListItem;
|
||||||
@ -473,10 +473,9 @@ pub(super) fn PostsTabs(
|
|||||||
{
|
{
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
crate::utils::time::sleep_ms(50).await;
|
crate::utils::time::sleep_ms(50).await;
|
||||||
if let Some(el) = web_sys::window()
|
if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| {
|
||||||
.and_then(|w| w.document())
|
d.get_element_by_id(&format!("posts-tab-{id_prefix}-{active_key}"))
|
||||||
.and_then(|d| d.get_element_by_id(&format!("posts-tab-{id_prefix}-{active_key}")))
|
}) {
|
||||||
{
|
|
||||||
if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
|
if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
|
||||||
indicator_style.set(format!(
|
indicator_style.set(format!(
|
||||||
"left: {}px; width: {}px; opacity: 1;",
|
"left: {}px; width: {}px; opacity: 1;",
|
||||||
|
|||||||
@ -26,9 +26,9 @@ use crate::components::empty_state::EmptyState;
|
|||||||
use crate::components::skeletons::atoms::SkeletonBox;
|
use crate::components::skeletons::atoms::SkeletonBox;
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::ui::{
|
use crate::components::ui::{
|
||||||
Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
|
LoadingButton, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
|
||||||
BTN_DANGER_OUTLINE, BTN_ICON, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED,
|
BTN_DANGER_OUTLINE, BTN_ICON, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED,
|
||||||
CHECKBOX_CLASS, LoadingButton,
|
CHECKBOX_CLASS,
|
||||||
};
|
};
|
||||||
use crate::hooks::query::use_paginated;
|
use crate::hooks::query::use_paginated;
|
||||||
use crate::models::post::PostListItem;
|
use crate::models::post::PostListItem;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use dioxus::prelude::*;
|
|||||||
use crate::components::skeletons::atoms::SkeletonBox;
|
use crate::components::skeletons::atoms::SkeletonBox;
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::ui::{
|
use crate::components::ui::{
|
||||||
BTN_OUTLINE, BTN_PRIMARY_SM, BTN_TEXT_AMBER, BTN_TEXT_RED, FilterTabs, LoadingButton,
|
FilterTabs, LoadingButton, BTN_OUTLINE, BTN_PRIMARY_SM, BTN_TEXT_AMBER, BTN_TEXT_RED,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 系统管理的 5 个功能 tab。
|
/// 系统管理的 5 个功能 tab。
|
||||||
@ -452,9 +452,9 @@ fn format_uptime(secs: u64) -> String {
|
|||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||||
fn ServerStatusTab() -> Element {
|
fn ServerStatusTab() -> Element {
|
||||||
use crate::api::database::system_status::ServerStatus;
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::database::system_status::get_server_status;
|
use crate::api::database::system_status::get_server_status;
|
||||||
|
use crate::api::database::system_status::ServerStatus;
|
||||||
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
|
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
|
||||||
|
|
||||||
let mut status = use_signal(|| Option::<ServerStatus>::None);
|
let mut status = use_signal(|| Option::<ServerStatus>::None);
|
||||||
@ -728,15 +728,15 @@ fn ServerStatusTab() -> Element {
|
|||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||||
fn SqlConsoleTab() -> Element {
|
fn SqlConsoleTab() -> Element {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::api::database::schema::get_db_schema;
|
||||||
use crate::api::database::sql_console::SqlResult;
|
use crate::api::database::sql_console::SqlResult;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
|
use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::database::schema::get_db_schema;
|
use crate::codemirror_bridge;
|
||||||
use crate::components::sql_result_table::SqlResultTable;
|
use crate::components::sql_result_table::SqlResultTable;
|
||||||
use crate::components::ui::ADMIN_TABLE_CLASS;
|
use crate::components::ui::ADMIN_TABLE_CLASS;
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use crate::codemirror_bridge;
|
|
||||||
// use_resolved_theme 两种构建都用:resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。
|
// use_resolved_theme 两种构建都用:resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。
|
||||||
use crate::theme::use_resolved_theme;
|
use crate::theme::use_resolved_theme;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@ -775,7 +775,15 @@ fn SqlConsoleTab() -> Element {
|
|||||||
};
|
};
|
||||||
// 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
|
// 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
|
||||||
let lower = sql.to_lowercase();
|
let lower = sql.to_lowercase();
|
||||||
let looks_write = ["update ", "delete ", "insert ", "alter ", "drop ", "truncate ", "create "]
|
let looks_write = [
|
||||||
|
"update ",
|
||||||
|
"delete ",
|
||||||
|
"insert ",
|
||||||
|
"alter ",
|
||||||
|
"drop ",
|
||||||
|
"truncate ",
|
||||||
|
"create ",
|
||||||
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|k| lower.contains(k));
|
.any(|k| lower.contains(k));
|
||||||
if looks_write && !confirm_dangerous() {
|
if looks_write && !confirm_dangerous() {
|
||||||
@ -828,11 +836,14 @@ fn SqlConsoleTab() -> Element {
|
|||||||
// (Closure::new 要求 Fn 可重复调用)。闭包包装走借用调用,每次都读最新值。
|
// (Closure::new 要求 Fn 可重复调用)。闭包包装走借用调用,每次都读最新值。
|
||||||
#[allow(clippy::redundant_closure)]
|
#[allow(clippy::redundant_closure)]
|
||||||
let on_run_shortcut = Closure::new(move || {
|
let on_run_shortcut = Closure::new(move || {
|
||||||
dioxus::core::Runtime::current()
|
dioxus::core::Runtime::current().in_scope(scope_id, || execute_for_editor());
|
||||||
.in_scope(scope_id, || execute_for_editor());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let theme_name = if resolved() == ResolvedTheme::Dark { "dark" } else { "light" };
|
let theme_name = if resolved() == ResolvedTheme::Dark {
|
||||||
|
"dark"
|
||||||
|
} else {
|
||||||
|
"light"
|
||||||
|
};
|
||||||
let opts = codemirror_bridge::EditorOptions::new();
|
let opts = codemirror_bridge::EditorOptions::new();
|
||||||
opts.set_language("sql");
|
opts.set_language("sql");
|
||||||
opts.set_theme(theme_name);
|
opts.set_theme(theme_name);
|
||||||
@ -870,7 +881,11 @@ fn SqlConsoleTab() -> Element {
|
|||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let r = resolved();
|
let r = resolved();
|
||||||
if let Some(h) = editor_handle.read().as_ref() {
|
if let Some(h) = editor_handle.read().as_ref() {
|
||||||
h.instance().set_theme(if r == ResolvedTheme::Dark { "dark" } else { "light" });
|
h.instance().set_theme(if r == ResolvedTheme::Dark {
|
||||||
|
"dark"
|
||||||
|
} else {
|
||||||
|
"light"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -890,7 +905,15 @@ fn SqlConsoleTab() -> Element {
|
|||||||
};
|
};
|
||||||
// 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
|
// 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
|
||||||
let lower = sql.to_lowercase();
|
let lower = sql.to_lowercase();
|
||||||
let looks_write = ["update ", "delete ", "insert ", "alter ", "drop ", "truncate ", "create "]
|
let looks_write = [
|
||||||
|
"update ",
|
||||||
|
"delete ",
|
||||||
|
"insert ",
|
||||||
|
"alter ",
|
||||||
|
"drop ",
|
||||||
|
"truncate ",
|
||||||
|
"create ",
|
||||||
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|k| lower.contains(k));
|
.any(|k| lower.contains(k));
|
||||||
if looks_write && !confirm_dangerous() {
|
if looks_write && !confirm_dangerous() {
|
||||||
@ -923,12 +946,18 @@ fn SqlConsoleTab() -> Element {
|
|||||||
let current_result = result.read().clone();
|
let current_result = result.read().clone();
|
||||||
let current_error = error.read().clone();
|
let current_error = error.read().clone();
|
||||||
let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0);
|
let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0);
|
||||||
let affected = current_result.as_ref().map(|r| r.affected_rows).unwrap_or(0);
|
let affected = current_result
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.affected_rows)
|
||||||
|
.unwrap_or(0);
|
||||||
let stmt_type = current_result
|
let stmt_type = current_result
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|r| r.statement_type.clone())
|
.map(|r| r.statement_type.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let truncated = current_result.as_ref().map(|r| r.truncated).unwrap_or(false);
|
let truncated = current_result
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.truncated)
|
||||||
|
.unwrap_or(false);
|
||||||
let explain = current_result.as_ref().and_then(|r| r.explain.clone());
|
let explain = current_result.as_ref().and_then(|r| r.explain.clone());
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
@ -1188,9 +1217,11 @@ fn urlencode(s: &str) -> String {
|
|||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||||
fn BackupTab() -> Element {
|
fn BackupTab() -> Element {
|
||||||
use crate::api::database::backup::BackupInfo;
|
use crate::api::database::backup::BackupInfo;
|
||||||
use crate::api::database::tasks::TaskProgress;
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::database::backup::{create_backup, delete_backup, list_backups, restore_backup};
|
use crate::api::database::backup::{
|
||||||
|
create_backup, delete_backup, list_backups, restore_backup,
|
||||||
|
};
|
||||||
|
use crate::api::database::tasks::TaskProgress;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::database::tasks::{get_task_progress, TaskStatus};
|
use crate::api::database::tasks::{get_task_progress, TaskStatus};
|
||||||
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
|
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
|
||||||
@ -1261,8 +1292,8 @@ fn BackupTab() -> Element {
|
|||||||
crate::utils::time::sleep_ms(1500).await;
|
crate::utils::time::sleep_ms(1500).await;
|
||||||
match get_task_progress(tid.clone()).await {
|
match get_task_progress(tid.clone()).await {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
let done = p.status == TaskStatus::Done
|
let done =
|
||||||
|| p.status == TaskStatus::Failed;
|
p.status == TaskStatus::Done || p.status == TaskStatus::Failed;
|
||||||
active_progress.set(Some(p));
|
active_progress.set(Some(p));
|
||||||
if done {
|
if done {
|
||||||
// 刷新列表(备份完成后新文件出现)并清理任务态
|
// 刷新列表(备份完成后新文件出现)并清理任务态
|
||||||
@ -1609,7 +1640,11 @@ mod tests {
|
|||||||
SystemTab::Backup,
|
SystemTab::Backup,
|
||||||
] {
|
] {
|
||||||
let s = tab.as_str();
|
let s = tab.as_str();
|
||||||
assert_eq!(SystemTab::from_str(s), Ok(tab), "roundtrip failed for {tab:?}");
|
assert_eq!(
|
||||||
|
SystemTab::from_str(s),
|
||||||
|
Ok(tab),
|
||||||
|
"roundtrip failed for {tab:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ use crate::api::posts::{
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle};
|
use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle};
|
||||||
// 共享上传状态类型:两端都编译(rsx 在 server SSR 时也要渲染这些结构)。
|
// 共享上传状态类型:两端都编译(rsx 在 server SSR 时也要渲染这些结构)。
|
||||||
use crate::components::ui::{BTN_CLOSE_ICON, BTN_PRIMARY_SM, LoadingButton};
|
use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
|
||||||
use crate::components::write_skeleton::WriteSkeleton;
|
use crate::components::write_skeleton::WriteSkeleton;
|
||||||
use crate::models::post::Post;
|
use crate::models::post::Post;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|||||||
@ -11,8 +11,8 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::api::posts::{search_posts, PostListResponse};
|
use crate::api::posts::{search_posts, PostListResponse};
|
||||||
use crate::components::post_card::PostCard;
|
|
||||||
use crate::components::empty_state::EmptyState;
|
use crate::components::empty_state::EmptyState;
|
||||||
|
use crate::components::post_card::PostCard;
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::search_skeleton::SearchSkeleton;
|
use crate::components::skeletons::search_skeleton::SearchSkeleton;
|
||||||
|
|
||||||
|
|||||||
@ -15,10 +15,10 @@ use dioxus::prelude::*;
|
|||||||
use dioxus::router::components::Link;
|
use dioxus::router::components::Link;
|
||||||
|
|
||||||
use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse};
|
use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse};
|
||||||
|
use crate::components::empty_state::EmptyState;
|
||||||
use crate::components::post_card::PostCard;
|
use crate::components::post_card::PostCard;
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::tags_skeleton::{TagDetailSkeleton, TagsSkeleton};
|
use crate::components::skeletons::tags_skeleton::{TagDetailSkeleton, TagsSkeleton};
|
||||||
use crate::components::empty_state::EmptyState;
|
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
/// 标签云页面组件,对应路由 `/tags`。
|
/// 标签云页面组件,对应路由 `/tags`。
|
||||||
|
|||||||
@ -11,7 +11,9 @@ use crate::components::admin_layout::AdminLayout;
|
|||||||
use crate::components::frontend_layout::FrontendLayout;
|
use crate::components::frontend_layout::FrontendLayout;
|
||||||
use crate::context::UserContext;
|
use crate::context::UserContext;
|
||||||
use crate::pages::about::About;
|
use crate::pages::about::About;
|
||||||
use crate::pages::admin::{Admin, AdminComments, AdminCommentsPage, Posts, Runner, System, Write, WriteEdit};
|
use crate::pages::admin::{
|
||||||
|
Admin, AdminComments, AdminCommentsPage, Posts, Runner, System, Write, WriteEdit,
|
||||||
|
};
|
||||||
use crate::pages::archives::Archives;
|
use crate::pages::archives::Archives;
|
||||||
use crate::pages::home::{Home, HomePage};
|
use crate::pages::home::{Home, HomePage};
|
||||||
use crate::pages::login::Login;
|
use crate::pages::login::Login;
|
||||||
|
|||||||
@ -261,7 +261,10 @@ pub fn use_theme_provider() -> Signal<Theme> {
|
|||||||
use_event_listener(
|
use_event_listener(
|
||||||
|| {
|
|| {
|
||||||
let window = web_sys::window()?;
|
let window = web_sys::window()?;
|
||||||
window.match_media("(prefers-color-scheme: dark)").ok().flatten()
|
window
|
||||||
|
.match_media("(prefers-color-scheme: dark)")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
},
|
},
|
||||||
"change",
|
"change",
|
||||||
move || {
|
move || {
|
||||||
|
|||||||
@ -16,9 +16,11 @@
|
|||||||
pub fn escape_html(input: &str) -> String {
|
pub fn escape_html(input: &str) -> String {
|
||||||
// 快速路径:无特殊字符直接 clone(大多纯文本走这里,零额外扫描)。
|
// 快速路径:无特殊字符直接 clone(大多纯文本走这里,零额外扫描)。
|
||||||
// memchr 风格的逐字节查找比总是分配 + 遍历更省。
|
// memchr 风格的逐字节查找比总是分配 + 遍历更省。
|
||||||
if !input.as_bytes().iter().any(|&b| {
|
if !input
|
||||||
matches!(b, b'&' | b'<' | b'>' | b'"' | b'\'')
|
.as_bytes()
|
||||||
}) {
|
.iter()
|
||||||
|
.any(|&b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''))
|
||||||
|
{
|
||||||
return input.to_string();
|
return input.to_string();
|
||||||
}
|
}
|
||||||
let mut out = String::with_capacity(input.len());
|
let mut out = String::with_capacity(input.len());
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user