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 error: Option<String>,
47 pub truncated: bool,
49}
50
51#[cfg(feature = "server")]
56const MAX_ROWS: usize = 500;
57
58#[cfg(feature = "server")]
67const ABSOLUTELY_FORBIDDEN: &[&[&str]] = &[
68 &["drop", "database"],
69 &["drop", "schema"],
70 &["create", "database"],
71];
72
73#[cfg(feature = "server")]
81fn is_absolutely_forbidden(sql: &str) -> Option<&'static str> {
82 let lowered = sql.to_lowercase();
84 let tokens: Vec<&str> = lowered
85 .split_whitespace()
86 .map(|t| t.trim_end_matches([',', ';', '(', ')']))
87 .collect();
88 for forbidden in ABSOLUTELY_FORBIDDEN {
89 for window in tokens.windows(forbidden.len()) {
91 if window == *forbidden {
92 return Some(match forbidden {
93 ["drop", "database"] => "DROP DATABASE",
94 ["drop", "schema"] => "DROP SCHEMA",
95 ["create", "database"] => "CREATE DATABASE",
96 _ => "未知高危操作",
97 });
98 }
99 }
100 }
101 None
102}
103
104#[cfg(feature = "server")]
106#[derive(Debug)]
107enum GuardResult {
108 Allowed,
109 NeedsConfirm,
111 Forbidden(String),
113}
114
115#[cfg(feature = "server")]
117fn check_guards(asts: &[sqlparser::ast::Statement], confirm_dangerous: bool) -> GuardResult {
118 use sqlparser::ast::{ObjectType, Statement};
119
120 for stmt in asts {
121 match stmt {
122 Statement::Drop {
125 object_type: ObjectType::Schema,
126 ..
127 } => {
128 return GuardResult::Forbidden("禁止 DROP SCHEMA".to_string());
129 }
130 Statement::Drop {
131 object_type: ObjectType::Database,
132 ..
133 } => {
134 return GuardResult::Forbidden("禁止 DROP DATABASE".to_string());
135 }
136 Statement::CreateDatabase { .. } => {
140 return GuardResult::Forbidden("禁止 CREATE DATABASE".to_string());
141 }
142 Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => {
144 if !confirm_dangerous {
145 return GuardResult::NeedsConfirm;
146 }
147 }
148 Statement::Update(sqlparser::ast::Update {
150 selection: None, ..
151 }) => {
152 return GuardResult::Forbidden(
153 "UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
154 );
155 }
156 Statement::Delete(sqlparser::ast::Delete {
158 selection: None, ..
159 }) => {
160 return GuardResult::Forbidden(
161 "DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
162 );
163 }
164 _ => {}
165 }
166 }
167 GuardResult::Allowed
168}
169
170#[cfg(feature = "server")]
172fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String {
173 use sqlparser::ast::Statement;
174 let name = match stmt {
175 Statement::Query(_) => "Select",
176 Statement::Insert(_) => "Insert",
177 Statement::Update(_) => "Update",
178 Statement::Delete(_) => "Delete",
179 Statement::CreateTable { .. } => "CreateTable",
180 Statement::AlterTable { .. } => "AlterTable",
181 Statement::Drop { .. } => "Drop",
182 Statement::Truncate { .. } => "Truncate",
183 Statement::Explain { .. } => "Explain",
184 _ => "Other",
185 };
186 name.to_string()
187}
188
189#[cfg(feature = "server")]
191fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
192 use sqlparser::ast::Statement;
193 matches!(
194 stmt,
195 Statement::Query(_)
197 | Statement::Explain { .. }
198 | Statement::ShowVariable { .. }
200 | Statement::ShowVariables { .. }
201 | Statement::ShowStatus { .. }
202 | Statement::ShowCreate { .. }
203 | Statement::ShowColumns { .. }
204 | Statement::ShowCatalogs { .. }
205 | Statement::ShowDatabases { .. }
206 | Statement::ShowProcessList { .. }
207 | Statement::ShowSchemas { .. }
208 | Statement::ShowCharset(_)
209 | Statement::ShowObjects(_)
210 | Statement::ShowTables { .. }
211 | Statement::ShowViews { .. }
212 | Statement::ShowFunctions { .. }
213 | Statement::ShowCollation { .. }
214 )
215}
216
217#[cfg(feature = "server")]
223fn writes_affect_cache(stmts: &[sqlparser::ast::Statement]) -> bool {
224 stmts.iter().any(|s| !is_read_only(s))
225}
226
227#[cfg(feature = "server")]
229fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
230 use serde_json::json;
231 let ty = row
232 .columns()
233 .get(idx)
234 .map(|c| c.type_().name())
235 .unwrap_or("");
236 match ty {
237 "int2" => row
238 .try_get::<_, Option<i16>>(idx)
239 .ok()
240 .flatten()
241 .map(|v| json!(v))
242 .unwrap_or(serde_json::Value::Null),
243 "int4" => row
244 .try_get::<_, Option<i32>>(idx)
245 .ok()
246 .flatten()
247 .map(|v| json!(v))
248 .unwrap_or(serde_json::Value::Null),
249 "int8" => row
250 .try_get::<_, Option<i64>>(idx)
251 .ok()
252 .flatten()
253 .map(|v| json!(v))
254 .unwrap_or(serde_json::Value::Null),
255 "float4" => row
256 .try_get::<_, Option<f32>>(idx)
257 .ok()
258 .flatten()
259 .map(|v| json!(v))
260 .unwrap_or(serde_json::Value::Null),
261 "float8" => row
262 .try_get::<_, Option<f64>>(idx)
263 .ok()
264 .flatten()
265 .map(|v| json!(v))
266 .unwrap_or(serde_json::Value::Null),
267 "bool" => row
268 .try_get::<_, Option<bool>>(idx)
269 .ok()
270 .flatten()
271 .map(|v| json!(v))
272 .unwrap_or(serde_json::Value::Null),
273 _ => row
275 .try_get::<_, Option<String>>(idx)
276 .ok()
277 .flatten()
278 .map(|v| json!(v))
279 .unwrap_or(serde_json::Value::Null),
280 }
281}
282
283#[server(ExecuteSql, "/api")]
285pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult, ServerFnError> {
286 let _user = get_current_admin_user().await?;
287
288 #[cfg(feature = "server")]
289 {
290 use sqlparser::dialect::PostgreSqlDialect;
291 use sqlparser::parser::Parser;
292
293 if let Some(name) = is_absolutely_forbidden(&sql) {
296 return Err(AppError::BadRequest(format!("禁止的操作:{}", name)).into());
297 }
298
299 let dialect = PostgreSqlDialect {};
301 let asts = Parser::parse_sql(&dialect, &sql)
302 .map_err(|e| AppError::BadRequest(format!("SQL 解析失败:{e}")))?;
303 if asts.is_empty() {
304 return Err(AppError::BadRequest("空的 SQL 语句".into()).into());
305 }
306
307 if asts.len() > 1 && !opts.allow_multi {
309 return Err(AppError::BadRequest(
310 "检测到多条语句,请勾选「允许多语句」后再执行".into(),
311 )
312 .into());
313 }
314
315 match check_guards(&asts, opts.confirm_dangerous) {
317 GuardResult::Forbidden(msg) => {
318 return Err(AppError::BadRequest(msg).into());
319 }
320 GuardResult::NeedsConfirm => {
321 return Err(AppError::BadRequest(
322 "高危操作(DROP/TRUNCATE/ALTER),需勾选「我了解后果」".into(),
323 )
324 .into());
325 }
326 GuardResult::Allowed => {}
327 }
328
329 let client = get_conn().await.map_err(AppError::db_conn)?;
330 let start = std::time::Instant::now;
331
332 let mut last_result = SqlResult::default();
336 for stmt in &asts {
337 let stmt_sql = stmt.to_string();
339 last_result = execute_one(&client, stmt, &stmt_sql, opts.with_explain, start).await?;
340 if last_result.error.is_some() {
341 break;
342 }
343 }
344 let has_write = writes_affect_cache(&asts);
346 if has_write {
350 crate::cache::invalidate_all_post_caches();
351 crate::cache::invalidate_search_results();
352 crate::cache::invalidate_all_comments();
353 crate::ssr_cache::invalidate_ssr_all_public();
354 crate::ssr_cache::bump_global_generation();
355 }
356 Ok(last_result)
357 }
358 #[cfg(not(feature = "server"))]
359 {
360 let _ = (sql, opts);
361 Ok(SqlResult::default())
362 }
363}
364
365#[cfg(feature = "server")]
371fn format_sql_error(error: impl std::fmt::Display) -> String {
372 error.to_string().chars().take(2_000).collect()
373}
374
375#[cfg(feature = "server")]
376async fn execute_one(
377 client: &deadpool_postgres::Object,
378 stmt: &sqlparser::ast::Statement,
379 stmt_sql: &str,
380 with_explain: bool,
381 start: impl Fn() -> std::time::Instant + Copy,
382) -> Result<SqlResult, ServerFnError> {
383 let statement_type = statement_type_name(stmt);
384 let read_only = is_read_only(stmt);
385
386 if with_explain && read_only {
387 let explain_sql = format!("EXPLAIN {}", stmt_sql.trim_end_matches(';'));
389 let rows = match client.query(&explain_sql, &[]).await {
390 Ok(rows) => rows,
391 Err(error) => {
392 return Ok(SqlResult {
393 statement_type,
394 error: Some(format_sql_error(error)),
395 elapsed_ms: start().elapsed().as_millis() as u64,
396 ..Default::default()
397 });
398 }
399 };
400 let explain = rows
401 .iter()
402 .filter_map(|r| r.try_get::<_, String>(0).ok())
403 .collect::<Vec<_>>()
404 .join("\n");
405 return Ok(SqlResult {
406 statement_type,
407 explain: Some(explain),
408 elapsed_ms: start().elapsed().as_millis() as u64,
409 ..Default::default()
410 });
411 }
412
413 if read_only {
414 let limited_sql = format!(
420 "SELECT * FROM ({}) AS q LIMIT {}",
421 stmt_sql.trim_end_matches(';'),
422 MAX_ROWS + 1
423 );
424 let rows = match client.query(&limited_sql, &[]).await {
425 Ok(rows) => rows,
426 Err(error) => {
427 return Ok(SqlResult {
428 statement_type,
429 error: Some(format_sql_error(error)),
430 elapsed_ms: start().elapsed().as_millis() as u64,
431 ..Default::default()
432 });
433 }
434 };
435 let columns: Vec<String> = rows
436 .first()
437 .map(|r| r.columns().iter().map(|c| c.name().to_string()).collect())
438 .unwrap_or_default();
439 let mut data: Vec<Vec<serde_json::Value>> = Vec::new();
440 let mut truncated = false;
441 for r in &rows {
442 if data.len() >= MAX_ROWS {
443 truncated = true;
444 break;
445 }
446 let row: Vec<serde_json::Value> = (0..r.len()).map(|i| col_to_json(r, i)).collect();
447 data.push(row);
448 }
449 Ok(SqlResult {
450 columns,
451 rows: data,
452 truncated,
453 statement_type,
454 elapsed_ms: start().elapsed().as_millis() as u64,
455 ..Default::default()
456 })
457 } else {
458 let affected = match client.execute(stmt_sql, &[]).await {
460 Ok(affected) => affected,
461 Err(error) => {
462 return Ok(SqlResult {
463 statement_type,
464 error: Some(format_sql_error(error)),
465 elapsed_ms: start().elapsed().as_millis() as u64,
466 ..Default::default()
467 });
468 }
469 };
470 Ok(SqlResult {
471 affected_rows: affected,
472 statement_type,
473 elapsed_ms: start().elapsed().as_millis() as u64,
474 ..Default::default()
475 })
476 }
477}
478
479#[cfg(all(test, feature = "server"))]
480mod tests {
481 use super::*;
482
483 fn parse(sql: &str) -> Vec<sqlparser::ast::Statement> {
485 use sqlparser::dialect::PostgreSqlDialect;
486 use sqlparser::parser::Parser;
487 Parser::parse_sql(&PostgreSqlDialect {}, sql).unwrap_or_default()
488 }
489
490 #[test]
495 fn absolutely_forbidden_targets_database_and_schema() {
496 assert_eq!(
498 ABSOLUTELY_FORBIDDEN,
499 &[
500 &["drop", "database"],
501 &["drop", "schema"],
502 &["create", "database"],
503 ]
504 );
505 }
506
507 #[test]
508 fn precheck_catches_forbidden_regardless_of_case() {
509 for sql in [
510 "DROP DATABASE yggdrasil",
511 "drop schema public",
512 "CREATE DATABASE evil",
513 "Drop Database x",
514 ] {
515 assert!(is_absolutely_forbidden(sql).is_some(), "应拦截: {sql:?}");
516 }
517 }
518
519 #[test]
520 fn precheck_catches_multi_space_bypass() {
521 for sql in [
525 "DROP DATABASE x",
526 "drop\tdatabase\tx",
527 "DROP\nDATABASE\nx",
528 "DROP\t\tDATABASE x;",
529 ] {
530 assert!(
531 is_absolutely_forbidden(sql).is_some(),
532 "多空格绕过应被拦截: {sql:?}"
533 );
534 }
535 }
536
537 #[test]
538 fn precheck_returns_canonical_name_for_error_message() {
539 assert_eq!(
540 is_absolutely_forbidden("DROP DATABASE x"),
541 Some("DROP DATABASE")
542 );
543 assert_eq!(
544 is_absolutely_forbidden("drop schema public"),
545 Some("DROP SCHEMA")
546 );
547 assert_eq!(
548 is_absolutely_forbidden("CREATE DATABASE evil"),
549 Some("CREATE DATABASE")
550 );
551 }
552
553 #[test]
554 fn create_database_is_blocked_at_both_string_and_ast_layers() {
555 assert!(is_absolutely_forbidden("CREATE DATABASE x").is_some());
561 let asts = parse("CREATE DATABASE x");
562 assert!(!asts.is_empty(), "CREATE DATABASE 应可被 sqlparser 解析");
563 assert!(matches!(
564 check_guards(&asts, true),
565 GuardResult::Forbidden(_)
566 ));
567 }
568
569 #[test]
570 fn drop_database_is_guarded_by_both_precheck_and_ast_check() {
571 let asts = parse("DROP DATABASE x");
572 assert!(!asts.is_empty(), "DROP DATABASE 应可被 sqlparser 解析");
573 assert!(matches!(
574 check_guards(&asts, true),
575 GuardResult::Forbidden(_)
576 ));
577 assert!(is_absolutely_forbidden("DROP DATABASE x").is_some());
578 }
579
580 #[test]
581 fn precheck_ignores_benign_statements() {
582 for sql in [
583 "SELECT * FROM users",
584 "DROP TABLE old_logs",
585 "CREATE TABLE t (id int)",
586 "DELETE FROM t WHERE id = 1",
587 "CREATE INDEX idx ON t (col)",
588 ] {
589 assert!(is_absolutely_forbidden(sql).is_none(), "不应误拦: {sql:?}");
590 }
591 }
592
593 #[test]
596 fn guard_forbids_drop_schema_even_though_string_precheck_is_bypassable() {
597 let asts = parse("DROP SCHEMA public");
599 match check_guards(&asts, true) {
600 GuardResult::Forbidden(msg) => {
601 assert!(msg.contains("SCHEMA"), "DROP SCHEMA 应被禁止, 得到: {msg}")
602 }
603 other => panic!("DROP SCHEMA 应 Forbidden, 得到 {other:?}"),
604 }
605 }
606
607 #[test]
608 fn guard_drop_schema_ignores_confirm_flag() {
609 let asts = parse("DROP SCHEMA public");
611 assert!(matches!(
612 check_guards(&asts, true),
613 GuardResult::Forbidden(_)
614 ));
615 }
616
617 #[test]
620 fn guard_drop_table_needs_confirm() {
621 let asts = parse("DROP TABLE old_logs");
622 assert!(matches!(
623 check_guards(&asts, false),
624 GuardResult::NeedsConfirm
625 ));
626 }
627
628 #[test]
629 fn guard_drop_table_allowed_with_confirm() {
630 let asts = parse("DROP TABLE old_logs");
631 assert!(matches!(check_guards(&asts, true), GuardResult::Allowed));
632 }
633
634 #[test]
635 fn guard_truncate_needs_confirm() {
636 let asts = parse("TRUNCATE TABLE sessions");
637 assert!(matches!(
638 check_guards(&asts, false),
639 GuardResult::NeedsConfirm
640 ));
641 }
642
643 #[test]
644 fn guard_alter_table_needs_confirm() {
645 let asts = parse("ALTER TABLE posts ADD COLUMN foo text");
646 assert!(matches!(
647 check_guards(&asts, false),
648 GuardResult::NeedsConfirm
649 ));
650 }
651
652 #[test]
653 fn guard_alter_table_allowed_with_confirm() {
654 let asts = parse("ALTER TABLE posts ADD COLUMN foo text");
655 assert!(matches!(check_guards(&asts, true), GuardResult::Allowed));
656 }
657
658 #[test]
661 fn guard_update_without_where_is_forbidden() {
662 let asts = parse("UPDATE posts SET title = 'x'");
663 match check_guards(&asts, true) {
664 GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE")),
665 other => panic!("无 WHERE 的 UPDATE 应 Forbidden, 得到 {other:?}"),
666 }
667 }
668
669 #[test]
670 fn guard_delete_without_where_is_forbidden() {
671 let asts = parse("DELETE FROM posts");
672 match check_guards(&asts, true) {
673 GuardResult::Forbidden(msg) => assert!(msg.contains("WHERE")),
674 other => panic!("无 WHERE 的 DELETE 应 Forbidden, 得到 {other:?}"),
675 }
676 }
677
678 #[test]
679 fn guard_update_with_where_allowed() {
680 let asts = parse("UPDATE posts SET title = 'x' WHERE id = 1");
681 assert!(matches!(check_guards(&asts, false), GuardResult::Allowed));
682 }
683
684 #[test]
685 fn guard_delete_with_where_allowed() {
686 let asts = parse("DELETE FROM posts WHERE id = 1");
687 assert!(matches!(check_guards(&asts, false), GuardResult::Allowed));
688 }
689
690 #[test]
691 fn guard_update_with_where_not_rescued_by_confirm() {
692 let asts = parse("UPDATE posts SET title = 'x'");
694 assert!(matches!(
695 check_guards(&asts, true),
696 GuardResult::Forbidden(_)
697 ));
698 }
699
700 #[test]
703 fn guard_allows_select_insert_create() {
704 for sql in [
705 "SELECT * FROM posts",
706 "INSERT INTO posts (title) VALUES ('x')",
707 "CREATE TABLE t (id int)",
708 ] {
709 let asts = parse(sql);
710 assert!(
711 matches!(check_guards(&asts, false), GuardResult::Allowed),
712 "应放行: {sql}"
713 );
714 }
715 }
716
717 #[test]
720 fn guard_checks_all_statements_in_batch() {
721 let asts = parse("SELECT 1; DELETE FROM posts");
723 assert!(matches!(
724 check_guards(&asts, false),
725 GuardResult::Forbidden(_)
726 ));
727 }
728
729 #[test]
730 fn guard_first_dangerous_short_circuits() {
731 let asts = parse("DROP TABLE a; SELECT 1");
733 assert!(matches!(
734 check_guards(&asts, false),
735 GuardResult::NeedsConfirm
736 ));
737 }
738
739 #[test]
742 fn statement_type_name_maps_variants() {
743 assert_eq!(statement_type_name(&parse("SELECT 1")[0]), "Select");
744 assert_eq!(
745 statement_type_name(&parse("INSERT INTO t (a) VALUES (1)")[0]),
746 "Insert"
747 );
748 assert_eq!(
749 statement_type_name(&parse("UPDATE t SET a = 1 WHERE id = 1")[0]),
750 "Update"
751 );
752 assert_eq!(
753 statement_type_name(&parse("DELETE FROM t WHERE id = 1")[0]),
754 "Delete"
755 );
756 assert_eq!(
757 statement_type_name(&parse("CREATE TABLE t (id int)")[0]),
758 "CreateTable"
759 );
760 assert_eq!(
761 statement_type_name(&parse("ALTER TABLE t ADD COLUMN x int")[0]),
762 "AlterTable"
763 );
764 assert_eq!(statement_type_name(&parse("DROP TABLE t")[0]), "Drop");
765 assert_eq!(statement_type_name(&parse("TRUNCATE t")[0]), "Truncate");
766 }
767
768 #[test]
771 fn is_read_only_classifies_correctly() {
772 assert!(is_read_only(&parse("SELECT 1")[0]));
773 assert!(is_read_only(&parse("EXPLAIN SELECT 1")[0]));
774 assert!(!is_read_only(&parse("UPDATE t SET a = 1 WHERE id = 1")[0]));
775 assert!(!is_read_only(&parse("DELETE FROM t WHERE id = 1")[0]));
776 assert!(!is_read_only(&parse("INSERT INTO t (a) VALUES (1)")[0]));
777 }
778
779 #[test]
783 fn writes_affect_cache_true_for_write_statements() {
784 for sql in [
785 "UPDATE posts SET deleted_at = NOW() WHERE id = 648",
786 "DELETE FROM posts WHERE id = 648",
787 "INSERT INTO posts (title) VALUES ('x')",
788 "TRUNCATE posts",
789 "ALTER TABLE posts ADD COLUMN x int",
790 "DROP TABLE posts",
791 ] {
792 assert!(
793 writes_affect_cache(&parse(sql)),
794 "写语句应触发兜底失效:{sql:?}"
795 );
796 }
797 }
798
799 #[test]
800 fn writes_affect_cache_false_for_read_only_statements() {
801 for sql in [
802 "SELECT 1",
803 "EXPLAIN SELECT * FROM posts",
804 "SELECT id FROM posts WHERE id = 1",
805 ] {
806 assert!(
807 !writes_affect_cache(&parse(sql)),
808 "只读语句不应触发兜底失效:{sql:?}"
809 );
810 }
811 }
812
813 #[test]
814 fn writes_affect_cache_mixed_statements_flagged() {
815 let stmts = parse("SELECT 1; UPDATE posts SET deleted_at = NULL WHERE id = 648");
817 assert!(writes_affect_cache(&stmts));
818 }
819 #[test]
820 fn format_sql_error_truncates_long_messages() {
821 let message = format_sql_error("x".repeat(2_500));
822 assert_eq!(message.chars().count(), 2_000);
823 }
824}