1#![allow(clippy::unused_unit, deprecated)]
2
3use dioxus::prelude::*;
15use serde::{Deserialize, Serialize};
16
17#[cfg(feature = "server")]
19use crate::api::auth::get_current_admin_user;
20#[cfg(feature = "server")]
21use crate::api::error::AppError;
22#[cfg(feature = "server")]
23use crate::db::pool::get_conn;
24
25#[derive(Deserialize, Serialize, Debug, Clone, Copy)]
26pub struct ExecuteSqlOpts {
27 pub allow_multi: bool,
29 pub confirm_dangerous: bool,
31 pub with_explain: bool,
33}
34
35#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
36pub struct SqlResult {
37 pub columns: Vec<String>,
38 pub rows: Vec<Vec<serde_json::Value>>,
40 pub affected_rows: u64,
41 pub elapsed_ms: u64,
42 pub statement_type: String,
44 pub explain: Option<String>,
45 pub truncated: bool,
47}
48
49#[cfg(feature = "server")]
54const MAX_ROWS: usize = 500;
55
56#[cfg(feature = "server")]
65const ABSOLUTELY_FORBIDDEN: &[&[&str]] = &[
66 &["drop", "database"],
67 &["drop", "schema"],
68 &["create", "database"],
69];
70
71#[cfg(feature = "server")]
79fn is_absolutely_forbidden(sql: &str) -> Option<&'static str> {
80 let lowered = sql.to_lowercase();
82 let tokens: Vec<&str> = lowered
83 .split_whitespace()
84 .map(|t| t.trim_end_matches([',', ';', '(', ')']))
85 .collect();
86 for forbidden in ABSOLUTELY_FORBIDDEN {
87 for window in tokens.windows(forbidden.len()) {
89 if window == *forbidden {
90 return Some(match forbidden {
91 ["drop", "database"] => "DROP DATABASE",
92 ["drop", "schema"] => "DROP SCHEMA",
93 ["create", "database"] => "CREATE DATABASE",
94 _ => "未知高危操作",
95 });
96 }
97 }
98 }
99 None
100}
101
102#[cfg(feature = "server")]
104#[derive(Debug)]
105enum GuardResult {
106 Allowed,
107 NeedsConfirm,
109 Forbidden(String),
111}
112
113#[cfg(feature = "server")]
115fn check_guards(asts: &[sqlparser::ast::Statement], confirm_dangerous: bool) -> GuardResult {
116 use sqlparser::ast::{ObjectType, Statement};
117
118 for stmt in asts {
119 match stmt {
120 Statement::Drop {
123 object_type: ObjectType::Schema,
124 ..
125 } => {
126 return GuardResult::Forbidden("禁止 DROP SCHEMA".to_string());
127 }
128 Statement::Drop {
129 object_type: ObjectType::Database,
130 ..
131 } => {
132 return GuardResult::Forbidden("禁止 DROP DATABASE".to_string());
133 }
134 Statement::CreateDatabase { .. } => {
138 return GuardResult::Forbidden("禁止 CREATE DATABASE".to_string());
139 }
140 Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => {
142 if !confirm_dangerous {
143 return GuardResult::NeedsConfirm;
144 }
145 }
146 Statement::Update(sqlparser::ast::Update {
148 selection: None, ..
149 }) => {
150 return GuardResult::Forbidden(
151 "UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
152 );
153 }
154 Statement::Delete(sqlparser::ast::Delete {
156 selection: None, ..
157 }) => {
158 return GuardResult::Forbidden(
159 "DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
160 );
161 }
162 _ => {}
163 }
164 }
165 GuardResult::Allowed
166}
167
168#[cfg(feature = "server")]
170fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String {
171 use sqlparser::ast::Statement;
172 let name = match stmt {
173 Statement::Query(_) => "Select",
174 Statement::Insert(_) => "Insert",
175 Statement::Update(_) => "Update",
176 Statement::Delete(_) => "Delete",
177 Statement::CreateTable { .. } => "CreateTable",
178 Statement::AlterTable { .. } => "AlterTable",
179 Statement::Drop { .. } => "Drop",
180 Statement::Truncate { .. } => "Truncate",
181 Statement::Explain { .. } => "Explain",
182 _ => "Other",
183 };
184 name.to_string()
185}
186
187#[cfg(feature = "server")]
189fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
190 use sqlparser::ast::Statement;
191 matches!(
192 stmt,
193 Statement::Query(_)
195 | Statement::Explain { .. }
196 | Statement::ShowVariable { .. }
198 | Statement::ShowVariables { .. }
199 | Statement::ShowStatus { .. }
200 | Statement::ShowCreate { .. }
201 | Statement::ShowColumns { .. }
202 | Statement::ShowCatalogs { .. }
203 | Statement::ShowDatabases { .. }
204 | Statement::ShowProcessList { .. }
205 | Statement::ShowSchemas { .. }
206 | Statement::ShowCharset(_)
207 | Statement::ShowObjects(_)
208 | Statement::ShowTables { .. }
209 | Statement::ShowViews { .. }
210 | Statement::ShowFunctions { .. }
211 | Statement::ShowCollation { .. }
212 )
213}
214
215#[cfg(feature = "server")]
221fn writes_affect_cache(stmts: &[sqlparser::ast::Statement]) -> bool {
222 stmts.iter().any(|s| !is_read_only(s))
223}
224
225#[cfg(feature = "server")]
227fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
228 use serde_json::json;
229 let ty = row
230 .columns()
231 .get(idx)
232 .map(|c| c.type_().name())
233 .unwrap_or("");
234 match ty {
235 "int2" => row
236 .try_get::<_, Option<i16>>(idx)
237 .ok()
238 .flatten()
239 .map(|v| json!(v))
240 .unwrap_or(serde_json::Value::Null),
241 "int4" => row
242 .try_get::<_, Option<i32>>(idx)
243 .ok()
244 .flatten()
245 .map(|v| json!(v))
246 .unwrap_or(serde_json::Value::Null),
247 "int8" => row
248 .try_get::<_, Option<i64>>(idx)
249 .ok()
250 .flatten()
251 .map(|v| json!(v))
252 .unwrap_or(serde_json::Value::Null),
253 "float4" => row
254 .try_get::<_, Option<f32>>(idx)
255 .ok()
256 .flatten()
257 .map(|v| json!(v))
258 .unwrap_or(serde_json::Value::Null),
259 "float8" => row
260 .try_get::<_, Option<f64>>(idx)
261 .ok()
262 .flatten()
263 .map(|v| json!(v))
264 .unwrap_or(serde_json::Value::Null),
265 "bool" => row
266 .try_get::<_, Option<bool>>(idx)
267 .ok()
268 .flatten()
269 .map(|v| json!(v))
270 .unwrap_or(serde_json::Value::Null),
271 _ => row
273 .try_get::<_, Option<String>>(idx)
274 .ok()
275 .flatten()
276 .map(|v| json!(v))
277 .unwrap_or(serde_json::Value::Null),
278 }
279}
280
281#[server(ExecuteSql, "/api")]
283pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult, ServerFnError> {
284 let _user = get_current_admin_user().await?;
285
286 #[cfg(feature = "server")]
287 {
288 use sqlparser::dialect::PostgreSqlDialect;
289 use sqlparser::parser::Parser;
290
291 if let Some(name) = is_absolutely_forbidden(&sql) {
294 return Err(AppError::BadRequest(format!("禁止的操作:{}", name)).into());
295 }
296
297 let dialect = PostgreSqlDialect {};
299 let asts = Parser::parse_sql(&dialect, &sql)
300 .map_err(|e| AppError::BadRequest(format!("SQL 解析失败:{e}")))?;
301 if asts.is_empty() {
302 return Err(AppError::BadRequest("空的 SQL 语句".into()).into());
303 }
304
305 if asts.len() > 1 && !opts.allow_multi {
307 return Err(AppError::BadRequest(
308 "检测到多条语句,请勾选「允许多语句」后再执行".into(),
309 )
310 .into());
311 }
312
313 match check_guards(&asts, opts.confirm_dangerous) {
315 GuardResult::Forbidden(msg) => {
316 return Err(AppError::BadRequest(msg).into());
317 }
318 GuardResult::NeedsConfirm => {
319 return Err(AppError::BadRequest(
320 "高危操作(DROP/TRUNCATE/ALTER),需勾选「我了解后果」".into(),
321 )
322 .into());
323 }
324 GuardResult::Allowed => {}
325 }
326
327 let client = get_conn().await.map_err(AppError::db_conn)?;
328 let start = std::time::Instant::now;
329
330 let mut last_result = SqlResult::default();
334 for stmt in &asts {
335 let stmt_sql = stmt.to_string();
337 last_result = execute_one(&client, stmt, &stmt_sql, opts.with_explain, start).await?;
338 }
339 let has_write = writes_affect_cache(&asts);
341 if has_write {
345 crate::cache::invalidate_all_post_caches();
346 crate::cache::invalidate_search_results();
347 crate::cache::invalidate_all_comments();
348 crate::ssr_cache::invalidate_ssr_all_public();
349 crate::ssr_cache::bump_global_generation();
350 }
351 Ok(last_result)
352 }
353 #[cfg(not(feature = "server"))]
354 {
355 let _ = (sql, opts);
356 Ok(SqlResult::default())
357 }
358}
359
360#[cfg(feature = "server")]
365async fn execute_one(
366 client: &deadpool_postgres::Object,
367 stmt: &sqlparser::ast::Statement,
368 stmt_sql: &str,
369 with_explain: bool,
370 start: impl Fn() -> std::time::Instant + Copy,
371) -> Result<SqlResult, ServerFnError> {
372 let statement_type = statement_type_name(stmt);
373 let read_only = is_read_only(stmt);
374
375 if with_explain && read_only {
376 let explain_sql = format!("EXPLAIN {}", stmt_sql.trim_end_matches(';'));
378 let rows = client
379 .query(&explain_sql, &[])
380 .await
381 .map_err(AppError::query)?;
382 let explain = rows
383 .iter()
384 .filter_map(|r| r.try_get::<_, String>(0).ok())
385 .collect::<Vec<_>>()
386 .join("\n");
387 return Ok(SqlResult {
388 statement_type,
389 explain: Some(explain),
390 elapsed_ms: start().elapsed().as_millis() as u64,
391 ..Default::default()
392 });
393 }
394
395 if read_only {
396 let limited_sql = format!(
402 "SELECT * FROM ({}) AS q LIMIT {}",
403 stmt_sql.trim_end_matches(';'),
404 MAX_ROWS + 1
405 );
406 let rows = client
407 .query(&limited_sql, &[])
408 .await
409 .map_err(AppError::query)?;
410 let columns: Vec<String> = rows
411 .first()
412 .map(|r| r.columns().iter().map(|c| c.name().to_string()).collect())
413 .unwrap_or_default();
414 let mut data: Vec<Vec<serde_json::Value>> = Vec::new();
415 let mut truncated = false;
416 for r in &rows {
417 if data.len() >= MAX_ROWS {
418 truncated = true;
419 break;
420 }
421 let row: Vec<serde_json::Value> = (0..r.len()).map(|i| col_to_json(r, i)).collect();
422 data.push(row);
423 }
424 Ok(SqlResult {
425 columns,
426 rows: data,
427 truncated,
428 statement_type,
429 elapsed_ms: start().elapsed().as_millis() as u64,
430 ..Default::default()
431 })
432 } else {
433 let affected = client
435 .execute(stmt_sql, &[])
436 .await
437 .map_err(AppError::query)?;
438 Ok(SqlResult {
439 affected_rows: affected,
440 statement_type,
441 elapsed_ms: start().elapsed().as_millis() as u64,
442 ..Default::default()
443 })
444 }
445}
446
447#[cfg(all(test, feature = "server"))]
448mod tests {
449 use super::*;
450
451 fn parse(sql: &str) -> Vec<sqlparser::ast::Statement> {
453 use sqlparser::dialect::PostgreSqlDialect;
454 use sqlparser::parser::Parser;
455 Parser::parse_sql(&PostgreSqlDialect {}, sql).unwrap_or_default()
456 }
457
458 #[test]
463 fn absolutely_forbidden_targets_database_and_schema() {
464 assert_eq!(
466 ABSOLUTELY_FORBIDDEN,
467 &[
468 &["drop", "database"],
469 &["drop", "schema"],
470 &["create", "database"],
471 ]
472 );
473 }
474
475 #[test]
476 fn precheck_catches_forbidden_regardless_of_case() {
477 for sql in [
478 "DROP DATABASE yggdrasil",
479 "drop schema public",
480 "CREATE DATABASE evil",
481 "Drop Database x",
482 ] {
483 assert!(is_absolutely_forbidden(sql).is_some(), "应拦截: {sql:?}");
484 }
485 }
486
487 #[test]
488 fn precheck_catches_multi_space_bypass() {
489 for sql in [
493 "DROP DATABASE x",
494 "drop\tdatabase\tx",
495 "DROP\nDATABASE\nx",
496 "DROP\t\tDATABASE x;",
497 ] {
498 assert!(
499 is_absolutely_forbidden(sql).is_some(),
500 "多空格绕过应被拦截: {sql:?}"
501 );
502 }
503 }
504
505 #[test]
506 fn precheck_returns_canonical_name_for_error_message() {
507 assert_eq!(
508 is_absolutely_forbidden("DROP DATABASE x"),
509 Some("DROP DATABASE")
510 );
511 assert_eq!(
512 is_absolutely_forbidden("drop schema public"),
513 Some("DROP SCHEMA")
514 );
515 assert_eq!(
516 is_absolutely_forbidden("CREATE DATABASE evil"),
517 Some("CREATE DATABASE")
518 );
519 }
520
521 #[test]
522 fn create_database_is_blocked_at_both_string_and_ast_layers() {
523 assert!(is_absolutely_forbidden("CREATE DATABASE x").is_some());
529 let asts = parse("CREATE DATABASE x");
530 assert!(!asts.is_empty(), "CREATE DATABASE 应可被 sqlparser 解析");
531 assert!(matches!(
532 check_guards(&asts, true),
533 GuardResult::Forbidden(_)
534 ));
535 }
536
537 #[test]
538 fn drop_database_is_guarded_by_both_precheck_and_ast_check() {
539 let asts = parse("DROP DATABASE x");
540 assert!(!asts.is_empty(), "DROP DATABASE 应可被 sqlparser 解析");
541 assert!(matches!(
542 check_guards(&asts, true),
543 GuardResult::Forbidden(_)
544 ));
545 assert!(is_absolutely_forbidden("DROP DATABASE x").is_some());
546 }
547
548 #[test]
549 fn precheck_ignores_benign_statements() {
550 for sql in [
551 "SELECT * FROM users",
552 "DROP TABLE old_logs",
553 "CREATE TABLE t (id int)",
554 "DELETE FROM t WHERE id = 1",
555 "CREATE INDEX idx ON t (col)",
556 ] {
557 assert!(is_absolutely_forbidden(sql).is_none(), "不应误拦: {sql:?}");
558 }
559 }
560
561 #[test]
564 fn guard_forbids_drop_schema_even_though_string_precheck_is_bypassable() {
565 let asts = parse("DROP SCHEMA public");
567 match check_guards(&asts, true) {
568 GuardResult::Forbidden(msg) => {
569 assert!(msg.contains("SCHEMA"), "DROP SCHEMA 应被禁止, 得到: {msg}")
570 }
571 other => panic!("DROP SCHEMA 应 Forbidden, 得到 {other:?}"),
572 }
573 }
574
575 #[test]
576 fn guard_drop_schema_ignores_confirm_flag() {
577 let asts = parse("DROP SCHEMA public");
579 assert!(matches!(
580 check_guards(&asts, true),
581 GuardResult::Forbidden(_)
582 ));
583 }
584
585 #[test]
588 fn guard_drop_table_needs_confirm() {
589 let asts = parse("DROP TABLE old_logs");
590 assert!(matches!(
591 check_guards(&asts, false),
592 GuardResult::NeedsConfirm
593 ));
594 }
595
596 #[test]
597 fn guard_drop_table_allowed_with_confirm() {
598 let asts = parse("DROP TABLE old_logs");
599 assert!(matches!(check_guards(&asts, true), GuardResult::Allowed));
600 }
601
602 #[test]
603 fn guard_truncate_needs_confirm() {
604 let asts = parse("TRUNCATE TABLE sessions");
605 assert!(matches!(
606 check_guards(&asts, false),
607 GuardResult::NeedsConfirm
608 ));
609 }
610
611 #[test]
612 fn guard_alter_table_needs_confirm() {
613 let asts = parse("ALTER TABLE posts ADD COLUMN foo text");
614 assert!(matches!(
615 check_guards(&asts, false),
616 GuardResult::NeedsConfirm
617 ));
618 }
619
620 #[test]
621 fn guard_alter_table_allowed_with_confirm() {
622 let asts = parse("ALTER TABLE posts ADD COLUMN foo text");
623 assert!(matches!(check_guards(&asts, true), GuardResult::Allowed));
624 }
625
626 #[test]
629 fn guard_update_without_where_is_forbidden() {
630 let asts = parse("UPDATE posts SET title = 'x'");
631 match check_guards(&asts, true) {
632 GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE")),
633 other => panic!("无 WHERE 的 UPDATE 应 Forbidden, 得到 {other:?}"),
634 }
635 }
636
637 #[test]
638 fn guard_delete_without_where_is_forbidden() {
639 let asts = parse("DELETE FROM posts");
640 match check_guards(&asts, true) {
641 GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE")),
642 other => panic!("无 WHERE 的 DELETE 应 Forbidden, 得到 {other:?}"),
643 }
644 }
645
646 #[test]
647 fn guard_update_with_where_allowed() {
648 let asts = parse("UPDATE posts SET title = 'x' WHERE id = 1");
649 assert!(matches!(check_guards(&asts, false), GuardResult::Allowed));
650 }
651
652 #[test]
653 fn guard_delete_with_where_allowed() {
654 let asts = parse("DELETE FROM posts WHERE id = 1");
655 assert!(matches!(check_guards(&asts, false), GuardResult::Allowed));
656 }
657
658 #[test]
659 fn guard_update_with_where_not_rescued_by_confirm() {
660 let asts = parse("UPDATE posts SET title = 'x'");
662 assert!(matches!(
663 check_guards(&asts, true),
664 GuardResult::Forbidden(_)
665 ));
666 }
667
668 #[test]
671 fn guard_allows_select_insert_create() {
672 for sql in [
673 "SELECT * FROM posts",
674 "INSERT INTO posts (title) VALUES ('x')",
675 "CREATE TABLE t (id int)",
676 ] {
677 let asts = parse(sql);
678 assert!(
679 matches!(check_guards(&asts, false), GuardResult::Allowed),
680 "应放行: {sql}"
681 );
682 }
683 }
684
685 #[test]
688 fn guard_checks_all_statements_in_batch() {
689 let asts = parse("SELECT 1; DELETE FROM posts");
691 assert!(matches!(
692 check_guards(&asts, false),
693 GuardResult::Forbidden(_)
694 ));
695 }
696
697 #[test]
698 fn guard_first_dangerous_short_circuits() {
699 let asts = parse("DROP TABLE a; SELECT 1");
701 assert!(matches!(
702 check_guards(&asts, false),
703 GuardResult::NeedsConfirm
704 ));
705 }
706
707 #[test]
710 fn statement_type_name_maps_variants() {
711 assert_eq!(statement_type_name(&parse("SELECT 1")[0]), "Select");
712 assert_eq!(
713 statement_type_name(&parse("INSERT INTO t (a) VALUES (1)")[0]),
714 "Insert"
715 );
716 assert_eq!(
717 statement_type_name(&parse("UPDATE t SET a = 1 WHERE id = 1")[0]),
718 "Update"
719 );
720 assert_eq!(
721 statement_type_name(&parse("DELETE FROM t WHERE id = 1")[0]),
722 "Delete"
723 );
724 assert_eq!(
725 statement_type_name(&parse("CREATE TABLE t (id int)")[0]),
726 "CreateTable"
727 );
728 assert_eq!(
729 statement_type_name(&parse("ALTER TABLE t ADD COLUMN x int")[0]),
730 "AlterTable"
731 );
732 assert_eq!(statement_type_name(&parse("DROP TABLE t")[0]), "Drop");
733 assert_eq!(statement_type_name(&parse("TRUNCATE t")[0]), "Truncate");
734 }
735
736 #[test]
739 fn is_read_only_classifies_correctly() {
740 assert!(is_read_only(&parse("SELECT 1")[0]));
741 assert!(is_read_only(&parse("EXPLAIN SELECT 1")[0]));
742 assert!(!is_read_only(&parse("UPDATE t SET a = 1 WHERE id = 1")[0]));
743 assert!(!is_read_only(&parse("DELETE FROM t WHERE id = 1")[0]));
744 assert!(!is_read_only(&parse("INSERT INTO t (a) VALUES (1)")[0]));
745 }
746
747 #[test]
751 fn writes_affect_cache_true_for_write_statements() {
752 for sql in [
753 "UPDATE posts SET deleted_at = NOW() WHERE id = 648",
754 "DELETE FROM posts WHERE id = 648",
755 "INSERT INTO posts (title) VALUES ('x')",
756 "TRUNCATE posts",
757 "ALTER TABLE posts ADD COLUMN x int",
758 "DROP TABLE posts",
759 ] {
760 assert!(
761 writes_affect_cache(&parse(sql)),
762 "写语句应触发兜底失效:{sql:?}"
763 );
764 }
765 }
766
767 #[test]
768 fn writes_affect_cache_false_for_read_only_statements() {
769 for sql in [
770 "SELECT 1",
771 "EXPLAIN SELECT * FROM posts",
772 "SELECT id FROM posts WHERE id = 1",
773 ] {
774 assert!(
775 !writes_affect_cache(&parse(sql)),
776 "只读语句不应触发兜底失效:{sql:?}"
777 );
778 }
779 }
780
781 #[test]
782 fn writes_affect_cache_mixed_statements_flagged() {
783 let stmts = parse("SELECT 1; UPDATE posts SET deleted_at = NULL WHERE id = 648");
785 assert!(writes_affect_cache(&stmts));
786 }
787}