diff --git a/build.rs b/build.rs index fdb1c71..cfbdcda 100644 --- a/build.rs +++ b/build.rs @@ -51,7 +51,11 @@ fn git_output(args: &[&str]) -> Option { if !out.status.success() { return None; } - String::from_utf8(out.stdout).ok()?.trim().to_string().into() + String::from_utf8(out.stdout) + .ok()? + .trim() + .to_string() + .into() } /// `rustc --version`,采集编译工具链。 diff --git a/src/api/code_runner/execute.rs b/src/api/code_runner/execute.rs index 378c596..08f51ee 100644 --- a/src/api/code_runner/execute.rs +++ b/src/api/code_runner/execute.rs @@ -159,9 +159,7 @@ pub async fn start_exec(req: ExecRequest) -> Result { final_limits, ) .await; - let duration_ms = (chrono::Utc::now() - start_time) - .num_milliseconds() - .max(0) as u64; + let duration_ms = (chrono::Utc::now() - start_time).num_milliseconds().max(0) as u64; drop(ticket); // 显式释放信号量 @@ -287,9 +285,7 @@ pub async fn start_exec_stream(req: ExecRequest) -> Result { - return Ok(CommentResponse::error("post_not_found", "文章不存在".to_string())); + return Ok(CommentResponse::error( + "post_not_found", + "文章不存在".to_string(), + )); } Some(row) => { let status: String = row.get("status"); let deleted_at: Option> = row.get("deleted_at"); 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 { None => { - return Ok(CommentResponse::error("parent_not_found", "父评论不存在".to_string())); + return Ok(CommentResponse::error( + "parent_not_found", + "父评论不存在".to_string(), + )); } Some(row) => { 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"); 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" { - return Ok(CommentResponse::error("parent_not_approved", "父评论未通过审核".to_string())); + return Ok(CommentResponse::error( + "parent_not_approved", + "父评论未通过审核".to_string(), + )); } depth = parent_depth + 1; 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() { // 重复:回滚(释放 advisory 锁)后返回。 tx.rollback().await.ok(); - return Ok(CommentResponse::error("duplicate", "请勿重复提交".to_string())); + return Ok(CommentResponse::error( + "duplicate", + "请勿重复提交".to_string(), + )); } // 插入评论,默认状态为 pending,等待管理员审核。 @@ -214,7 +235,12 @@ pub async fn create_comment( cache::invalidate_comments_by_post(post_id).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"))] unreachable!() diff --git a/src/api/comments/markdown.rs b/src/api/comments/markdown.rs index 4e14f6c..68f7c04 100644 --- a/src/api/comments/markdown.rs +++ b/src/api/comments/markdown.rs @@ -58,7 +58,10 @@ pub fn render_comment_markdown(md: &str) -> String { crate::highlight::server::highlight_code(&code_buffer, Some(lang)); format!("
{}
", highlighted) } else { - format!("
{}
", crate::utils::html::escape_html(&code_buffer)) + format!( + "
{}
", + crate::utils::html::escape_html(&code_buffer) + ) }; events.push(Event::Html(html.into())); in_codeblock = false; diff --git a/src/api/comments/update.rs b/src/api/comments/update.rs index f14ec63..4f396fb 100644 --- a/src/api/comments/update.rs +++ b/src/api/comments/update.rs @@ -34,7 +34,10 @@ pub async fn approve_comment(id: i64) -> Result let post_id: i32 = match row { Some(r) => r.get("post_id"), None => { - return Ok(CommentResponse::error("not_found", "评论不存在".to_string())); + return Ok(CommentResponse::error( + "not_found", + "评论不存在".to_string(), + )); } }; diff --git a/src/api/database/backup.rs b/src/api/database/backup.rs index 39f3d34..0105be8 100644 --- a/src/api/database/backup.rs +++ b/src/api/database/backup.rs @@ -363,7 +363,9 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result Result<(String, String), (StatusCode, String)> /// 简单标识符校验(字母数字下划线,防 SQL 注入与路径穿越)。 fn is_simple_ident(s: &str) -> bool { - !s.is_empty() - && s.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_') + !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') } /// 判断 AST 是否只读(SELECT/EXPLAIN)。 @@ -169,9 +167,7 @@ async fn export_sql( }; for r in &rows { - let vals: Vec = (0..r.len()) - .map(|i| sql_quote_cell(r, i)) - .collect(); + let vals: Vec = (0..r.len()).map(|i| sql_quote_cell(r, i)).collect(); out.push_str(&format!( "INSERT INTO {} {} VALUES ({});\n", &table_name, @@ -190,7 +186,11 @@ async fn export_sql( /// 把一个单元格值转成 SQL 字面量(字符串单引号转义,数字原样,NULL)。 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 { "int2" => row .try_get::<_, Option>(idx) diff --git a/src/api/database/mod.rs b/src/api/database/mod.rs index 827a7b5..0f81578 100644 --- a/src/api/database/mod.rs +++ b/src/api/database/mod.rs @@ -6,18 +6,18 @@ //! 后续 task 会新增:`system_status`(服务器状态)、`sql_console`(SQL 执行+护栏)、 //! `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 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; diff --git a/src/api/database/sql_console.rs b/src/api/database/sql_console.rs index b6b416d..296f33d 100644 --- a/src/api/database/sql_console.rs +++ b/src/api/database/sql_console.rs @@ -112,10 +112,7 @@ enum GuardResult { /// 护栏 1+2:sqlparser 解析后遍历 AST,检查高危语句与无 WHERE 的 UPDATE/DELETE。 #[cfg(feature = "server")] -fn check_guards( - asts: &[sqlparser::ast::Statement], - confirm_dangerous: bool, -) -> GuardResult { +fn check_guards(asts: &[sqlparser::ast::Statement], confirm_dangerous: bool) -> GuardResult { use sqlparser::ast::{ObjectType, Statement}; for stmt in asts { @@ -147,13 +144,17 @@ fn check_guards( } } // 护栏 2:UPDATE 无 WHERE - Statement::Update(sqlparser::ast::Update { selection: None, .. }) => { + Statement::Update(sqlparser::ast::Update { + selection: None, .. + }) => { return GuardResult::Forbidden( "UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(), ); } // 护栏 2:DELETE 无 WHERE - Statement::Delete(sqlparser::ast::Delete { selection: None, .. }) => { + Statement::Delete(sqlparser::ast::Delete { + selection: None, .. + }) => { return GuardResult::Forbidden( "DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(), ); @@ -187,17 +188,18 @@ fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String { #[cfg(feature = "server")] fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool { use sqlparser::ast::Statement; - matches!( - stmt, - Statement::Query(_) | Statement::Explain { .. } - ) + matches!(stmt, Statement::Query(_) | Statement::Explain { .. }) } /// 把一列的值转成 JSON(按 PG 类型名分发)。 #[cfg(feature = "server")] fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value { 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 { "int2" => row .try_get::<_, Option>(idx) @@ -349,18 +351,10 @@ async fn execute_one( if read_only { // 只读:取结果集。列名从第一行取(空结果集时无列名,前端容错)。 - let rows = client - .query(stmt_sql, &[]) - .await - .map_err(AppError::query)?; + let rows = client.query(stmt_sql, &[]).await.map_err(AppError::query)?; let columns: Vec = rows .first() - .map(|r| { - r.columns() - .iter() - .map(|c| c.name().to_string()) - .collect() - }) + .map(|r| r.columns().iter().map(|c| c.name().to_string()).collect()) .unwrap_or_default(); let mut data: Vec> = Vec::new(); let mut truncated = false; @@ -431,10 +425,7 @@ mod tests { "CREATE DATABASE evil", "Drop Database x", ] { - assert!( - is_absolutely_forbidden(sql).is_some(), - "应拦截: {sql:?}" - ); + assert!(is_absolutely_forbidden(sql).is_some(), "应拦截: {sql:?}"); } } @@ -458,9 +449,18 @@ mod tests { #[test] fn precheck_returns_canonical_name_for_error_message() { - assert_eq!(is_absolutely_forbidden("DROP DATABASE x"), 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")); + assert_eq!( + is_absolutely_forbidden("DROP DATABASE x"), + 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] @@ -499,10 +499,7 @@ mod tests { "DELETE FROM t WHERE id = 1", "CREATE INDEX idx ON t (col)", ] { - assert!( - is_absolutely_forbidden(sql).is_none(), - "不应误拦: {sql:?}" - ); + assert!(is_absolutely_forbidden(sql).is_none(), "不应误拦: {sql:?}"); } } @@ -513,10 +510,9 @@ mod tests { // 即使字符串预检被某种方式绕过,AST 层仍绝禁 DROP SCHEMA。 let asts = parse("DROP SCHEMA public"); match check_guards(&asts, true) { - GuardResult::Forbidden(msg) => assert!( - msg.contains("SCHEMA"), - "DROP SCHEMA 应被禁止, 得到: {msg}" - ), + GuardResult::Forbidden(msg) => { + assert!(msg.contains("SCHEMA"), "DROP SCHEMA 应被禁止, 得到: {msg}") + } other => panic!("DROP SCHEMA 应 Forbidden, 得到 {other:?}"), } } diff --git a/src/api/database/system_status.rs b/src/api/database/system_status.rs index bf72674..f32c214 100644 --- a/src/api/database/system_status.rs +++ b/src/api/database/system_status.rs @@ -71,7 +71,10 @@ pub async fn get_server_status() -> Result { // 活跃会话数 let client = get_conn().await.map_err(AppError::db_conn)?; 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 .map_err(AppError::query)? .get(0); diff --git a/src/api/database/tasks.rs b/src/api/database/tasks.rs index 81130a1..d338c9a 100644 --- a/src/api/database/tasks.rs +++ b/src/api/database/tasks.rs @@ -15,9 +15,9 @@ use serde::{Deserialize, Serialize}; use crate::api::auth::get_current_admin_user; // DashMap / LazyLock 仅 server 构建持有任务进度表;WASM 端只序列化 TaskProgress。 #[cfg(feature = "server")] -use std::sync::LazyLock; -#[cfg(feature = "server")] use dashmap::DashMap; +#[cfg(feature = "server")] +use std::sync::LazyLock; #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub enum TaskKind { @@ -93,7 +93,9 @@ pub(super) fn update( #[cfg(feature = "server")] fn gc_old() { 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 Self { - tracing::error!( - "Query failed: {}", - crate::db::format_with_sources(&e) - ); + tracing::error!("Query failed: {}", crate::db::format_with_sources(&e)); AppError::Query("query error".to_string()) } /// 记录并包装数据库事务错误。 pub fn tx(e: impl std::error::Error) -> Self { - tracing::error!( - "Transaction failed: {}", - crate::db::format_with_sources(&e) - ); + tracing::error!("Transaction failed: {}", crate::db::format_with_sources(&e)); AppError::Transaction("transaction error".to_string()) } } diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 2026fa9..7327361 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -475,7 +475,11 @@ mod tests { *received.borrow_mut() = p.to_string(); 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] @@ -493,10 +497,7 @@ mod tests { fn wrap_images_with_blur_omits_alt_attr_when_empty() { let html = r#""#; let result = wrap_images_with_blur_with(html, |_| None); - assert!( - !result.contains("alt=\""), - "无 alt 时不应生成空 alt 属性" - ); + assert!(!result.contains("alt=\""), "无 alt 时不应生成空 alt 属性"); } #[test] @@ -717,7 +718,9 @@ mod tests { ); // data-source 携带 HTML 转义后的原始源码(单引号转义为 ')。 assert!( - result.html.contains(r#"data-source="print('hi')"#), + result + .html + .contains(r#"data-source="print('hi')"#), "data-source 应含转义后的源码, got: {}", result.html ); @@ -740,7 +743,9 @@ console.log(1) // overrides JSON 应被 HTML 转义后放入属性(双引号变 "),不得出现裸引号越界。 // 字段顺序按 serde 派生默认(字母序),cpu_cores 在前。 assert!( - result.html.contains("data-overrides=\"{"cpu_cores":1.0"), + result + .html + .contains("data-overrides=\"{"cpu_cores":1.0"), "overrides 应 HTML 转义, got: {}", result.html ); @@ -767,7 +772,9 @@ console.log(1) fn render_markdown_plain_fence_without_runnable_no_data_attrs() { let result = render_markdown_enhanced("```python\nprint(1)\n```"); assert!(!result.html.contains("data-runnable")); - assert!(result.html.contains(r#"
"#));
+        assert!(result
+            .html
+            .contains(r#"
"#));
     }
 
     #[test]
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 94cfb52..aa2a067 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -6,6 +6,8 @@
 
 /// 认证相关的 Dioxus server function。
 pub mod auth;
+/// 代码运行接口与数据结构。
+pub mod code_runner;
 /// 评论相关接口。
 pub mod comments;
 /// CSRF 防护中间件。
@@ -32,5 +34,3 @@ pub mod settings;
 pub mod slug;
 /// 图片上传的 Axum 处理器。
 pub mod upload;
-/// 代码运行接口与数据结构。
-pub mod code_runner;
diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs
index 43b2d1c..4c8bdb7 100644
--- a/src/api/posts/create.rs
+++ b/src/api/posts/create.rs
@@ -50,7 +50,9 @@ pub async fn create_post(
         Some(ref s) if !s.trim().is_empty() => {
             let s = s.trim();
             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()
         }
@@ -139,7 +141,11 @@ pub async fn create_post(
         // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
         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"))]
diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs
index 5881b20..203b4de 100644
--- a/src/api/posts/delete.rs
+++ b/src/api/posts/delete.rs
@@ -74,7 +74,11 @@ pub async fn delete_post(post_id: i32) -> Result Result Result {
+pub async fn rebuild_post_content_html(post_id: i32) -> Result {
     let _user = get_current_admin_user().await?;
 
     #[cfg(feature = "server")]
@@ -224,7 +222,11 @@ pub async fn rebuild_post_content_html(
         // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
         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"))]
diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs
index 8011bce..b2bae1f 100644
--- a/src/api/posts/trash.rs
+++ b/src/api/posts/trash.rs
@@ -85,7 +85,11 @@ pub async fn restore_post(post_id: i32) -> Result Result) -> Result Result {
             crate::ssr_cache::bump_global_generation();
         }
 
-        Ok(CreatePostResponse::ok_msg(format!("已清空回收站({result} 篇)")))
+        Ok(CreatePostResponse::ok_msg(format!(
+            "已清空回收站({result} 篇)"
+        )))
     }
 
     #[cfg(not(feature = "server"))]
diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs
index f675c30..c950e3d 100644
--- a/src/api/posts/update.rs
+++ b/src/api/posts/update.rs
@@ -208,7 +208,11 @@ pub async fn update_post(
         // 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
         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"))]
diff --git a/src/api/sanitizer.rs b/src/api/sanitizer.rs
index 6482d44..a278716 100644
--- a/src/api/sanitizer.rs
+++ b/src/api/sanitizer.rs
@@ -636,7 +636,8 @@ mod tests {
     #[test]
     fn clean_html_input_rejects_type_image() {
         // type=image 是已知的 input 滥用面(可配合 src 触发请求),必须整体移除
-        let input = r#"
  • 文本
"#; + let input = + r#"
  • 文本
"#; let result = clean_html(input); assert!( !result.contains("input"), @@ -703,7 +704,10 @@ mod tests { // 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick)应被剥离。 let input = r#"
x
"#; let result = clean_html(input); - assert!(result.contains("data-runnable"), "白名单 data-runnable 应保留"); + assert!( + result.contains("data-runnable"), + "白名单 data-runnable 应保留" + ); assert!( !result.contains("data-evil"), "未知 data-* 应被剥离, got: {result}" diff --git a/src/api/upload.rs b/src/api/upload.rs index fcf73b7..dd986b3 100644 --- a/src/api/upload.rs +++ b/src/api/upload.rs @@ -139,12 +139,18 @@ pub async fn upload_image( Ok(d) => d, Err(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 { - return Err(upload_error(StatusCode::PAYLOAD_TOO_LARGE, "文件超过大小限制")); + return Err(upload_error( + StatusCode::PAYLOAD_TOO_LARGE, + "文件超过大小限制", + )); } // 校验文件头 magic bytes,防止仅修改扩展名/Content-Type 上传非图片文件。 @@ -174,7 +180,10 @@ pub async fn upload_image( .await .map_err(|_| upload_error(StatusCode::INTERNAL_SERVER_ERROR, "图片校验任务失败"))?; 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 { 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 { 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()); diff --git a/src/build_info.rs b/src/build_info.rs index 0b97e71..31b3992 100644 --- a/src/build_info.rs +++ b/src/build_info.rs @@ -91,8 +91,15 @@ mod tests { fn build_time_parses_as_unix_seconds() { // build.rs 存的是 Unix 秒,运行时应能解析回时间戳。 let parsed = BUILD_INFO.build_time.parse::(); - assert!(parsed.is_ok(), "build_time not a unix second: {}", BUILD_INFO.build_time); - assert!(parsed.unwrap() > 1_600_000_000, "build_time implausibly old"); + assert!( + 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] diff --git a/src/cache.rs b/src/cache.rs index 864d754..e605008 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -228,7 +228,8 @@ impl CacheStats { self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } 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 调用)。 #[cfg(feature = "server")] pub fn cache_stats() -> Vec { - fn snap( - stats: &CacheStats, - entry_count: u64, - ) -> CacheStatSnapshot { + fn snap(stats: &CacheStats, entry_count: u64) -> CacheStatSnapshot { let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed); let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed); 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 { name: stats.name, entry_count, diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index 49b1c66..69367a9 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -20,7 +20,6 @@ pub fn AdminLayout() -> Element { let navigator = dioxus::router::navigator(); let route = use_route::(); - use_effect(move || { if !(ctx.checked)() { (ctx.checked).set(true); @@ -58,14 +57,19 @@ pub fn AdminLayout() -> Element { // write 路由例外:卡片不滚动(overflow-hidden),main 作为 flex 容器不带头尾 padding, // 由 write 页面自身组织 [内容区 flex-1 overflow-y-auto] + [底栏 flex-shrink-0] 的分区布局, // 这样底栏永远贴卡片底部不随内容滚动,也不会出现 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 { "flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0" } else { "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! { aside { class: "w-64 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-4 bg-[var(--color-paper-entry)]", diff --git a/src/components/code_runner/runner.rs b/src/components/code_runner/runner.rs index 3568830..0d108e8 100644 --- a/src/components/code_runner/runner.rs +++ b/src/components/code_runner/runner.rs @@ -11,10 +11,10 @@ use dioxus::prelude::*; -#[cfg(not(target_arch = "wasm32"))] -use crate::api::code_runner::execute::{get_exec_result, start_exec}; #[cfg(target_arch = "wasm32")] 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::components::ui::SPINNER_SVG; use crate::infra::runner_config::ResourceLimits; @@ -272,9 +272,7 @@ pub fn CodeRunner( opts.set_font_size(13); opts.set_on_ready(&on_ready); - if let Ok(Some(inst)) = - xterm_bridge::get_module().create(&mount_container_id, &opts) - { + if let Ok(Some(inst)) = xterm_bridge::get_module().create(&mount_container_id, &opts) { let handle = xterm_bridge::TerminalHandle::new(inst, on_ready); term_handle.set(Some(handle)); } @@ -736,13 +734,12 @@ mod sse_consumer { match get_exec_result(task_id.to_string()).await { Ok(task) => { stage.set(task.stage.clone()); - let terminal = task.status != ExecStatus::Queued - && task.status != ExecStatus::Running; + let terminal = + task.status != ExecStatus::Queued && task.status != ExecStatus::Running; if terminal { running.set(false); if let Some(res) = task.result { - let out = - format!("Stdout:\n{}\nStderr:\n{}", res.stdout, res.stderr); + let out = format!("Stdout:\n{}\nStderr:\n{}", res.stdout, res.stderr); output.set(out); exit_info.set(format!( "耗时: {}ms · 状态: {}", diff --git a/src/components/comments/list.rs b/src/components/comments/list.rs index b34f7d9..76da43f 100644 --- a/src/components/comments/list.rs +++ b/src/components/comments/list.rs @@ -6,8 +6,8 @@ use dioxus::prelude::*; use crate::components::comments::item::CommentItem; use crate::components::comments::pending_item::PendingCommentItem; -use crate::utils::comment_storage::PendingComment; use crate::models::comment::PublicComment; +use crate::utils::comment_storage::PendingComment; /// 合并后的评论节点,可能是已审核或待审核评论。 #[derive(Clone)] diff --git a/src/components/comments/section.rs b/src/components/comments/section.rs index d3b5c4e..00f4d37 100644 --- a/src/components/comments/section.rs +++ b/src/components/comments/section.rs @@ -40,12 +40,10 @@ pub struct CommentContext { /// - 空评论时展示提示文案 #[component] pub fn CommentSection(post_id: i32) -> Element { - let mut ctx = use_context_provider(|| { - CommentContext { - active_reply: Signal::new(None), - refresh_trigger: Signal::new(false), - pending_comments: Signal::new(Vec::new()), - } + let mut ctx = use_context_provider(|| CommentContext { + active_reply: Signal::new(None), + refresh_trigger: Signal::new(false), + pending_comments: Signal::new(Vec::new()), }); // 挂载后从本地存储异步加载待审核评论以防 SSR Hydration Mismatch @@ -72,7 +70,9 @@ pub fn CommentSection(post_id: i32) -> Element { .collect(); if !to_remove.is_empty() { 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) => { @@ -85,9 +85,7 @@ pub fn CommentSection(post_id: i32) -> Element { // 评论数据资源,refresh_trigger 变化时自动重新加载 let comments_resource = use_resource(move || { let _ = (ctx.refresh_trigger)(); - async move { - get_comments(post_id).await - } + async move { get_comments(post_id).await } }); let data = comments_resource.read(); diff --git a/src/components/post/post_content.rs b/src/components/post/post_content.rs index 73079f9..293db4c 100644 --- a/src/components/post/post_content.rs +++ b/src/components/post/post_content.rs @@ -262,9 +262,6 @@ mod tests { #[test] fn decode_html_entities_roundtrip() { assert_eq!(decode_html_entities("print('hi')"), "print('hi')"); - assert_eq!( - decode_html_entities(""<>&"), - "\"<>&" - ); + assert_eq!(decode_html_entities(""<>&"), "\"<>&"); } } diff --git a/src/components/ui.rs b/src/components/ui.rs index cc23c82..cb10ed1 100644 --- a/src/components/ui.rs +++ b/src/components/ui.rs @@ -317,7 +317,8 @@ pub fn Popover( use dioxus::prelude::{use_drop, use_effect, use_hook}; use std::cell::RefCell; use std::rc::Rc; - type EscState = Rc>>>; + type EscState = + Rc>>>; let state: EscState = use_hook(|| Rc::new(RefCell::new(None))); let state_for_drop = state.clone(); let open_for_effect = open; @@ -332,12 +333,13 @@ pub fn Popover( let on_close_for_esc = on_close_for_esc; // Closure 带 KeyboardEvent 参数:浏览器调用 handler 时传入事件对象, // 无需依赖已废弃的 window.event()。as_ref + unchecked_ref 转成 JS Function。 - let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| { - if ev.key() == "Escape" { - on_close_for_esc.call(()); - } - }) - as Box); + let closure = + wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| { + if ev.key() == "Escape" { + on_close_for_esc.call(()); + } + }) + as Box); let _ = window.add_event_listener_with_callback( "keydown", wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()), @@ -362,7 +364,11 @@ pub fn Popover( // 面板定位:top/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 { // top:面板在点击点上方——用 bottom 锚定 viewport 底,差值即视口高度 - y + 间隙。 // 视口高度用 100vh,纯 CSS 无需 JS 读取 scrollHeight。 diff --git a/src/db/migrate.rs b/src/db/migrate.rs index 2bdfb34..20b95f5 100644 --- a/src/db/migrate.rs +++ b/src/db/migrate.rs @@ -95,10 +95,18 @@ impl std::fmt::Display for MigrateError { // 无信息量的 `db error`,真正的 message/SQLSTATE/约束名藏在 source() 里。 match self { 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) => { - 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 } => { write!( diff --git a/src/db/pool.rs b/src/db/pool.rs index 0c4f158..027b4a2 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -302,15 +302,12 @@ pub async fn ensure_database() -> Result<(), String> { // 5. 不存在则创建。db_name 已通过 is_simple_ident 校验,可安全拼到 SQL。 tracing::info!("target database {:?} does not exist, creating", db_name); let stmt = format!("CREATE DATABASE {db_name}"); - client - .batch_execute(&stmt) - .await - .map_err(|e| { - format!( - "failed to create database {db_name:?}: {}", - crate::db::format_with_sources(&e) - ) - })?; + client.batch_execute(&stmt).await.map_err(|e| { + format!( + "failed to create database {db_name:?}: {}", + crate::db::format_with_sources(&e) + ) + })?; tracing::info!("created database {:?}", db_name); Ok(()) } diff --git a/src/hooks/event_listener.rs b/src/hooks/event_listener.rs index 98b24cb..b37077f 100644 --- a/src/hooks/event_listener.rs +++ b/src/hooks/event_listener.rs @@ -42,8 +42,7 @@ where // 用 use_hook 持有 (Closure, target),在整个组件生命周期内复用; // use_drop 时 take 出来移除监听,防止泄漏。 - type ListenerState = - Rc, T)>>>; + type ListenerState = Rc, T)>>>; let state: ListenerState = use_hook(|| Rc::new(RefCell::new(None))); let state_for_drop = state.clone(); @@ -53,14 +52,17 @@ where let mut handler = Some(handler); use_effect(move || { - let Some(acquire) = acquire.take() else { return }; - let Some(mut handler) = handler.take() else { return }; + let Some(acquire) = acquire.take() else { + return; + }; + let Some(mut handler) = handler.take() else { + return; + }; let Some(target) = acquire() else { return }; let target_clone = target.clone(); let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || { handler(); - }) - as Box); + }) as Box); let _ = target_clone.as_ref().add_event_listener_with_callback( event, wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()), diff --git a/src/hooks/query.rs b/src/hooks/query.rs index e4cca0b..9a80716 100644 --- a/src/hooks/query.rs +++ b/src/hooks/query.rs @@ -48,11 +48,7 @@ pub struct PaginatedState { /// ``` #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] #[allow(unused_variables)] -pub fn use_paginated( - page: P, - per_page: i32, - fetch: F, -) -> PaginatedState +pub fn use_paginated(page: P, per_page: i32, fetch: F) -> PaginatedState where T: PartialEq + Clone + 'static, P: Fn() -> i32 + Copy + 'static, diff --git a/src/infra/docker.rs b/src/infra/docker.rs index bc2c099..0ca76ee 100644 --- a/src/infra/docker.rs +++ b/src/infra/docker.rs @@ -1,23 +1,59 @@ +use futures::StreamExt; use std::collections::HashMap; use std::sync::LazyLock; use std::time::Duration; 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 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 = LazyLock::new(|| { - Docker::connect_with_unix(&RUNNER_CONFIG.docker_socket_path, 120, bollard::API_DEFAULT_VERSION) - .expect("Failed to connect to Docker daemon via unix socket") +/// 共享的 Docker 客户端。 +/// +/// `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> = 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 { let mut tmpfs = HashMap::new(); // /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), memory: Some(memory), 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), tmpfs: Some(tmpfs), pids_limit: Some(64), @@ -46,9 +86,11 @@ pub fn build_host_config(limits: &ResourceLimits) -> HostConfig { // 不设 nproc:RLIMIT_NPROC 在 setrlimit 时按 UID 计数,配合 non-root 用户会让 // 容器初始 exec /bin/sh 直接 EAGAIN("exec: resource temporarily unavailable"), // 与容器内实际进程数无关。pids_limit 已在 cgroup 层兜底,nproc 是冗余且有害的双重约束。 - ulimits: Some(vec![ - ResourcesUlimits { name: Some("nofile".to_string()), soft: Some(64), hard: Some(64) }, - ]), + ulimits: Some(vec![ResourcesUlimits { + name: Some("nofile".to_string()), + soft: Some(64), + hard: Some(64), + }]), cap_drop: Some(vec!["ALL".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 @@ -112,7 +154,7 @@ pub async fn run_in_container( ext: &str, limits: ResourceLimits, ) -> Result<(Option, String, String, bool), bollard::errors::Error> { - let docker = &*DOCKER_CLIENT; + let docker = get_docker()?; 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 @@ -133,10 +175,9 @@ pub async fn run_in_container( ..Default::default() }; - let container = docker.create_container( - None::, - config - ).await?; + let container = docker + .create_container(None::, config) + .await?; let container_id = container.id; let _guard = ContainerGuard { @@ -145,17 +186,19 @@ pub async fn run_in_container( }; // Attach to container to stream stdin, stdout, and stderr - let attach_res = docker.attach_container( - &container_id, - Some(AttachContainerOptions { - stdin: true, - stdout: true, - stderr: true, - stream: true, - logs: false, - ..Default::default() - }) - ).await; + let attach_res = docker + .attach_container( + &container_id, + Some(AttachContainerOptions { + stdin: true, + stdout: true, + stderr: true, + stream: true, + logs: false, + ..Default::default() + }), + ) + .await; let (mut writer, mut stream) = match attach_res { 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() { 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); @@ -214,14 +257,16 @@ pub async fn run_in_container( Ok(chunk) => { match chunk { 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 { let to_add = message.len().min(remaining); stdout_buf.extend_from_slice(&message[..to_add]); } } 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 { let to_add = message.len().min(remaining); stderr_buf.extend_from_slice(&message[..to_add]); @@ -242,9 +287,10 @@ pub async fn run_in_container( // Check OOM status let inspect = docker.inspect_container(&container_id, None).await; - let oom_killed = inspect.ok().and_then(|info| { - info.state.and_then(|s| s.oom_killed) - }).unwrap_or(false); + let oom_killed = inspect + .ok() + .and_then(|info| info.state.and_then(|s| s.oom_killed)) + .unwrap_or(false); // Truncate output to limits.output_bytes let limit_bytes = limits.output_bytes as usize; @@ -256,7 +302,7 @@ pub async fn run_in_container( if timed_out { 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, tx: tokio::sync::mpsc::Sender, ) -> Result<(Option, String, String, bool, bool), bollard::errors::Error> { - let docker = &*DOCKER_CLIENT; + let docker = get_docker()?; let host_config = build_host_config(&limits); // 与 run_in_container 相同的 stdin 注入脚本。 @@ -399,7 +445,8 @@ pub async fn run_in_container_stream( match item { Ok(chunk) => match chunk { 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 { let to_add = message.len().min(remaining); let slice = &message[..to_add]; @@ -413,7 +460,8 @@ pub async fn run_in_container_stream( } } 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 { let to_add = message.len().min(remaining); let slice = &message[..to_add]; @@ -625,14 +673,7 @@ mod tests { output_bytes: 1024, allow_network: false, }; - let res = run_in_container( - "alpine:latest", - "sleep 10", - "", - "txt", - limits, - ) - .await; + let res = run_in_container("alpine:latest", "sleep 10", "", "txt", limits).await; assert!(res.is_err()); let err = res.unwrap_err(); @@ -648,13 +689,17 @@ mod tests { #[serial_test::serial] async fn test_run_in_container_cancellation() { use bollard::query_parameters::ListContainersOptions; - let docker = &*DOCKER_CLIENT; - - let before = docker.list_containers(Some(ListContainersOptions { - all: true, - ..Default::default() - })).await.unwrap(); - let before_ids: std::collections::HashSet = before.into_iter().map(|c| c.id.unwrap()).collect(); + let docker = get_docker().expect("test requires a running Docker daemon"); + + let before = docker + .list_containers(Some(ListContainersOptions { + all: true, + ..Default::default() + })) + .await + .unwrap(); + let before_ids: std::collections::HashSet = + before.into_iter().map(|c| c.id.unwrap()).collect(); let limits = ResourceLimits { cpu_cores: 1.0, @@ -664,13 +709,7 @@ mod tests { allow_network: false, }; - let run_fut = run_in_container( - "alpine:latest", - "sleep 100", - "", - "txt", - limits, - ); + let run_fut = run_in_container("alpine:latest", "sleep 100", "", "txt", limits); tokio::select! { _ = run_fut => { @@ -683,10 +722,13 @@ mod tests { tokio::time::sleep(Duration::from_secs(2)).await; - let after = docker.list_containers(Some(ListContainersOptions { - all: true, - ..Default::default() - })).await.unwrap(); + let after = docker + .list_containers(Some(ListContainersOptions { + all: true, + ..Default::default() + })) + .await + .unwrap(); let mut leaked = Vec::new(); for c in after { @@ -698,10 +740,15 @@ mod tests { let leaked_count = leaked.len(); for id in leaked { - let _ = docker.remove_container(&id, Some(RemoveContainerOptions { - force: true, - ..Default::default() - })).await; + let _ = docker + .remove_container( + &id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; } assert_eq!(leaked_count, 0, "Found {} leaked containers", leaked_count); diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 13ddc26..361aa92 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -1,4 +1,3 @@ #[cfg(feature = "server")] pub mod docker; pub mod runner_config; - diff --git a/src/infra/runner_config.rs b/src/infra/runner_config.rs index 26fedd9..89972bb 100644 --- a/src/infra/runner_config.rs +++ b/src/infra/runner_config.rs @@ -41,20 +41,51 @@ pub static RUNNER_CONFIG: LazyLock = LazyLock::new(|| { // CODE_RUNNER_LANGUAGES 未设置时默认全开(None):注册表里的语言均可用, // 新增语言无需同步白名单。设置为逗号分隔列表则收窄到这些语言。 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 { - max_cpu_cores: env::var("CODE_RUNNER_MAX_CPU_CORES").ok().and_then(|v| v.parse().ok()).unwrap_or(2.0), - max_memory_mb: env::var("CODE_RUNNER_MAX_MEMORY_MB").ok().and_then(|v| v.parse().ok()).unwrap_or(1024), - max_timeout_secs: env::var("CODE_RUNNER_MAX_TIMEOUT_SECS").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()), + max_cpu_cores: env::var("CODE_RUNNER_MAX_CPU_CORES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(2.0), + max_memory_mb: env::var("CODE_RUNNER_MAX_MEMORY_MB") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1024), + max_timeout_secs: env::var("CODE_RUNNER_MAX_TIMEOUT_SECS") + .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, } }); @@ -65,8 +96,16 @@ pub fn clamp_limits(merged: ResourceLimits, lang_allows_network: bool) -> Resour } #[cfg(feature = "server")] -fn clamp_limits_impl(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 }; +fn clamp_limits_impl( + 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 cpu_cores = if merged.cpu_cores.is_nan() { 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 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 { cpu_cores, diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index 879386b..358bbba 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -22,8 +22,8 @@ use crate::components::empty_state::{EmptyState, EmptyStateAction}; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::posts_skeleton::PostsSkeleton; use crate::components::ui::{ - Pagination, StatusBadge, Tooltip, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_OUTLINE, - BTN_PRIMARY, BTN_TEXT_ACCENT, BTN_TEXT_RED, SPINNER_SVG, + Pagination, StatusBadge, Tooltip, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_OUTLINE, BTN_PRIMARY, + BTN_TEXT_ACCENT, BTN_TEXT_RED, SPINNER_SVG, }; use crate::hooks::query::use_paginated; use crate::models::post::PostListItem; @@ -473,10 +473,9 @@ pub(super) fn PostsTabs( { use wasm_bindgen::JsCast; crate::utils::time::sleep_ms(50).await; - if let Some(el) = web_sys::window() - .and_then(|w| w.document()) - .and_then(|d| d.get_element_by_id(&format!("posts-tab-{id_prefix}-{active_key}"))) - { + if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| { + d.get_element_by_id(&format!("posts-tab-{id_prefix}-{active_key}")) + }) { if let Ok(html_el) = el.dyn_into::() { indicator_style.set(format!( "left: {}px; width: {}px; opacity: 1;", diff --git a/src/pages/admin/posts_trash.rs b/src/pages/admin/posts_trash.rs index cfe97dd..2d1ea7c 100644 --- a/src/pages/admin/posts_trash.rs +++ b/src/pages/admin/posts_trash.rs @@ -26,9 +26,9 @@ use crate::components::empty_state::EmptyState; use crate::components::skeletons::atoms::SkeletonBox; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; 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, - CHECKBOX_CLASS, LoadingButton, + CHECKBOX_CLASS, }; use crate::hooks::query::use_paginated; use crate::models::post::PostListItem; diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs index 0c847db..267d787 100644 --- a/src/pages/admin/system.rs +++ b/src/pages/admin/system.rs @@ -8,7 +8,7 @@ use dioxus::prelude::*; use crate::components::skeletons::atoms::SkeletonBox; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; 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。 @@ -452,9 +452,9 @@ fn format_uptime(secs: u64) -> String { #[allow(non_snake_case)] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] fn ServerStatusTab() -> Element { - use crate::api::database::system_status::ServerStatus; #[cfg(target_arch = "wasm32")] 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}; let mut status = use_signal(|| Option::::None); @@ -728,15 +728,15 @@ fn ServerStatusTab() -> Element { #[allow(non_snake_case)] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] fn SqlConsoleTab() -> Element { + #[cfg(target_arch = "wasm32")] + use crate::api::database::schema::get_db_schema; use crate::api::database::sql_console::SqlResult; #[cfg(target_arch = "wasm32")] use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts}; #[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::ui::ADMIN_TABLE_CLASS; - #[cfg(target_arch = "wasm32")] - use crate::codemirror_bridge; // use_resolved_theme 两种构建都用:resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。 use crate::theme::use_resolved_theme; #[cfg(target_arch = "wasm32")] @@ -775,9 +775,17 @@ fn SqlConsoleTab() -> Element { }; // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词) let lower = sql.to_lowercase(); - let looks_write = ["update ", "delete ", "insert ", "alter ", "drop ", "truncate ", "create "] - .iter() - .any(|k| lower.contains(k)); + let looks_write = [ + "update ", + "delete ", + "insert ", + "alter ", + "drop ", + "truncate ", + "create ", + ] + .iter() + .any(|k| lower.contains(k)); if looks_write && !confirm_dangerous() { // 简单提示;真正的高危放行靠 confirm_dangerous 开关 let confirmed = web_sys::window().and_then(|w| { @@ -828,11 +836,14 @@ fn SqlConsoleTab() -> Element { // (Closure::new 要求 Fn 可重复调用)。闭包包装走借用调用,每次都读最新值。 #[allow(clippy::redundant_closure)] let on_run_shortcut = Closure::new(move || { - dioxus::core::Runtime::current() - .in_scope(scope_id, || execute_for_editor()); + dioxus::core::Runtime::current().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(); opts.set_language("sql"); opts.set_theme(theme_name); @@ -870,7 +881,11 @@ fn SqlConsoleTab() -> Element { use_effect(move || { let r = resolved(); 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,9 +905,17 @@ fn SqlConsoleTab() -> Element { }; // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词) let lower = sql.to_lowercase(); - let looks_write = ["update ", "delete ", "insert ", "alter ", "drop ", "truncate ", "create "] - .iter() - .any(|k| lower.contains(k)); + let looks_write = [ + "update ", + "delete ", + "insert ", + "alter ", + "drop ", + "truncate ", + "create ", + ] + .iter() + .any(|k| lower.contains(k)); if looks_write && !confirm_dangerous() { // 简单提示;真正的高危放行靠 confirm_dangerous 开关 let confirmed = web_sys::window().and_then(|w| { @@ -923,12 +946,18 @@ fn SqlConsoleTab() -> Element { let current_result = result.read().clone(); let current_error = error.read().clone(); 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 .as_ref() .map(|r| r.statement_type.clone()) .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()); rsx! { @@ -1188,9 +1217,11 @@ fn urlencode(s: &str) -> String { #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] fn BackupTab() -> Element { use crate::api::database::backup::BackupInfo; - use crate::api::database::tasks::TaskProgress; #[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")] use crate::api::database::tasks::{get_task_progress, TaskStatus}; use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; @@ -1261,8 +1292,8 @@ fn BackupTab() -> Element { crate::utils::time::sleep_ms(1500).await; match get_task_progress(tid.clone()).await { Ok(p) => { - let done = p.status == TaskStatus::Done - || p.status == TaskStatus::Failed; + let done = + p.status == TaskStatus::Done || p.status == TaskStatus::Failed; active_progress.set(Some(p)); if done { // 刷新列表(备份完成后新文件出现)并清理任务态 @@ -1609,7 +1640,11 @@ mod tests { SystemTab::Backup, ] { 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:?}" + ); } } diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index 13bc157..e6a7747 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -17,7 +17,7 @@ use crate::api::posts::{ #[cfg(target_arch = "wasm32")] use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle}; // 共享上传状态类型:两端都编译(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::models::post::Post; use crate::router::Route; diff --git a/src/pages/search.rs b/src/pages/search.rs index c0b50b4..e70e02f 100644 --- a/src/pages/search.rs +++ b/src/pages/search.rs @@ -11,8 +11,8 @@ use dioxus::prelude::*; use crate::api::posts::{search_posts, PostListResponse}; -use crate::components::post_card::PostCard; use crate::components::empty_state::EmptyState; +use crate::components::post_card::PostCard; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::search_skeleton::SearchSkeleton; diff --git a/src/pages/tags.rs b/src/pages/tags.rs index e05a29c..b42511d 100644 --- a/src/pages/tags.rs +++ b/src/pages/tags.rs @@ -15,10 +15,10 @@ use dioxus::prelude::*; use dioxus::router::components::Link; 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::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::tags_skeleton::{TagDetailSkeleton, TagsSkeleton}; -use crate::components::empty_state::EmptyState; use crate::router::Route; /// 标签云页面组件,对应路由 `/tags`。 diff --git a/src/router.rs b/src/router.rs index b49948d..b369d39 100644 --- a/src/router.rs +++ b/src/router.rs @@ -11,7 +11,9 @@ use crate::components::admin_layout::AdminLayout; use crate::components::frontend_layout::FrontendLayout; use crate::context::UserContext; 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::home::{Home, HomePage}; use crate::pages::login::Login; diff --git a/src/theme.rs b/src/theme.rs index 43f1a8c..b871873 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -261,7 +261,10 @@ pub fn use_theme_provider() -> Signal { use_event_listener( || { let window = web_sys::window()?; - window.match_media("(prefers-color-scheme: dark)").ok().flatten() + window + .match_media("(prefers-color-scheme: dark)") + .ok() + .flatten() }, "change", move || { diff --git a/src/utils/html.rs b/src/utils/html.rs index ef2549f..00f76d9 100644 --- a/src/utils/html.rs +++ b/src/utils/html.rs @@ -16,9 +16,11 @@ pub fn escape_html(input: &str) -> String { // 快速路径:无特殊字符直接 clone(大多纯文本走这里,零额外扫描)。 // memchr 风格的逐字节查找比总是分配 + 遍历更省。 - if !input.as_bytes().iter().any(|&b| { - matches!(b, b'&' | b'<' | b'>' | b'"' | b'\'') - }) { + if !input + .as_bytes() + .iter() + .any(|&b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\'')) + { return input.to_string(); } let mut out = String::with_capacity(input.len());