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:
xfy 2026-07-15 17:22:06 +08:00
parent 71f3351c99
commit 65a7b1226f
49 changed files with 541 additions and 323 deletions

View File

@ -51,7 +51,11 @@ fn git_output(args: &[&str]) -> Option<String> {
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`,采集编译工具链。

View File

@ -159,9 +159,7 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
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<String, ServerFnError
tx,
)
.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); // 显式释放信号量

View File

@ -172,8 +172,9 @@ mod tests {
#[test]
fn parse_fence_info_with_overrides() {
let (lang, runnable, overrides) =
parse_fence_info(r#"node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":true,"cpu_cores":1.0,"output_bytes":1024}"#);
let (lang, runnable, overrides) = parse_fence_info(
r#"node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":true,"cpu_cores":1.0,"output_bytes":1024}"#,
);
assert_eq!(lang, "node");
assert!(runnable);
let limits = overrides.unwrap();

View File

@ -73,10 +73,10 @@ pub struct ExecTask {
// execute.rs 含 server function需对双目标可见不能 cfg-gate其 server-only
// 依赖在文件内单独 gate。languages / progress 是纯 server 辅助,整体 gate。
pub mod execute;
#[cfg(feature = "server")]
pub mod languages;
#[cfg(feature = "server")]
pub mod progress;
#[cfg(feature = "server")]
pub mod sse;
pub mod execute;

View File

@ -85,10 +85,7 @@ mod tests {
let task_id = "test-task-progress-123".to_string();
insert_task(task_id.clone());
assert!(EXEC_TASKS.contains_key(&task_id));
assert_eq!(
EXEC_TASKS.get(&task_id).unwrap().status,
ExecStatus::Queued
);
assert_eq!(EXEC_TASKS.get(&task_id).unwrap().status, ExecStatus::Queued);
update_task_stage(&task_id, ExecStatus::Running, "运行中");
assert_eq!(

View File

@ -76,13 +76,19 @@ pub async fn create_comment(
match post_row {
None => {
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<chrono::DateTime<chrono::Utc>> = 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!()

View File

@ -58,7 +58,10 @@ pub fn render_comment_markdown(md: &str) -> String {
crate::highlight::server::highlight_code(&code_buffer, Some(lang));
format!("<pre><code>{}</code></pre>", highlighted)
} 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()));
in_codeblock = false;

View File

@ -34,7 +34,10 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
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(),
));
}
};

View File

@ -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();
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();
@ -680,10 +682,7 @@ mod tests {
"a.sql",
"A-B_C.123",
] {
assert!(
is_valid_backup_filename(name),
"正常文件名应通过: {name}"
);
assert!(is_valid_backup_filename(name), "正常文件名应通过: {name}");
}
}
@ -697,10 +696,7 @@ mod tests {
"a/../../b",
"backup.sql/../../etc",
] {
assert!(
!is_valid_backup_filename(evil),
"路径穿越应被拒: {evil}"
);
assert!(!is_valid_backup_filename(evil), "路径穿越应被拒: {evil}");
}
}
@ -715,10 +711,7 @@ mod tests {
"a`b`.sql",
"",
] {
assert!(
!is_valid_backup_filename(evil),
"特殊字符应被拒: {evil:?}"
);
assert!(!is_valid_backup_filename(evil), "特殊字符应被拒: {evil:?}");
}
}
@ -728,7 +721,10 @@ mod tests {
fn backup_path_stays_in_backup_dir_for_normal_name() {
let p = backup_path("backup_20260702.sql");
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]
@ -739,7 +735,8 @@ mod tests {
let p = backup_path(evil);
// 不应逃出 BACKUP_DIR(应为 BACKUP_DIR 本身,不含文件名)
assert_eq!(
p, PathBuf::from(BACKUP_DIR),
p,
PathBuf::from(BACKUP_DIR),
"穿越应被规约回 {BACKUP_DIR}: {evil}"
);
}

View File

@ -107,9 +107,7 @@ fn parse_source(source: &str) -> 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<String> = (0..r.len())
.map(|i| sql_quote_cell(r, i))
.collect();
let vals: Vec<String> = (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<i16>>(idx)

View File

@ -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;

View File

@ -112,10 +112,7 @@ enum GuardResult {
/// 护栏 1+2sqlparser 解析后遍历 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(
}
}
// 护栏 2UPDATE 无 WHERE
Statement::Update(sqlparser::ast::Update { selection: None, .. }) => {
Statement::Update(sqlparser::ast::Update {
selection: None, ..
}) => {
return GuardResult::Forbidden(
"UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
);
}
// 护栏 2DELETE 无 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<i16>>(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<String> = 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<serde_json::Value>> = 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:?}"),
}
}

View File

@ -71,7 +71,10 @@ pub async fn get_server_status() -> Result<ServerStatus, ServerFnError> {
// 活跃会话数
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);

View File

@ -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<TaskProgress, ServerFn
#[cfg(feature = "server")]
{
gc_old();
TASKS.get(&task_id)
TASKS
.get(&task_id)
.map(|p| p.clone())
.ok_or_else(|| crate::api::error::AppError::NotFound("任务不存在").into())
}

View File

@ -45,19 +45,13 @@ impl AppError {
/// 记录并包装 SQL 查询错误。
pub fn query(e: impl std::error::Error) -> 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())
}
}

View File

@ -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#"<img src="/uploads/x.webp">"#;
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 转义后的原始源码(单引号转义为 &#x27;)。
assert!(
result.html.contains(r#"data-source="print(&#x27;hi&#x27;)"#),
result
.html
.contains(r#"data-source="print(&#x27;hi&#x27;)"#),
"data-source 应含转义后的源码, got: {}",
result.html
);
@ -740,7 +743,9 @@ console.log(1)
// overrides JSON 应被 HTML 转义后放入属性(双引号变 &quot;),不得出现裸引号越界。
// 字段顺序按 serde 派生默认字母序cpu_cores 在前。
assert!(
result.html.contains("data-overrides=\"{&quot;cpu_cores&quot;:1.0"),
result
.html
.contains("data-overrides=\"{&quot;cpu_cores&quot;: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#"<pre><code class="language-python">"#));
assert!(result
.html
.contains(r#"<pre><code class="language-python">"#));
}
#[test]

View File

@ -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;

View File

@ -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"))]

View File

@ -74,7 +74,11 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
// 递增 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"))]

View File

@ -53,4 +53,3 @@ pub use types::*;
/// 更新指定文章。
#[allow(unused_imports)]
pub use update::update_post;

View File

@ -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。
/// 仅 admin 可调用成功后按影响范围失效文章列表、slug 单篇与搜索缓存。
#[server(RebuildPostContentHtml, "/api")]
pub async fn rebuild_post_content_html(
post_id: i32,
) -> Result<CreatePostResponse, ServerFnError> {
pub async fn rebuild_post_content_html(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
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"))]

View File

@ -85,7 +85,11 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
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"))]
@ -152,7 +156,11 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
// 递增 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"))]
@ -334,7 +342,9 @@ pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse,
crate::ssr_cache::bump_global_generation();
}
Ok(CreatePostResponse::ok_msg(format!("已彻底删除 {result}/{total}")))
Ok(CreatePostResponse::ok_msg(format!(
"已彻底删除 {result}/{total} 篇"
)))
}
#[cfg(not(feature = "server"))]
@ -407,7 +417,9 @@ pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
crate::ssr_cache::bump_global_generation();
}
Ok(CreatePostResponse::ok_msg(format!("已清空回收站({result} 篇)")))
Ok(CreatePostResponse::ok_msg(format!(
"已清空回收站({result} 篇)"
)))
}
#[cfg(not(feature = "server"))]

View File

@ -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"))]

View File

@ -636,7 +636,8 @@ mod tests {
#[test]
fn clean_html_input_rejects_type_image() {
// 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);
assert!(
!result.contains("input"),
@ -703,7 +704,10 @@ mod tests {
// 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick应被剥离。
let input = r#"<pre data-runnable="true" data-evil="x"><code>x</code></pre>"#;
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}"

View File

@ -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());

View File

@ -91,8 +91,15 @@ mod tests {
fn build_time_parses_as_unix_seconds() {
// build.rs 存的是 Unix 秒,运行时应能解析回时间戳。
let parsed = BUILD_INFO.build_time.parse::<i64>();
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]

View File

@ -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<CacheStatSnapshot> {
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,

View File

@ -20,7 +20,6 @@ pub fn AdminLayout() -> Element {
let navigator = dioxus::router::navigator();
let route = use_route::<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)]",

View File

@ -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 · 状态: {}",

View File

@ -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)]

View File

@ -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();

View File

@ -262,9 +262,6 @@ mod tests {
#[test]
fn decode_html_entities_roundtrip() {
assert_eq!(decode_html_entities("print(&#x27;hi&#x27;)"), "print('hi')");
assert_eq!(
decode_html_entities("&quot;&lt;&gt;&amp;"),
"\"<>&"
);
assert_eq!(decode_html_entities("&quot;&lt;&gt;&amp;"), "\"<>&");
}
}

View File

@ -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<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_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<dyn FnMut(web_sys::KeyboardEvent)>);
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<dyn FnMut(web_sys::KeyboardEvent)>);
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。

View File

@ -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!(

View File

@ -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(())
}

View File

@ -42,8 +42,7 @@ where
// 用 use_hook 持有 (Closure, target),在整个组件生命周期内复用;
// use_drop 时 take 出来移除监听,防止泄漏。
type ListenerState<T> =
Rc<RefCell<Option<(wasm_bindgen::prelude::Closure<dyn FnMut()>, T)>>>;
type ListenerState<T> = Rc<RefCell<Option<(wasm_bindgen::prelude::Closure<dyn FnMut()>, T)>>>;
let state: ListenerState<T> = 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<dyn FnMut()>);
}) as Box<dyn FnMut()>);
let _ = target_clone.as_ref().add_event_listener_with_callback(
event,
wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),

View File

@ -48,11 +48,7 @@ pub struct PaginatedState<T> {
/// ```
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
#[allow(unused_variables)]
pub fn use_paginated<T, P, F, Fut, E>(
page: P,
per_page: i32,
fetch: F,
) -> PaginatedState<T>
pub fn use_paginated<T, P, F, Fut, E>(page: P, per_page: i32, fetch: F) -> PaginatedState<T>
where
T: PartialEq + Clone + 'static,
P: Fn() -> i32 + Copy + 'static,

View File

@ -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<Docker> = 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`。
///
/// 连接失败不 panicrelease 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 不可用时返回 IOErrorNotFound
///
/// `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=1777sticky + 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 {
// 不设 nprocRLIMIT_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<i64>, 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::<CreateContainerOptions>,
config
).await?;
let container = docker
.create_container(None::<CreateContainerOptions>, 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<OutputChunk>,
) -> 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);
// 与 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 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<String> = before.into_iter().map(|c| c.id.unwrap()).collect();
let before = docker
.list_containers(Some(ListContainersOptions {
all: true,
..Default::default()
}))
.await
.unwrap();
let before_ids: std::collections::HashSet<String> =
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);

View File

@ -1,4 +1,3 @@
#[cfg(feature = "server")]
pub mod docker;
pub mod runner_config;

View File

@ -41,20 +41,51 @@ pub static RUNNER_CONFIG: LazyLock<RunnerConfig> = 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,

View File

@ -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::<web_sys::HtmlElement>() {
indicator_style.set(format!(
"left: {}px; width: {}px; opacity: 1;",

View File

@ -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;

View File

@ -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::<ServerStatus>::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:?}"
);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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`。

View File

@ -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;

View File

@ -261,7 +261,10 @@ pub fn use_theme_provider() -> Signal<Theme> {
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 || {

View File

@ -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());