style: 全项目格式规范化(cargo fmt)+ Docker daemon 断连容错
- 全项目 .rs 统一 cargo fmt 排版(换行/缩进/参数分行/导入排序) - infra/docker: DOCKER_CLIENT 连接失败不 panic,降级返回 None (博客不依赖 Docker,panic=abort 下会导致整个进程崩溃) - infra/mod: 去除多余空行 - database/mod: 模块声明按字母序重新排序
This commit is contained in:
parent
71f3351c99
commit
65a7b1226f
6
build.rs
6
build.rs
@ -51,7 +51,11 @@ fn git_output(args: &[&str]) -> Option<String> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(out.stdout).ok()?.trim().to_string().into()
|
||||
String::from_utf8(out.stdout)
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string()
|
||||
.into()
|
||||
}
|
||||
|
||||
/// `rustc --version`,采集编译工具链。
|
||||
|
||||
@ -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); // 显式释放信号量
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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!()
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 转义后的原始源码(单引号转义为 ')。
|
||||
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#"<pre><code class="language-python">"#));
|
||||
assert!(result
|
||||
.html
|
||||
.contains(r#"<pre><code class="language-python">"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"))]
|
||||
|
||||
@ -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"))]
|
||||
|
||||
@ -53,4 +53,3 @@ pub use types::*;
|
||||
/// 更新指定文章。
|
||||
#[allow(unused_imports)]
|
||||
pub use update::update_post;
|
||||
|
||||
|
||||
@ -157,9 +157,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
|
||||
/// 在阻塞线程池渲染 HTML,并更新 content_html / toc_html / word_count / reading_time。
|
||||
/// 仅 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"))]
|
||||
|
||||
@ -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"))]
|
||||
|
||||
@ -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"))]
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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]
|
||||
|
||||
14
src/cache.rs
14
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<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,
|
||||
|
||||
@ -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)]",
|
||||
|
||||
@ -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 · 状态: {}",
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(""<>&"), "\"<>&");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`。
|
||||
///
|
||||
/// 连接失败不 panic:release profile 是 `panic = "abort"`,会让整个服务进程一起挂掉,
|
||||
/// 而博客本身并不依赖 Docker。改为记 error 日志后返回 `None`,代码运行器把
|
||||
/// `None` 转成普通 bollard 错误向上冒泡,最终在 execute.rs 的错误脱敏层统一映射为
|
||||
/// 「系统暂时不可用」,其余功能不受影响。
|
||||
pub static DOCKER_CLIENT: LazyLock<Option<Docker>> = LazyLock::new(|| {
|
||||
match Docker::connect_with_unix(
|
||||
&RUNNER_CONFIG.docker_socket_path,
|
||||
120,
|
||||
bollard::API_DEFAULT_VERSION,
|
||||
) {
|
||||
Ok(d) => Some(d),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = ?e,
|
||||
socket = %RUNNER_CONFIG.docker_socket_path,
|
||||
"无法连接 Docker daemon,代码运行功能将不可用。\
|
||||
请确认 Docker 已安装并运行,或设置正确的 DOCKER_SOCKET_PATH",
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// 取共享 Docker 客户端;daemon 不可用时返回 IOError(NotFound)。
|
||||
///
|
||||
/// `NotFound` 既不命中 `TimedOut` 也不命中超时判断,会走 execute.rs 的通用失败路径
|
||||
/// (`ExecStatus::Failed` + 「系统暂时不可用」),不会误报成超时。
|
||||
fn get_docker() -> Result<&'static Docker, bollard::errors::Error> {
|
||||
DOCKER_CLIENT.as_ref().ok_or_else(|| bollard::errors::Error::IOError {
|
||||
err: std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"Docker daemon 不可用(未安装或未运行)",
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_host_config(limits: &ResourceLimits) -> HostConfig {
|
||||
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<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);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
#[cfg(feature = "server")]
|
||||
pub mod docker;
|
||||
pub mod runner_config;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 || {
|
||||
|
||||
@ -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());
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user