From a8ab1dfcc6e47c6f0a0e64e9245f243608d64f33 Mon Sep 17 00:00:00 2001 From: ChenYunDa Date: Fri, 2 Oct 2026 13:12:49 +0800 Subject: [PATCH] fixup --- .DS_Store | Bin 0 -> 6148 bytes .dockerignore | 17 + .gitignore | 6 + ...ss_981c23fb-2298-41a2-b22c-23908fd42a92.md | 42 + ...ss_a18b1d7f-e3ed-40ca-9e24-634cfae55492.md | 19 + Cargo.lock | 2365 +++++++++++++++++ Cargo.toml | 26 + Dockerfile | 59 + alterdb.db-shm | Bin 0 -> 32768 bytes alterdb.db-wal | Bin 0 -> 24752 bytes src/.DS_Store | Bin 0 -> 6148 bytes src/connect.rs | 1300 +++++++++ src/controllers/accounts.rs | 203 ++ src/controllers/auth.rs | 702 +++++ src/controllers/database.rs | 791 ++++++ src/controllers/health.rs | 53 + src/controllers/mod.rs | 370 +++ src/controllers/pages.rs | 710 +++++ src/main.rs | 54 + src/models/account.rs | 212 ++ src/models/database.rs | 336 +++ src/models/mod.rs | 7 + src/session.rs | 257 ++ src/state.rs | 11 + src/storage/account.rs | 473 ++++ src/storage/database.rs | 345 +++ src/storage/mod.rs | 125 + templates/404.html | 47 + templates/_shell.html | 136 + templates/base.html | 98 + templates/database_detail.html | 252 ++ templates/database_form.html | 176 ++ templates/databases.html | 178 ++ templates/index.html | 9 + templates/login.html | 82 + templates/password.html | 68 + templates/table_data.html | 501 ++++ templates/table_detail.html | 355 +++ 38 files changed, 10385 insertions(+) create mode 100644 .DS_Store create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .zcode/plans/plan-sess_981c23fb-2298-41a2-b22c-23908fd42a92.md create mode 100644 .zcode/plans/plan-sess_a18b1d7f-e3ed-40ca-9e24-634cfae55492.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 alterdb.db-shm create mode 100644 alterdb.db-wal create mode 100644 src/.DS_Store create mode 100644 src/connect.rs create mode 100644 src/controllers/accounts.rs create mode 100644 src/controllers/auth.rs create mode 100644 src/controllers/database.rs create mode 100644 src/controllers/health.rs create mode 100644 src/controllers/mod.rs create mode 100644 src/controllers/pages.rs create mode 100644 src/main.rs create mode 100644 src/models/account.rs create mode 100644 src/models/database.rs create mode 100644 src/models/mod.rs create mode 100644 src/session.rs create mode 100644 src/state.rs create mode 100644 src/storage/account.rs create mode 100644 src/storage/database.rs create mode 100644 src/storage/mod.rs create mode 100644 templates/404.html create mode 100644 templates/_shell.html create mode 100644 templates/base.html create mode 100644 templates/database_detail.html create mode 100644 templates/database_form.html create mode 100644 templates/databases.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/password.html create mode 100644 templates/table_data.html create mode 100644 templates/table_detail.html diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cd8b2aec66aeb38c3da5a273b3237485442ed338 GIT binary patch literal 6148 zcmeHKJxc>Y5PfS7iP{8Bq22ug!A31CPCYFIt5k}G=7T6EUi@mT?k9+i*k~u%`y+&O zR#sMmh>iXOXLeV!>m}(Fkr~*1o1K|`voGB3EdaG}T%7_60P@rrgJJ5r82hkEN`PZ&*+pC7(TceqDYUmZ?)q#-f$l(wN zsCX3&Z0vVDE_ZRZJ$GfeZJx`T^?H0crlWlyVT%~+sQH>dJ-obr_q*JCV~^(-pZj*! za4YB)JO4=kNVh)Vjdc?H zSq13KX3LKllwK4N1w?@_1^E3CQe%uPrUvz^gF1Z$APi_)8;uJ*APg-=7E^=dAyX!m z(4;DR#ZV@l=0h77SxgO@bSQiIP?lNQ8;a8Gw0@|=p(2CQivps6uYma)MS;)%rQY{{ zKS_Rx0;0gbQa}Z9DPEv0*|RmXIX-JcYMa#9I4(7)Q>e4Yu^#YIJWfp;`9dBLMix_p RG(#pI0$K*?M1fyb-~%>Hrg8uP literal 0 HcmV?d00001 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1a56e32 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# 建置產物與本機環境 +target/ +.git/ +.zcode/ +**/.DS_Store + +# 本機 SQLite 數據檔(容器應使用乾淨資料庫或掛載 volume) +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# 其他非建置所需 +README.md +.gitignore +.dockerignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce59d48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target + +# SQLite 數據檔 +*.db +*.sqlite +*.sqlite3 diff --git a/.zcode/plans/plan-sess_981c23fb-2298-41a2-b22c-23908fd42a92.md b/.zcode/plans/plan-sess_981c23fb-2298-41a2-b22c-23908fd42a92.md new file mode 100644 index 0000000..15a3415 --- /dev/null +++ b/.zcode/plans/plan-sess_981c23fb-2298-41a2-b22c-23908fd42a92.md @@ -0,0 +1,42 @@ +# 獨立的 Database 新增 / 編輯頁面 + +沿用既定的 JS + REST API 架構,把目前內嵌在列表頁的伸縮式表單拆成兩個獨立頁面;編輯頁改為**服務端預填**(Askama 直接渲染現值,無需 JS 先取數據),提交仍走 `fetch` POST/PUT,成功後跳回 `/databases`。 + +## 1. 路由(`src/controllers/pages.rs`) + +| 路徑 | 說明 | +| --- | --- | +| `GET /databases` | 列表頁(保留):渲染表格 + 刪除(JS confirm + DELETE)| +| `GET /databases/new` | 新增頁:空表單 | +| `GET /databases/{id}/edit` | 編輯頁:服務端 `get_by_id` 預填表單;不存在 → 404「數據庫連接不存在」| + +三個頁面均以 `CurrentUser` 守衛(未登入 303 → `/login`)。`/databases/new`(一段路徑)與 `/databases/{id}/edit`(兩段)無路由衝突。 + +處理器:`database_new_page(user)` 直接渲染空表單;`database_edit_page(State, user, Path)` 查存儲層後渲染。共用一個 `render` 輔助(已存在)。 + +## 2. 模板 + +**`templates/database_form.html`(新,新增/編輯共用)**: +- 內容:session bar + 標題(`新增連接` / `編輯連接:{name}`)+ `role="alert"` 錯誤條 + 表單(name、type 下拉、host、port、username、password、database_name)+ 提示文字 +- 上下文:`editing: Option`。Askama 條件渲染:各欄位 `value="{% if let Some(db) = editing %}{{ db.name }}{% endif %}"`(自動轉義);type 下拉按 `db.db_type.as_str()` 標記 selected;密碼一律不輸出值,placeholder 按模式顯示「可留空」/「留空保留原密碼」 +- `
` +- 內嵌 JS:提交時按 `data-mode` 構造 payload(埠留空省略 → 後端補默認;密碼留空:新增=空密碼、編輯=省略鍵保留原值),`POST /api/databases` 或 `PUT /api/databases/{id}`;成功 `location.assign('/databases')`,失敗顯示 API 錯誤訊息。埠 placeholder 提示隨 type 切換(沿用現有邏輯) +- 表單樣式從 `databases.html` 遷移至此;「取消」按鈕為返回 `/databases` 的連結 + +**`templates/databases.html`(列表頁簡化)**: +- 移除整個內嵌表單區塊與其 JS;「新增連接」改為連結按鈕 → `/databases/new`;每行「編輯」改為連結 → `/databases/{id}/edit`(`renderRow` 中以 `setAttribute('href', ...)` 構造) +- 保留:列表載入、空狀態、刪除(confirm + DELETE)、列表錯誤條 + +## 3. 測試 + +- `pages.rs`:新增頁渲染測試(表單存在、無預填);編輯頁預填測試(test_pool + storage 建一條 → 斷言 `value="shop-prod"`、type selected、密碼無值);編輯頁 404 測試(不存在的 id) +- `controllers/mod.rs` 集成測試補:未登入 `GET /databases/new`、`GET /databases/1/edit` → 303 `/login` +- 瀏覽器實測:列表 → 新增頁建立 → 編輯頁修改(密碼留空保留)→ 刪除 + +## 4. README + +「Web 管理介面」小節的路徑表更新為三個頁面;結構樹補 `database_form.html`、調整 `databases.html` 描述。 + +## 5. 驗證 + +`cargo build` + `cargo test` 全綠;伺服器 + 瀏覽器走完整流程(臨時資料庫,測完清理)。 \ No newline at end of file diff --git a/.zcode/plans/plan-sess_a18b1d7f-e3ed-40ca-9e24-634cfae55492.md b/.zcode/plans/plan-sess_a18b1d7f-e3ed-40ca-9e24-634cfae55492.md new file mode 100644 index 0000000..b8ca037 --- /dev/null +++ b/.zcode/plans/plan-sess_a18b1d7f-e3ed-40ca-9e24-634cfae55492.md @@ -0,0 +1,19 @@ +撰寫 README.md(繁體中文)— alterdb 項目願景文件 +=============================================== + +背景:項目目前僅為 `cargo init` 骨架(src/main.rs 為 Hello World,Cargo.toml 無任何依賴),尚無 SQLite 或 Web 相關代碼。因此 README 以「項目願景 + 規劃功能 + 如實標註開發中」的方式撰寫,不虛構已有功能。 + +交付物:新建 /Users/dan/alterminal/alterdb/README.md + +README 結構: +1. 標題與一句話簡介 — alterdb:以 SQLite 為底層存儲格式的自定義數據庫引擎,附帶 Web 管理介面 +2. 專案簡介 — 說明定位:不是 SQLite 管理器,而是自定義數據庫引擎(自有查詢層),SQLite 僅作底層存儲;透過本地 Web 伺服器 + 瀏覽器操作管理 +3. 核心特性(規劃中)— 自定義查詢引擎、SQLite 存儲後端、Web 管理介面(資料庫/表瀏覽、執行查詢、數據編輯)、單一可執行檔部署 +4. 架構規劃 — ASCII 分層圖:瀏覽器 Web UI ⇄ HTTP API(Rust 伺服器)⇄ 自定義查詢引擎 ⇄ SQLite 存儲層;技術選型(Rust edition 2024;Web 框架與 SQLite 綁定庫標註為「規劃中、暫定」) +5. 快速開始 — 環境需求(Rust 1.85+,因使用 edition 2024)、cargo build / cargo run、瀏覽器訪問本地地址;明確標註此為目標使用方式,功能尚未實作 +6. 開發路線圖(Roadmap)— checkbox 形式:存儲層 MVP → 查詢引擎 → HTTP API → Web 管理介面 → 進階功能(匯入匯出、備份等) +7. 專案現狀與結構 — 如實說明目前為初始骨架,列出現有文件 +8. 開發指南 — cargo build / test / run +9. 授權 — 標註尚未決定(TBD) + +可選的小補充(一併執行):在 .gitignore 追加 `*.db` / `*.sqlite*`,避免日後 SQLite 數據檔被誤提交(與 README 中「數據檔位置」說明呼應)。 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3ed0c72 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2365 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alterdb" +version = "0.1.0" +dependencies = [ + "argon2", + "askama", + "axum", + "deadpool-sqlite", + "mysql_async", + "rand_core 0.6.4", + "rusqlite", + "serde", + "serde_json", + "tokio", + "tokio-postgres", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "askama" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 3.0.6", +] + +[[package]] +name = "askama_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "btoi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" +dependencies = [ + "num-traits", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "deadpool" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e" +dependencies = [ + "deadpool-runtime", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[package]] +name = "deadpool-sqlite" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b02743f8fad68dc9bb2851c0a4a1d63190d8e3885c5dea20f8a0a2a6bff9a" +dependencies = [ + "deadpool", + "deadpool-sync", + "rusqlite", +] + +[[package]] +name = "deadpool-sync" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf" +dependencies = [ + "deadpool-runtime", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyed_priority_queue" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" +dependencies = [ + "indexmap", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "mysql_async" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1d9585dc9058886ff3a1f48a23024dd1d054264dee7c5ae0e4bd640c953bee5" +dependencies = [ + "bytes", + "crossbeam-queue", + "flate2", + "futures-core", + "futures-sink", + "futures-util", + "keyed_priority_queue", + "lru", + "mysql_common", + "pem", + "percent-encoding", + "rand 0.9.5", + "serde", + "serde_json", + "socket2 0.5.10", + "thiserror", + "tokio", + "tokio-util", + "twox-hash", + "url", +] + +[[package]] +name = "mysql_common" +version = "0.35.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052" +dependencies = [ + "base64", + "bitflags", + "btoi", + "byteorder", + "bytes", + "crc32fast", + "flate2", + "getrandom 0.3.4", + "num-bigint", + "num-traits", + "regex", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2 0.10.9", + "thiserror", + "uuid", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.10.3", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.3", + "socket2 0.6.5", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c687f84 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "alterdb" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["fs", "cors", "compression-gzip", "trace"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +rusqlite = { version = "0.40.2", features = ["bundled"] } +deadpool-sqlite = "0.14.0" +askama = "0.16.1" +argon2 = { version = "0.5", features = ["std"] } +# 外部數據庫實際連線(測試連接 / 瀏覽數據表);暫不啟用 TLS +mysql_async = { version = "0.36", default-features = false, features = ["minimal"] } +tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"] } +# 僅為 argon2 的 SaltString::generate 提供 OsRng(啟用 getrandom 特性) +rand_core = { version = "0.6", features = ["getrandom"] } + +[dev-dependencies] +# 整合測試以 ServiceExt::oneshot 直接把請求送進完整路由,无需真實 socket +tower = { version = "0.5", features = ["util"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..367641e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +########## 建置階段:編譯 release 二進位 ########## +# edition 2024 需 Rust 1.85+;跟隨本機驗證過的 1.98 +# 基礎映像可用 --build-arg 覆寫(例如網路受限時改用可達的 registry 代理) +ARG RUST_IMAGE=rust:1.98-slim +ARG DEBIAN_IMAGE=debian:trixie-slim + +FROM ${RUST_IMAGE} AS builder + +# rusqlite(bundled)編譯內嵌 SQLite 需要 C 編譯器 +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# askama 在「編譯期」讀取 templates/,因此模板目錄必須一併複製 +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY templates ./templates + +# BuildKit 快取掛載保留 cargo registry 與 target 目錄,重複建置只重編改動部分 +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/app/target \ + cargo build --release --locked \ + && cp target/release/alterdb /usr/local/bin/alterdb + +########## 執行階段:僅含二進位與執行期依賴 ########## +# 與建置階段同基礎發行版(trixie),glibc 一致;模板已編入二進位,無需其他檔案 +FROM ${DEBIAN_IMAGE} AS runtime + +# ca-certificates 供未來對外連線使用;建立非 root 執行帳戶(不加 shell 設定檔進 /data) +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --no-create-home --home-dir /data appuser \ + && mkdir -p /data && chown appuser:appuser /data + +# SQLite 數據檔集中於 /data,掛載 volume 即可持久化 +VOLUME /data + +# 容器內必須綁 0.0.0.0 才能從宿主機存取(main.rs 讀取 ALTERDB_BIND) +ENV ALTERDB_BIND=0.0.0.0:8080 \ + ALTERDB_DB_PATH=/data/alterdb.db \ + RUST_LOG=info + +USER appuser +WORKDIR /data + +COPY --from=builder /usr/local/bin/alterdb /usr/local/bin/alterdb + +EXPOSE 8080 + +# debian-slim 無 curl/wget,以 bash 內建 /dev/tcp 探測健康端點 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080 \ + && printf "GET /api/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3 \ + && grep -q "200 OK" <&3' || exit 1 + +ENTRYPOINT ["/usr/local/bin/alterdb"] diff --git a/alterdb.db-shm b/alterdb.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..94dd6b9d688010557b8f974be8e95876a5cc45df GIT binary patch literal 32768 zcmeI)y$J#_7zW_@-wHOjfaSS?rHz$y=n6W86DU|(2sUou7J`+PD`+Adq!KK>dtM0S zkA&|T;4AKBsL~2y*pG`r9J|SCxgDL$>2Y>lpPu{8__3HDF1O;x`_AV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5)uRUmbp5CQ}U5FkK+009C7>MhW%cX_HK z@I{~h=b;K^J4AUJikzrPNm^hmQYAv?K|~r#akLc(O6l*Ib`3(<*Ue{m`?q4=+AY z&K^CIDcL4jtTifzO(tn;NfP!ELSqN$mxr}!7Z+W!I$oUq_(q@QfG~df;mFAEg4;VP z3@^y{6z5rJUOJ!lT=PSoX*ltX4I?}bsANT zipiT0qN!hNY%6lk(}BJEiCBvN}We<3Y|WvP9Gv+9+l3Luu@sEmdi?% zDD|3U7aOLfL?W%umS{!){kCH2wZ?`RJ|KQ(@iExWO3mnvph3C+hsl~|~tg$^b| zGs)26G3HXOo$in{>vYertxsR%yd--3fq?k^S57(^`FyQewJpAsx{`_4z9^Tt{3}wa zYbrL8Y|JFHVwB2!_Fo!VC|UKgvGU@n=rHq{D_Odcw@as-9OXyN^Q9A=AnjCL4`<05 zkCFt>&aUa*bbvfz{`>6y-!6SoQg0`Yz8PjOhA3?rk$)6#IceoVTCr0k&xUs=trBjxUk!>vsai14wCB-i z+BIie=chMIPjps2@|rNje&p?Rx&70DeogsFLZ9Gk13RUE)gZweu=n!Lp6Jre_Q9kgf9T0*}7?^hRja_c!~00ZD!; z$bZo%6bL{70uX=z1Rwwb2tWV=5P$##-d}+aBv;Vexm#gabOpTph5|aMuKNg9hjv^j z|M~4zc3nV}R|Wb&fdB*`009U<00Izz00bZa0SG|g4GS!a!@{;ePznS-nVPB>`3J+4 zYk8y0J``sAUS7qQhOHOOQxoxtJ+VYQ7EfpslgZtClZjp9le=~w>sL>Q)pZ}iiId|u tW511FV)qe<@;?H7pg;fu5P$##AOHafKmY;|fB*y_u;l~>UE93%;&aqiS0C)QAf8Ke%Z(1*&9kg5eSAw}XZSU~&$;uo0Mk&yTS zcGMr}xxQ8#Hw3|g5W2Urj~~bO$#E3dL}c0*{T5M^i0Vj;!8)=}g8Mm@B5*AmKx5|^ zQ$`7X0~(ju+VB@0;Js^55BqjT=h(Hy{!Nl$tJBNUk(F(Bo~oH?7RTKr>w-~#efZeA z-F@EsirxPKdvsb6(4vT511L}Y}@NEZhW^>dVf|{!X z>VP^hcYwbSArfQcFc-9+4rBqU++SnlK$-6njG@EGVJ?Usgpr{@8EV`W!^m*7hmIFH z%mrmQ8M`v(aVr~lLos%Bw1<{XCKA+K9Z&~+2UeuoO`u^_^(waJ;4*V+zOfBxj zZ9I}YTT2heXKjddjKsmbTyQ=F1$z{O%SZ7xk`1(nTmVK6b3wEq^pAkGK{IvWR~?uE DG&6a~ literal 0 HcmV?d00001 diff --git a/src/connect.rs b/src/connect.rs new file mode 100644 index 0000000..f97d1b2 --- /dev/null +++ b/src/connect.rs @@ -0,0 +1,1300 @@ +//! 對外部數據庫(MySQL / PostgreSQL)的實際連線能力。 +//! +//! 提供無狀態操作:測試連接([`test`],連上即回報伺服器版本與延遲後 +//! 斷開)、列出數據表([`list_tables`])、檢視欄位結構([`describe_table`], +//! 含主鍵標記)、分頁瀏覽資料([`select_rows`])、新增 / 刪除欄位 +//! 與修改資料列([`update_row`],以主鍵定位)。除 DDL / DML 外皆讀取 +//! 目標庫 information_schema 中當前 schema 的表。每次操作建立全新連線、 +//! 用完即斷,不做連接複用;目標不可達等情況由整體逾時兜底, +//! 不會長時間佔住 HTTP 請求。 +//! +//! 暫不啟用 TLS:連線為明文,僅適合配合受信任網路使用(見 README)。 + +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use tokio::time::timeout; + +use crate::models::database::{Database, DatabaseType}; + +/// 單次連線操作(建立連線 + 一次查詢)的整體逾時 +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// 連接測試結果。`ok` 為 `true` 時附伺服器版本,否則附可直接展示的 +/// 錯誤訊息;兩種情況都記錄耗時,供管理介面呈現延遲。 +#[derive(Debug, Serialize)] +pub struct TestOutcome { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// 從發起連線到得出結果的耗時(毫秒),含失敗案例 + pub elapsed_ms: u64, +} + +/// 數據表摘要,來自 information_schema 的 `TABLES` 視圖 +#[derive(Debug, Serialize)] +pub struct TableSummary { + pub name: String, + /// information_schema 的 `TABLE_TYPE` 值(如 `BASE TABLE` / `VIEW`) + pub kind: String, +} + +/// 欄位結構摘要,來自 information_schema 的 `COLUMNS` 視圖 +#[derive(Debug, Serialize)] +pub struct ColumnSummary { + pub name: String, + /// 類型描述:MySQL 為 `COLUMN_TYPE`(如 `varchar(255)`、`int unsigned`); + /// PostgreSQL 為 `data_type`,帶長度上限的字符類型附加 `(n)`。 + /// API 欄位名為 `type` + #[serde(rename = "type")] + pub data_type: String, + /// 是否允許 NULL(information_schema 的 `IS_NULLABLE = 'YES'`) + pub nullable: bool, + /// 欄位默認值的字串描述;無默認值時整鍵省略 + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + /// 是否為主鍵欄位(複合主鍵時多欄皆為 `true`;經 `TABLE_CONSTRAINTS` + /// 連接 `KEY_COLUMN_USAGE` 判定)。修改資料列以此定位目標列 + pub primary_key: bool, +} + +/// 一頁資料:欄位名、每列每格的字串表示與分頁資訊 +#[derive(Debug, Serialize)] +pub struct TableRows { + /// 資料欄位名,按表定義順序(同 `SELECT *` 的輸出順序) + pub columns: Vec, + /// 每列每格的字串表示;NULL 為 `None` + pub rows: Vec>>, + /// 資料表總列數(`COUNT(*)`),供分頁導航 + pub total: i64, + /// 本次請求生效的分頁參數(呼叫端已驗證的值) + pub limit: i64, + pub offset: i64, +} + +/// 建立欄位的請求載荷(`POST .../columns`)。 +/// +/// `type` 與 `default` 是**原生 SQL 片段**(如 `varchar(255)`、`0`、 +/// `'text'`、`CURRENT_TIMESTAMP`),由管理介面直接拼入 DDL——這是 +/// 表結構管理工具的固有語義;驗證僅攔截多重語句與註解攻擊面 +/// (分號、`--`、`/*` 與控制字元),配合兩驅動皆走的預備語句協議 +/// (不含多用戶端多語句標誌)杜絕第二條語句被執行。 +#[derive(Debug, Deserialize)] +pub struct ColumnInput { + /// 欄位名(必填;拼入 DDL 前以引號轉義) + pub name: String, + /// 類型(必填,原生 SQL 片段);API 欄位名為 `type` + #[serde(rename = "type")] + pub data_type: String, + /// 是否允許 NULL,缺省為允許(對已有資料的表最安全) + pub nullable: Option, + /// 默認值(可選,原生 SQL 片段);缺省或空字串表示不設 DEFAULT + pub default: Option, +} + +/// 欄位名長度上限(MySQL 為 64 字符,PostgreSQL 超出 63 會被截斷) +const COLUMN_NAME_MAX_CHARS: usize = 64; +/// 類型片段長度上限 +const COLUMN_TYPE_MAX_CHARS: usize = 128; +/// 默認值片段長度上限 +const COLUMN_DEFAULT_MAX_CHARS: usize = 256; + +impl ColumnInput { + /// 驗證並正規化輸入:名稱與類型去除首尾空白、檢查長度; + /// 名稱拒絕控制字元,類型與默認值另拒絕分號與註解起始符。 + /// `Err` 的訊息可直接作為 API 400 響應的錯誤內容。 + pub fn normalized(self) -> Result { + let name = self.name.trim().to_string(); + if name.is_empty() { + return Err("name 不可為空".into()); + } + if name.chars().count() > COLUMN_NAME_MAX_CHARS { + return Err(format!("name 長度不可超過 {COLUMN_NAME_MAX_CHARS} 個字符")); + } + if name.chars().any(char::is_control) { + return Err("name 不可包含控制字元".into()); + } + + let data_type = validate_fragment(&self.data_type, "type", COLUMN_TYPE_MAX_CHARS)?; + + // 空字串視為不設默認值;其餘按 SQL 片段驗證 + let default = match self.default.as_deref() { + Some(value) if !value.trim().is_empty() => Some(validate_fragment( + value, + "default", + COLUMN_DEFAULT_MAX_CHARS, + )?), + _ => None, + }; + + Ok(Self { + name, + data_type, + nullable: self.nullable, + default, + }) + } + + /// NULL / NOT NULL 子句(缺省允許 NULL) + fn nullability_clause(&self) -> &'static str { + if self.nullable.unwrap_or(true) { + "NULL" + } else { + "NOT NULL" + } + } +} + +/// 驗證拼入 DDL 的原生 SQL 片段(類型 / 默認值): +/// 非空、長度上限、無控制字元、無分號與註解起始符。 +fn validate_fragment(value: &str, field: &str, max_chars: usize) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(format!("{field} 不可為空")); + } + if trimmed.chars().count() > max_chars { + return Err(format!("{field} 長度不可超過 {max_chars} 個字符")); + } + if trimmed.chars().any(char::is_control) || trimmed.contains(';') { + return Err(format!("{field} 不可包含分號或控制字元")); + } + if trimmed.contains("--") || trimmed.contains("/*") { + return Err(format!("{field} 不可包含註解(-- 或 /*)")); + } + Ok(trimmed) +} + +/// 測試連接:嘗試以連接記錄的參數建立真實連線並查詢伺服器版本, +/// 成功或失敗都以 [`TestOutcome`] 回報(逾時亦同),不返回 `Err`。 +pub async fn test(database: &Database) -> TestOutcome { + let started = Instant::now(); + let elapsed = || started.elapsed().as_millis() as u64; + + match timeout(CONNECT_TIMEOUT, test_inner(database)).await { + Ok(Ok(version)) => TestOutcome { + ok: true, + server_version: Some(version), + error: None, + elapsed_ms: elapsed(), + }, + Ok(Err(error)) => TestOutcome { + ok: false, + server_version: None, + error: Some(error), + elapsed_ms: elapsed(), + }, + Err(_) => TestOutcome { + ok: false, + server_version: None, + error: Some(timeout_message()), + elapsed_ms: elapsed(), + }, + } +} + +async fn test_inner(database: &Database) -> Result { + match database.db_type { + DatabaseType::MySql => mysql_test(database).await, + DatabaseType::Postgres => postgres_test(database).await, + } +} + +/// 列出目標資料庫**當前 schema** 下的數據表(按名稱排序)。 +/// +/// MySQL 以 `DATABASE()`、PostgreSQL 以 `current_schema()` 對齊語義, +/// 兩者都排除系統表。連線或查詢失敗時返回可直接展示的錯誤訊息。 +pub async fn list_tables(database: &Database) -> Result, String> { + timeout(CONNECT_TIMEOUT, list_tables_inner(database)) + .await + .map_err(|_| timeout_message())? +} + +async fn list_tables_inner(database: &Database) -> Result, String> { + match database.db_type { + DatabaseType::MySql => mysql_tables(database).await, + DatabaseType::Postgres => postgres_tables(database).await, + } +} + +/// 檢視資料表欄位結構(按表定義順序)。 +/// +/// 目標庫當前 schema 下不存在該表時返回 `None`(表至少有一個欄位, +/// 空結果即意味著不存在);連線或查詢失敗返回可直接展示的錯誤訊息。 +pub async fn describe_table( + database: &Database, + table: &str, +) -> Result>, String> { + timeout(CONNECT_TIMEOUT, describe_table_inner(database, table)) + .await + .map_err(|_| timeout_message())? +} + +async fn describe_table_inner( + database: &Database, + table: &str, +) -> Result>, String> { + match database.db_type { + DatabaseType::MySql => mysql_columns(database, table).await, + DatabaseType::Postgres => postgres_columns(database, table).await, + } +} + +/// 分頁瀏覽資料表資料(`limit` / `offset` 由呼叫端驗證)。 +/// +/// 以 `SELECT *` 取一頁資料、`COUNT(*)` 取總列數;儲存格一律轉為 +/// 字串表示(NULL 為 `None`)。資料表不存在時返回 `None`, +/// 連線或查詢失敗返回可直接展示的錯誤訊息。 +pub async fn select_rows( + database: &Database, + table: &str, + limit: i64, + offset: i64, +) -> Result, String> { + timeout( + CONNECT_TIMEOUT, + select_rows_inner(database, table, limit, offset), + ) + .await + .map_err(|_| timeout_message())? +} + +async fn select_rows_inner( + database: &Database, + table: &str, + limit: i64, + offset: i64, +) -> Result, String> { + // 先取欄位結構:一併驗證資料表確實存在(表名此後才會被拼入 SQL), + // 也為 PostgreSQL 的逐欄 ::text 轉換取得欄位清單 + let Some(columns) = describe_table_inner(database, table).await? else { + return Ok(None); + }; + let rows = match database.db_type { + DatabaseType::MySql => mysql_rows(database, table, limit, offset, &columns).await?, + DatabaseType::Postgres => postgres_rows(database, table, limit, offset, &columns).await?, + }; + Ok(Some(rows)) +} + +/// 新增欄位(`ALTER TABLE … ADD COLUMN`,呼叫端已驗證輸入、 +/// 並核對過資料表存在與欄位名未衝突)。連線或 DDL 失敗返回 +/// 可直接展示的錯誤訊息(如對有資料的表加 NOT NULL 無默認值欄位)。 +pub async fn add_column( + database: &Database, + table: &str, + input: &ColumnInput, +) -> Result<(), String> { + timeout(CONNECT_TIMEOUT, add_column_inner(database, table, input)) + .await + .map_err(|_| timeout_message())? +} + +async fn add_column_inner( + database: &Database, + table: &str, + input: &ColumnInput, +) -> Result<(), String> { + match database.db_type { + DatabaseType::MySql => { + let definition = column_definition(quote_mysql, input); + mysql_exec_ddl( + database, + &format!("ALTER TABLE {} ADD COLUMN {definition}", quote_mysql(table)), + ) + .await + } + DatabaseType::Postgres => { + let definition = column_definition(quote_pg, input); + postgres_exec_ddl( + database, + &format!("ALTER TABLE {} ADD COLUMN {definition}", quote_pg(table)), + ) + .await + } + } +} + +/// 刪除欄位(`ALTER TABLE … DROP COLUMN`,呼叫端已核對表與欄位存在)。 +/// 該欄位的資料隨之丟失且無法復原;連線或 DDL 失敗返回錯誤訊息。 +pub async fn drop_column(database: &Database, table: &str, column: &str) -> Result<(), String> { + timeout(CONNECT_TIMEOUT, drop_column_inner(database, table, column)) + .await + .map_err(|_| timeout_message())? +} + +async fn drop_column_inner(database: &Database, table: &str, column: &str) -> Result<(), String> { + match database.db_type { + DatabaseType::MySql => { + mysql_exec_ddl( + database, + &format!( + "ALTER TABLE {} DROP COLUMN {}", + quote_mysql(table), + quote_mysql(column) + ), + ) + .await + } + DatabaseType::Postgres => { + postgres_exec_ddl( + database, + &format!( + "ALTER TABLE {} DROP COLUMN {}", + quote_pg(table), + quote_pg(column) + ), + ) + .await + } + } +} + +/// 修改資料列的請求載荷(`PATCH .../rows`)。 +/// +/// `where` 以**主鍵欄位的原值**定位目標資料列(鍵須恰好覆蓋主鍵欄位, +/// 由控制器核對);`set` 為要寫入的新值,值一律是字串或 `null` +/// (`null` 表示設為 NULL,空字串是有效的字串值),伺服器會按欄位 +/// 型別隱式轉換,無法轉換時由目標庫錯誤原樣回報。 +#[derive(Debug, Deserialize)] +pub struct RowUpdateInput { + /// 主鍵欄位名 → 原值(字串或 `null`);API 欄位名為 `where` + #[serde(rename = "where")] + pub filter: BTreeMap>, + /// 欄位名 → 新值(字串或 `null` = 設為 NULL) + pub set: BTreeMap>, +} + +/// 資料列欄位值的長度上限(字符) +const ROW_VALUE_MAX_CHARS: usize = 65_536; + +impl RowUpdateInput { + /// 驗證並正規化輸入:`set` / `where` 皆不可為空;鍵(欄位名)去除 + /// 首尾空白、非空、≤ 64 字符且無控制字元;值長度設上限。 + /// 值本身**不做** trim——內容即資料。`Err` 的訊息可直接作為 400 響應內容。 + pub fn normalized(self) -> Result { + if self.set.is_empty() { + return Err("set 不可為空(沒有要修改的欄位)".into()); + } + if self.filter.is_empty() { + return Err("where 不可為空(須以主鍵定位資料列)".into()); + } + let set = normalize_value_map("set", self.set)?; + let filter = normalize_value_map("where", self.filter)?; + Ok(Self { filter, set }) + } +} + +/// 驗證載荷中一組「欄位名 → 值」映射的鍵與值 +fn normalize_value_map( + label: &str, + map: BTreeMap>, +) -> Result>, String> { + let mut normalized = BTreeMap::new(); + for (column, value) in map { + let column = column.trim().to_string(); + if column.is_empty() { + return Err(format!("{label} 的欄位名不可為空")); + } + if column.chars().count() > COLUMN_NAME_MAX_CHARS { + return Err(format!( + "{label} 的欄位名長度不可超過 {COLUMN_NAME_MAX_CHARS} 字符" + )); + } + if column.chars().any(char::is_control) { + return Err(format!("{label} 的欄位名不可包含控制字元")); + } + if let Some(value) = &value + && value.chars().count() > ROW_VALUE_MAX_CHARS + { + return Err(format!("欄位 {column} 的值長度不可超過 {ROW_VALUE_MAX_CHARS} 字符")); + } + normalized.insert(column, value); + } + Ok(normalized) +} + +/// 修改資料列(以主鍵欄位原值定位,呼叫端已核對表存在、`where` 恰為 +/// 主鍵欄位與 `set` 欄位皆存在)。返回 `false` 表示目標列不存在 +/// (可能已被刪除或主鍵值已變更),`true` 表示已更新; +/// 連線或語句失敗返回可直接展示的錯誤訊息(如型別無法轉換、 +/// 違反唯一性約束等,由目標庫原樣回報)。 +pub async fn update_row( + database: &Database, + table: &str, + input: &RowUpdateInput, +) -> Result { + timeout(CONNECT_TIMEOUT, update_row_inner(database, table, input)) + .await + .map_err(|_| timeout_message())? +} + +async fn update_row_inner( + database: &Database, + table: &str, + input: &RowUpdateInput, +) -> Result { + match database.db_type { + DatabaseType::MySql => mysql_update_row(database, table, input).await, + DatabaseType::Postgres => postgres_update_row(database, table, input).await, + } +} + +/// 組裝 UPDATE 語句:欄位名以 `quote` 引號轉義(已由呼叫端經 +/// information_schema 白名單核對),值經 `render` 轉為 SQL 片段—— +/// MySQL 渲染為 `?` 佔位並另以預備語句參數綁定(無轉義問題), +/// PostgreSQL 渲染為轉義後的字面值。 +fn update_statement( + quote: fn(&str) -> String, + table: &str, + input: &RowUpdateInput, + render: fn(&Option) -> String, +) -> String { + let sets = input + .set + .iter() + .map(|(column, value)| format!("{} = {}", quote(column), render(value))) + .collect::>() + .join(", "); + let filter = input + .filter + .iter() + .map(|(column, value)| format!("{} = {}", quote(column), render(value))) + .collect::>() + .join(" AND "); + format!("UPDATE {} SET {sets} WHERE {filter}", quote(table)) +} + +/// MySQL 的值渲染:一律為 `?` 佔位,實際值走預備語句參數 +fn mysql_placeholder(_: &Option) -> String { + "?".to_string() +} + +/// PostgreSQL 的字面值:NULL 或單引號字面量(`'` 加倍轉義)。 +/// 標準遵循字串(PG 9.1 起默認)下反斜線無特殊含義;字面值型別 +/// 為未知,賦值與比較時由伺服器隱式轉換為欄位型別 +fn pg_literal(value: &Option) -> String { + match value { + None => "NULL".to_string(), + Some(text) => format!("'{}'", text.replace('\'', "''")), + } +} + +/// MySQL 修改資料列。值以字串參數綁定(None → NULL),伺服器按欄位 +/// 型別隱式轉換;連線啟用 CLIENT_FOUND_ROWS(見 [`mysql_connect`]), +/// affected rows 回報**匹配**列數,0 即可靠地表示目標列不存在。 +async fn mysql_update_row( + database: &Database, + table: &str, + input: &RowUpdateInput, +) -> Result { + use mysql_async::prelude::Queryable; + + let sql = update_statement(quote_mysql, table, input, mysql_placeholder); + let mut values: Vec = input + .set + .values() + .cloned() + .map(mysql_async::Value::from) + .collect(); + values.extend(input.filter.values().cloned().map(mysql_async::Value::from)); + + let mut conn = mysql_connect(database).await?; + let result = conn + .exec_iter(sql, mysql_async::Params::Positional(values)) + .await + .map_err(|err| error_chain(&err))?; + let matched = result.affected_rows(); + result + .drop_result() + .await + .map_err(|err| error_chain(&err))?; + conn.disconnect().await.map_err(|err| error_chain(&err))?; + Ok(matched > 0) +} + +/// PostgreSQL 修改資料列。值以轉義字面值拼入(見 [`pg_literal`]); +/// `execute` 回報的列數即匹配列數(PG 對值未變的列也計入), +/// 0 表示目標列不存在 +async fn postgres_update_row( + database: &Database, + table: &str, + input: &RowUpdateInput, +) -> Result { + let client = postgres_connect(database).await?; + let sql = update_statement(quote_pg, table, input, pg_literal); + let matched = client + .execute(sql.as_str(), &[]) + .await + .map_err(|err| error_chain(&err))?; + Ok(matched > 0) +} + + +/// 組裝 `ADD COLUMN` 的欄位定義片段:`名稱 類型 NULL|NOT NULL [DEFAULT 片段]`。 +/// 兩庫皆接受此約束順序(PG 的欄位約束為無序列表)。 +fn column_definition(quote: fn(&str) -> String, input: &ColumnInput) -> String { + let head = format!( + "{} {} {}", + quote(&input.name), + input.data_type, + input.nullability_clause() + ); + match input.default.as_deref() { + Some(default) => format!("{head} DEFAULT {default}"), + None => head, + } +} + +fn timeout_message() -> String { + format!("連線逾時(超過 {} 秒無回應)", CONNECT_TIMEOUT.as_secs()) +} + +/// 展平錯誤鏈為單行訊息:驅動庫頂層的 `Display` 往往語焉不詳 +/// (如 PostgreSQL 的 "db error"),附上 `source()` 才能區分認證失敗、 +/// 庫不存在等原因;已包含在前文中的環節略過,避免重複。 +fn error_chain(err: &(dyn std::error::Error + 'static)) -> String { + let mut message = err.to_string(); + let mut source = err.source(); + while let Some(err) = source { + let text = err.to_string(); + if !text.is_empty() && !message.contains(&text) { + message.push_str(": "); + message.push_str(&text); + } + source = err.source(); + } + message +} + +/// 連接埠轉為驅動庫要求的 `u16`;輸入層已驗證 1–65535,此處僅防禦性回退 +fn tcp_port(database: &Database) -> u16 { + u16::try_from(database.port).unwrap_or_else(|_| database.db_type.default_port() as u16) +} + +/// MySQL 標識符(表名 / 欄位名)以反引號包裹,內嵌反引號加倍。 +/// 拼入 SQL 的名稱皆已先經 information_schema 白名單核對,此處轉義為第二道防線 +fn quote_mysql(identifier: &str) -> String { + format!("`{}`", identifier.replace('`', "``")) +} + +/// PostgreSQL 標識符以雙引號包裹,內嵌雙引號加倍 +fn quote_pg(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} + +async fn mysql_test(database: &Database) -> Result { + use mysql_async::prelude::Queryable; + + let mut conn = mysql_connect(database).await?; + let versions: Vec = conn + .query("SELECT VERSION()") + .await + .map_err(|err| error_chain(&err))?; + conn.disconnect().await.map_err(|err| error_chain(&err))?; + // VERSION() 恰返回一行一列;空結果視為異常而非成功 + versions + .into_iter() + .next() + .ok_or_else(|| "伺服器未返回版本資訊".to_string()) +} + +async fn mysql_tables(database: &Database) -> Result, String> { + use mysql_async::prelude::Queryable; + + let mut conn = mysql_connect(database).await?; + let rows: Vec<(String, String)> = conn + .exec( + "SELECT TABLE_NAME, TABLE_TYPE + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + ORDER BY TABLE_NAME", + (), + ) + .await + .map_err(|err| error_chain(&err))?; + conn.disconnect().await.map_err(|err| error_chain(&err))?; + Ok(rows + .into_iter() + .map(|(name, kind)| TableSummary { name, kind }) + .collect()) +} + +async fn mysql_columns( + database: &Database, + table: &str, +) -> Result>, String> { + use mysql_async::prelude::Queryable; + + let mut conn = mysql_connect(database).await?; + let rows: Vec<(String, String, String, Option, i64)> = conn + .exec( + "SELECT c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, + (pk.COLUMN_NAME IS NOT NULL) + FROM information_schema.COLUMNS c + LEFT JOIN ( + SELECT kcu.COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE kcu + JOIN information_schema.TABLE_CONSTRAINTS tc + ON tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + WHERE kcu.TABLE_SCHEMA = DATABASE() AND kcu.TABLE_NAME = ? + ) pk ON pk.COLUMN_NAME = c.COLUMN_NAME + WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION", + (table, table), + ) + .await + .map_err(|err| error_chain(&err))?; + conn.disconnect().await.map_err(|err| error_chain(&err))?; + let columns: Vec = rows + .into_iter() + .map(|(name, data_type, nullable, default, primary_key)| ColumnSummary { + name, + data_type, + nullable: nullable == "YES", + default, + primary_key: primary_key != 0, + }) + .collect(); + // 表至少有一個欄位;空結果意味著當前 schema 下沒有這張表 + Ok((!columns.is_empty()).then_some(columns)) +} + +async fn mysql_rows( + database: &Database, + table: &str, + limit: i64, + offset: i64, + columns: &[ColumnSummary], +) -> Result { + use mysql_async::prelude::Queryable; + + let quoted = quote_mysql(table); + let mut conn = mysql_connect(database).await?; + let total: i64 = conn + .exec_first(format!("SELECT COUNT(*) FROM {quoted}"), ()) + .await + .map_err(|err| error_chain(&err))? + .unwrap_or(0); + // SELECT * 的輸出順序即表定義順序,與 columns 的 ORDINAL_POSITION 對齊 + let rows: Vec = conn + .exec( + format!("SELECT * FROM {quoted} LIMIT ? OFFSET ?"), + (limit, offset), + ) + .await + .map_err(|err| error_chain(&err))?; + conn.disconnect().await.map_err(|err| error_chain(&err))?; + Ok(TableRows { + columns: columns.iter().map(|column| column.name.clone()).collect(), + rows: rows + .iter() + .map(|row| { + // Row::as_ref(index) 對 NULL 儲存格返回 None,含義恰好對齊 + (0..row.len()) + .map(|index| row.as_ref(index).and_then(mysql_value_text)) + .collect() + }) + .collect(), + total, + limit, + offset, + }) +} + +/// 儲存格值轉字串表示;NULL 為 `None`,其餘按具體承載類型格式化 +fn mysql_value_text(value: &mysql_async::Value) -> Option { + use mysql_async::Value; + + match value { + Value::NULL => None, + Value::Bytes(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()), + Value::Int(x) => Some(x.to_string()), + Value::UInt(x) => Some(x.to_string()), + Value::Float(x) => Some(x.to_string()), + Value::Double(x) => Some(x.to_string()), + Value::Date(year, month, day, hour, minute, second, micros) => Some(if *micros > 0 { + format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{micros:06}") + } else { + format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}") + }), + Value::Time(negative, days, hour, minute, second, micros) => { + let text = if *micros > 0 { + format!("{days}d {hour:02}:{minute:02}:{second:02}.{micros:06}") + } else { + format!("{days}d {hour:02}:{minute:02}:{second:02}") + }; + Some(if *negative { format!("-{text}") } else { text }) + } + } +} + +/// 執行單條 DDL(無結果集)。`exec_drop` 走預備語句協議, +/// 連線未啟用多語句標誌,語句中的分號只會是語法錯誤 +async fn mysql_exec_ddl(database: &Database, sql: &str) -> Result<(), String> { + use mysql_async::prelude::Queryable; + + let mut conn = mysql_connect(database).await?; + conn.exec_drop(sql, ()) + .await + .map_err(|err| error_chain(&err))?; + conn.disconnect().await.map_err(|err| error_chain(&err)) +} + +async fn mysql_connect(database: &Database) -> Result { + let opts = mysql_async::OptsBuilder::default() + .ip_or_hostname(database.host.clone()) + .tcp_port(tcp_port(database)) + .user(Some(database.username.clone())) + .pass(Some(database.password.clone())) + .db_name(Some(database.database_name.clone())) + // UPDATE 的 affected rows 回報「匹配列數」而非「實際改變列數」, + // 使 0 可靠地表示目標列不存在(見 mysql_update_row);對其他操作無影響 + .client_found_rows(true); + mysql_async::Conn::new(opts) + .await + .map_err(|err| error_chain(&err)) +} + +async fn postgres_test(database: &Database) -> Result { + let client = postgres_connect(database).await?; + let row = client + .query_one("SELECT version()", &[]) + .await + .map_err(|err| error_chain(&err))?; + // version() 形如 "PostgreSQL 16.4 (Homebrew) on aarch64-...",截去主機描述 + let version: String = row.get(0); + Ok(match version.split_once(" on ") { + Some((trimmed, _)) => trimmed.to_string(), + None => version, + }) +} + +async fn postgres_tables(database: &Database) -> Result, String> { + let client = postgres_connect(database).await?; + let rows = client + .query( + "SELECT table_name, table_type + FROM information_schema.tables + WHERE table_schema = current_schema() + ORDER BY table_name", + &[], + ) + .await + .map_err(|err| error_chain(&err))?; + Ok(rows + .iter() + .map(|row| TableSummary { + name: row.get(0), + kind: row.get(1), + }) + .collect()) +} + +async fn postgres_columns( + database: &Database, + table: &str, +) -> Result>, String> { + let client = postgres_connect(database).await?; + // data_type 不含長度(如 "character varying"),對齊 MySQL 的展示習慣補上 (n) + let rows = client + .query( + "SELECT c.column_name, + CASE + WHEN c.data_type IN ('character varying', 'character') + AND c.character_maximum_length IS NOT NULL + THEN c.data_type || '(' || c.character_maximum_length || ')' + ELSE c.data_type + END, + c.is_nullable, + c.column_default, + (pk.column_name IS NOT NULL) + FROM information_schema.columns c + LEFT JOIN ( + SELECT kcu.column_name + FROM information_schema.key_column_usage kcu + JOIN information_schema.table_constraints tc + ON tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + AND tc.constraint_type = 'PRIMARY KEY' + WHERE kcu.table_schema = current_schema() AND kcu.table_name = $1 + ) pk ON pk.column_name = c.column_name + WHERE c.table_schema = current_schema() AND c.table_name = $1 + ORDER BY c.ordinal_position", + &[&table], + ) + .await + .map_err(|err| error_chain(&err))?; + let columns: Vec = rows + .iter() + .map(|row| { + let nullable: String = row.get(2); + ColumnSummary { + name: row.get(0), + data_type: row.get(1), + nullable: nullable == "YES", + default: row.get(3), + primary_key: row.get(4), + } + }) + .collect(); + // 表至少有一個欄位;空結果意味著當前 schema 下沒有這張表 + Ok((!columns.is_empty()).then_some(columns)) +} + +async fn postgres_rows( + database: &Database, + table: &str, + limit: i64, + offset: i64, + columns: &[ColumnSummary], +) -> Result { + let client = postgres_connect(database).await?; + let quoted_table = quote_pg(table); + // 逐欄 ::text:tokio-postgres 不做隱式類型轉換,統一以 text 取回便於展示; + // 轉換不改變輸出欄位名,順序同 columns 的 ordinal_position + let projection = columns + .iter() + .map(|column| format!("{}::text", quote_pg(&column.name))) + .collect::>() + .join(", "); + let count_row = client + .query_one(format!("SELECT COUNT(*) FROM {quoted_table}").as_str(), &[]) + .await + .map_err(|err| error_chain(&err))?; + let rows = client + .query( + format!("SELECT {projection} FROM {quoted_table} LIMIT $1 OFFSET $2").as_str(), + &[&limit, &offset], + ) + .await + .map_err(|err| error_chain(&err))?; + let mut data = Vec::with_capacity(rows.len()); + for row in &rows { + let mut cells = Vec::with_capacity(columns.len()); + for index in 0..columns.len() { + cells.push( + row.try_get::<_, Option>(index) + .map_err(|err| error_chain(&err))?, + ); + } + data.push(cells); + } + Ok(TableRows { + columns: columns.iter().map(|column| column.name.clone()).collect(), + rows: data, + total: count_row.get(0), + limit, + offset, + }) +} + +/// 執行單條 DDL。`execute` 走擴展協議(Parse/Bind/Execute), +/// 不接受以分號連接的多條語句 +async fn postgres_exec_ddl(database: &Database, sql: &str) -> Result<(), String> { + let client = postgres_connect(database).await?; + client + .execute(sql, &[]) + .await + .map_err(|err| error_chain(&err))?; + Ok(()) +} + +/// 建立 PostgreSQL 連線;連線驅動任務在背景運行,隨 `Client` 斷開而結束 +async fn postgres_connect(database: &Database) -> Result { + let mut config = tokio_postgres::Config::new(); + config + .host(database.host.clone()) + .port(tcp_port(database)) + .user(database.username.clone()) + .password(database.password.clone()) + .dbname(database.database_name.clone()) + .connect_timeout(CONNECT_TIMEOUT); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .map_err(|err| error_chain(&err))?; + tokio::spawn(async move { + // 驅動任務必須持續 poll 至連線結束;錯誤無需上報(查詢端會先收到) + let _ = connection.await; + }); + Ok(client) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 建立指向本機「已關閉埠」的連接記錄,快速得到確定性的連線失敗 + fn database_to_closed_port(db_type: DatabaseType) -> Database { + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); // 立即釋放,埠上不再有任何監聽 + port + }; + Database { + id: 1, + name: "closed".into(), + db_type, + host: "127.0.0.1".into(), + port: port as i64, + username: "app".into(), + password: String::new(), + database_name: "shop".into(), + created_at: String::new(), + updated_at: String::new(), + } + } + + #[tokio::test] + async fn test_reports_failure_for_closed_port() { + for db_type in [DatabaseType::MySql, DatabaseType::Postgres] { + let outcome = test(&database_to_closed_port(db_type)).await; + assert!(!outcome.ok, "{db_type:?}"); + let error = outcome.error.expect("失敗時應附錯誤訊息"); + assert!(!error.is_empty()); + assert!(outcome.server_version.is_none()); + assert!(outcome.elapsed_ms < CONNECT_TIMEOUT.as_millis() as u64); + } + } + + #[tokio::test] + async fn list_tables_reports_error_for_closed_port() { + for db_type in [DatabaseType::MySql, DatabaseType::Postgres] { + let error = list_tables(&database_to_closed_port(db_type)) + .await + .unwrap_err(); + assert!(!error.is_empty(), "{db_type:?}"); + } + } + + #[tokio::test] + async fn describe_and_select_report_error_for_closed_port() { + for db_type in [DatabaseType::MySql, DatabaseType::Postgres] { + let database = database_to_closed_port(db_type); + let error = describe_table(&database, "users").await.unwrap_err(); + assert!(!error.is_empty(), "{db_type:?}"); + let error = select_rows(&database, "users", 50, 0).await.unwrap_err(); + assert!(!error.is_empty(), "{db_type:?}"); + } + } + + #[test] + fn identifier_quoting_doubles_embedded_quote_chars() { + assert_eq!(quote_mysql("users"), "`users`"); + assert_eq!(quote_mysql("we`ird"), "`we``ird`"); + assert_eq!(quote_pg("users"), "\"users\""); + assert_eq!(quote_pg("we\"ird"), "\"we\"\"ird\""); + } + + #[test] + fn column_summary_serializes_with_type_name() { + let column = ColumnSummary { + name: "email".into(), + data_type: "varchar(255)".into(), + nullable: false, + default: Some("''".into()), + primary_key: false, + }; + let json = serde_json::to_value(&column).unwrap(); + assert_eq!(json.get("name"), Some(&serde_json::json!("email"))); + assert_eq!(json.get("type"), Some(&serde_json::json!("varchar(255)"))); + assert_eq!(json.get("nullable"), Some(&serde_json::json!(false))); + assert_eq!(json.get("default"), Some(&serde_json::json!("''"))); + assert_eq!(json.get("primary_key"), Some(&serde_json::json!(false))); + + // 無默認值時整鍵省略;主鍵欄位標記為 true + let json = serde_json::to_value(ColumnSummary { + default: None, + primary_key: true, + ..column + }) + .unwrap(); + assert!(json.get("default").is_none()); + assert_eq!(json.get("primary_key"), Some(&serde_json::json!(true))); + } + + fn column_input(name: &str, data_type: &str) -> ColumnInput { + ColumnInput { + name: name.into(), + data_type: data_type.into(), + nullable: None, + default: None, + } + } + + #[test] + fn column_input_normalizes_and_validates() { + // 名稱 / 類型去首尾空白;默認值空字串視為不設 + let normalized = ColumnInput { + default: Some(" ".into()), + ..column_input(" nickname ", " varchar(50) ") + } + .normalized() + .unwrap(); + assert_eq!(normalized.name, "nickname"); + assert_eq!(normalized.data_type, "varchar(50)"); + assert_eq!(normalized.default, None); + assert_eq!(normalized.nullable, None); // 缺省語義由 nullability_clause 處理 + + // 保留顯式默認值(內部空白不動) + let normalized = ColumnInput { + default: Some(" 'hello world' ".into()), + ..column_input("bio", "text") + } + .normalized() + .unwrap(); + assert_eq!(normalized.default.as_deref(), Some("'hello world'")); + + // 必填為空 / 過長 / 控制字元 + assert!(column_input("", "int").normalized().is_err()); + assert!( + column_input("x".repeat(65).as_str(), "int") + .normalized() + .is_err() + ); + assert!(column_input("bad\nname", "int").normalized().is_err()); + assert!(column_input("ok", " ").normalized().is_err()); + assert!( + column_input("ok", "t".repeat(129).as_str()) + .normalized() + .is_err() + ); + + // 類型 / 默認值片段:分號、註解、控制字元一律拒絕(多重語句與註解攻擊面) + //(尾端換行會被 trim 視為無害,此處測內嵌控制字元) + for fragment in ["int; DROP TABLE x", "int--", "int/*", "in\nt"] { + assert!( + column_input("ok", fragment).normalized().is_err(), + "type {fragment:?} 應被拒絕" + ); + assert!( + ColumnInput { + default: Some(fragment.into()), + ..column_input("ok", "int") + } + .normalized() + .is_err(), + "default {fragment:?} 應被拒絕" + ); + } + // 默認值片段過長 + assert!( + ColumnInput { + default: Some("0".repeat(257)), + ..column_input("ok", "int") + } + .normalized() + .is_err() + ); + } + + #[test] + fn nullability_clause_defaults_to_nullable() { + assert_eq!(column_input("x", "int").nullability_clause(), "NULL"); + assert_eq!( + ColumnInput { + nullable: Some(true), + ..column_input("x", "int") + } + .nullability_clause(), + "NULL" + ); + assert_eq!( + ColumnInput { + nullable: Some(false), + ..column_input("x", "int") + } + .nullability_clause(), + "NOT NULL" + ); + } + + #[test] + fn column_definition_quotes_name_and_appends_clauses() { + // 缺省:可空、無默認值 + let definition = column_definition(quote_mysql, &column_input("name", "varchar(255)")); + assert_eq!(definition, "`name` varchar(255) NULL"); + + // 顯式 NOT NULL + 默認值;標識符轉義在兩庫各自生效 + let input = ColumnInput { + nullable: Some(false), + default: Some("0".into()), + ..column_input("we`ird", "int") + }; + assert_eq!( + column_definition(quote_mysql, &input), + "`we``ird` int NOT NULL DEFAULT 0" + ); + let input = ColumnInput { + nullable: Some(false), + default: Some("0".into()), + ..column_input("we\"ird", "int") + }; + assert_eq!( + column_definition(quote_pg, &input), + "\"we\"\"ird\" int NOT NULL DEFAULT 0" + ); + } + + #[tokio::test] + async fn add_and_drop_column_report_error_for_closed_port() { + for db_type in [DatabaseType::MySql, DatabaseType::Postgres] { + let database = database_to_closed_port(db_type); + let input = column_input("bio", "text").normalized().unwrap(); + let error = add_column(&database, "users", &input).await.unwrap_err(); + assert!(!error.is_empty(), "{db_type:?}"); + let error = drop_column(&database, "users", "bio").await.unwrap_err(); + assert!(!error.is_empty(), "{db_type:?}"); + } + } + + fn row_update_input( + filter: &[(&str, Option<&str>)], + set: &[(&str, Option<&str>)], + ) -> RowUpdateInput { + RowUpdateInput { + filter: filter + .iter() + .map(|(k, v)| ((*k).into(), v.map(str::to_string))) + .collect(), + set: set + .iter() + .map(|(k, v)| ((*k).into(), v.map(str::to_string))) + .collect(), + } + } + + #[test] + fn row_update_input_normalizes_and_validates() { + // 鍵去首尾空白;值原樣保留(內容即資料,含首尾空白) + let normalized = row_update_input( + &[(" id ", Some("7")), ("name", None)], + &[(" email ", Some(" a@b.c "))], + ) + .normalized() + .unwrap(); + assert_eq!(normalized.filter.keys().collect::>(), ["id", "name"]); + assert_eq!( + normalized.set.get("email").map(|value| value.as_deref()), + Some(Some(" a@b.c ")) + ); + + // set / where 不可為空 + assert!(row_update_input(&[("id", None)], &[]).normalized().is_err()); + assert!(row_update_input(&[], &[("x", None)]).normalized().is_err()); + + // 鍵:空、過長、控制字元;值:過長 + assert!( + row_update_input(&[("id", None)], &[((" "), Some("v"))]) + .normalized() + .is_err() + ); + assert!( + row_update_input(&[("id", None)], &[("x".repeat(65).as_str(), None)]) + .normalized() + .is_err() + ); + assert!( + row_update_input(&[("id", None)], &[("bad\nname", None)]) + .normalized() + .is_err() + ); + assert!( + row_update_input(&[("bad\nkey", None)], &[("x", None)]) + .normalized() + .is_err() + ); + assert!( + row_update_input(&[("id", None)], &[("x", Some("v".repeat(65_537).as_str()))]) + .normalized() + .is_err() + ); + } + + #[test] + fn pg_literal_renders_null_and_escapes_quotes() { + assert_eq!(pg_literal(&None), "NULL"); + assert_eq!(pg_literal(&Some("plain".into())), "'plain'"); + // 單引號加倍;反斜線在標準遵循字串下無需處理 + assert_eq!(pg_literal(&Some("it's".into())), "'it''s'"); + assert_eq!(pg_literal(&Some("a\\b".into())), "'a\\b'"); + assert_eq!(pg_literal(&Some("".into())), "''"); + } + + #[test] + fn update_statement_builds_set_and_where_clauses() { + let input = row_update_input( + &[("id", Some("7")), ("tenant", None)], + &[("name", Some("it's")), ("bio", None)], + ); + + // MySQL:值一律 `?` 佔位(實際值走預備語句參數),欄位名反引號轉義 + assert_eq!( + update_statement(quote_mysql, "users", &input, mysql_placeholder), + "UPDATE `users` SET `bio` = ?, `name` = ? WHERE `id` = ? AND `tenant` = ?" + ); + + // PostgreSQL:轉義字面值,欄位名雙引號轉義 + assert_eq!( + update_statement(quote_pg, "users", &input, pg_literal), + "UPDATE \"users\" SET \"bio\" = NULL, \"name\" = 'it''s' \ + WHERE \"id\" = '7' AND \"tenant\" = NULL" + ); + + // 標識符轉義同樣生效於表名 / 欄位名 + let weird = row_update_input(&[("i`d", None)], &[("na`me", None)]); + assert_eq!( + update_statement(quote_mysql, "us`ers", &weird, mysql_placeholder), + "UPDATE `us``ers` SET `na``me` = ? WHERE `i``d` = ?" + ); + } + + #[tokio::test] + async fn update_row_reports_error_for_closed_port() { + for db_type in [DatabaseType::MySql, DatabaseType::Postgres] { + let database = database_to_closed_port(db_type); + let input = row_update_input(&[("id", Some("7"))], &[("name", Some("new"))]); + let error = update_row(&database, "users", &input) + .await + .unwrap_err(); + assert!(!error.is_empty(), "{db_type:?}"); + } + } + + #[test] + fn test_outcome_serializes_shape() { + let ok = TestOutcome { + ok: true, + server_version: Some("8.0.36".into()), + error: None, + elapsed_ms: 12, + }; + let json = serde_json::to_value(&ok).unwrap(); + assert_eq!(json.get("ok"), Some(&serde_json::json!(true))); + assert_eq!( + json.get("server_version"), + Some(&serde_json::json!("8.0.36")) + ); + // None 欄位整鍵省略,失敗案例不出現 server_version 鍵 + assert!(json.get("error").is_none()); + + let failed = serde_json::to_value(TestOutcome { + ok: false, + server_version: None, + error: Some("Connection refused".into()), + elapsed_ms: 3, + }) + .unwrap(); + assert!(failed.get("server_version").is_none()); + assert_eq!( + failed.get("error"), + Some(&serde_json::json!("Connection refused")) + ); + } +} diff --git a/src/controllers/accounts.rs b/src/controllers/accounts.rs new file mode 100644 index 0000000..4d16e21 --- /dev/null +++ b/src/controllers/accounts.rs @@ -0,0 +1,203 @@ +//! 帳戶控制器:`/api/accounts` 資源的 REST CRUD。 + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use serde_json::json; + +use crate::models::account::{Account, AccountInput}; +use crate::state::AppState; +use crate::storage::{self, StorageError}; + +/// 控制器層錯誤:統一轉為帶 JSON 錯誤體的 HTTP 響應。 +#[derive(Debug)] +enum ApiError { + /// 指定的資源不存在(404) + NotFound, + /// 唯一欄位衝突(409) + Conflict, + /// 輸入驗證失敗(400),內為可直接展示的錯誤訊息 + BadRequest(String), + /// 存儲層錯誤(503 / 500) + Storage(StorageError), +} + +impl From for ApiError { + fn from(err: StorageError) -> Self { + if err.is_unique_violation() { + ApiError::Conflict + } else { + ApiError::Storage(err) + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, message) = match self { + ApiError::NotFound => (StatusCode::NOT_FOUND, "帳戶不存在".to_string()), + ApiError::Conflict => ( + StatusCode::CONFLICT, + "username 或 email 已被其他帳戶使用".to_string(), + ), + ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message), + ApiError::Storage(err) => { + tracing::error!("帳戶存儲層錯誤:{err}"); + if matches!(err, StorageError::Pool(_)) { + ( + StatusCode::SERVICE_UNAVAILABLE, + "資料庫暫時不可用".to_string(), + ) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, "內部錯誤".to_string()) + } + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +/// 列出所有帳戶 +async fn list(State(state): State) -> Result>, ApiError> { + let accounts = storage::account::list(&state.db).await?; + Ok(Json(accounts)) +} + +/// 建立帳戶;輸入會先經 [`AccountInput::normalized`] 驗證與正規化 +async fn create( + State(state): State, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let payload = payload.normalized().map_err(ApiError::BadRequest)?; + let account = storage::account::create(&state.db, payload).await?; + Ok((StatusCode::CREATED, Json(account))) +} + +/// 取得單一帳戶 +async fn show( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + storage::account::get_by_id(&state.db, id) + .await? + .map(Json) + .ok_or(ApiError::NotFound) +} + +/// 更新帳戶的全部可編輯欄位(username / email / display_name / is_active) +async fn update( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let payload = payload.normalized().map_err(ApiError::BadRequest)?; + storage::account::update(&state.db, id, payload) + .await? + .map(Json) + .ok_or(ApiError::NotFound) +} + +/// 刪除帳戶 +async fn remove( + State(state): State, + Path(id): Path, +) -> Result { + match storage::account::delete(&state.db, id).await? { + true => Ok(StatusCode::NO_CONTENT), + false => Err(ApiError::NotFound), + } +} + +pub fn routes() -> Router { + Router::new() + .route("/api/accounts", get(list).post(create)) + .route("/api/accounts/{id}", get(show).put(update).delete(remove)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::testutil; + + fn payload(username: &str, email: &str) -> AccountInput { + AccountInput { + username: username.into(), + email: email.into(), + display_name: None, + is_active: None, + } + } + + #[tokio::test] + async fn accounts_crud_via_handlers() { + let (pool, path) = testutil::test_pool("accounts-api").await; + let state = State(AppState { + db: pool.clone(), + sessions: crate::session::SessionStore::new(), + }); + + // 建立:輸入被 trim、display_name 默認同 username → 201 + let (status, Json(created)) = + create(state.clone(), Json(payload(" alice ", "alice@example.com"))) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert_eq!(created.username, "alice"); + assert_eq!(created.display_name, "alice"); + + // 密碼雜湊永不外洩到序列化結果 + let json = serde_json::to_value(&created).unwrap(); + assert!(json.get("password_hash").is_none()); + + // 列表包含新帳戶(另有初始化自動建立的 admin 在前) + let Json(accounts) = list(state.clone()).await.unwrap(); + assert_eq!(accounts.len(), 2); + assert_eq!(accounts[0].username, "admin"); + assert_eq!(accounts[1].id, created.id); + + // username 重複 → 409;輸入驗證失敗 → 400 + let err = create(state.clone(), Json(payload("alice", "bob@example.com"))) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::CONFLICT); + let err = create(state.clone(), Json(payload("", "x@example.com"))) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST); + + // 查詢:存在 / 不存在 → 200 / 404 + let Json(found) = show(state.clone(), Path(created.id)).await.unwrap(); + assert_eq!(found.id, created.id); + let err = show(state.clone(), Path(9999)).await.unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + + // 更新:欄位被覆寫 + let Json(updated) = update( + state.clone(), + Path(created.id), + Json(AccountInput { + username: "alice".into(), + email: "alice@example.com".into(), + display_name: Some("Alice".into()), + is_active: Some(false), + }), + ) + .await + .unwrap(); + assert_eq!(updated.display_name, "Alice"); + assert!(!updated.is_active); + + // 刪除:→ 204,再刪 → 404 + assert_eq!( + remove(state.clone(), Path(created.id)).await.unwrap(), + StatusCode::NO_CONTENT + ); + let err = remove(state, Path(created.id)).await.unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + + pool.close(); + testutil::cleanup(&path); + } +} diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs new file mode 100644 index 0000000..92aa252 --- /dev/null +++ b/src/controllers/auth.rs @@ -0,0 +1,702 @@ +//! 認證控制器:登入頁面、會話的建立與銷毀,以及變更密碼。 +//! +//! - `GET /login` 渲染登入表單;已登入者直接回到首頁 +//! - `POST /login` 校驗用戶名 / 密碼(Argon2),成功後建立會話並 +//! 以 `Set-Cookie` 下發 token,重定向回首頁 +//! - `POST /logout` 銷毀會話、清除 Cookie,回到登入頁 +//! - `GET /password` 變更密碼頁(`CurrentUser` 保證已登入) +//! - `POST /password` 校驗原密碼與新密碼規則後更新密碼;成功即銷毀 +//! 該帳戶的全部會話(含當前)、清除 Cookie,導向 `/login?changed=1` + +use std::sync::OnceLock; + +use askama::Template; +use axum::Router; +use axum::extract::{Form, Query, State}; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; +use serde::Deserialize; + +use crate::models::account::{Account, hash_password, verify_password}; +use crate::session::{CurrentUser, clear_cookie_header, set_cookie_header, token_from_headers}; +use crate::state::AppState; +use crate::storage; + +/// 登入表單載荷(`application/x-www-form-urlencoded`) +#[derive(Debug, Deserialize)] +struct LoginForm { + username: String, + password: String, +} + +/// 登入頁查詢參數:`?changed=1` 表示剛完成密碼變更,顯示成功提示 +#[derive(Debug, Default, Deserialize)] +struct LoginQuery { + changed: Option, +} + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginTemplate { + error: Option, + notice: Option, +} + +/// 渲染登入頁;`status` 用於失敗重渲染時上報 401 +fn render_login(error: Option, notice: Option, status: StatusCode) -> Response { + let tpl = LoginTemplate { error, notice }; + match tpl.render() { + Ok(html) => (status, Html(html)).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "登入頁渲染失敗").into_response(), + } +} + +/// 登入頁;已持有效會話者直接回到首頁 +async fn login_page( + State(state): State, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> Response { + match token_from_headers(&headers) { + Some(token) if state.sessions.get(&token).is_some() => Redirect::to("/").into_response(), + _ => render_login( + None, + query + .changed + .is_some() + .then(|| "密碼已更新,請以新密碼重新登入。".to_string()), + StatusCode::OK, + ), + } +} + +/// 提交登入:校驗帳密,成功則建立會話並下發 Cookie +async fn login(State(state): State, Form(input): Form) -> Response { + let username = input.username.trim().to_string(); + let account = match storage::account::get_by_username(&state.db, &username).await { + Ok(account) => account, + Err(err) => { + tracing::error!("登入查詢失敗:{err}"); + return render_login( + Some("系統暫時不可用,請稍後再試".into()), + None, + StatusCode::OK, + ); + } + }; + + if !credentials_valid(account.as_ref(), &input.password) { + tracing::warn!("登入失敗:username={username:?}"); + return render_login( + Some("用戶名或密碼不正確".into()), + None, + StatusCode::UNAUTHORIZED, + ); + } + + let account = account.expect("credentials_valid 為 true 時帳號必存在"); + let token = state + .sessions + .insert(account.id, &account.username, &account.display_name); + tracing::info!("用戶登入成功:username={}", account.username); + + let mut response = Redirect::to("/").into_response(); + append_set_cookie(&mut response, set_cookie_header(&token)); + response +} + +/// 登出:銷毀會話並清除 Cookie +async fn logout(State(state): State, headers: axum::http::HeaderMap) -> Response { + if let Some(token) = token_from_headers(&headers) { + state.sessions.remove(&token); + } + let mut response = Redirect::to("/login").into_response(); + append_set_cookie(&mut response, clear_cookie_header()); + response +} + +/// 新密碼的長度下限(字符數);與密碼頁表單的 `minlength` 一致 +const PASSWORD_MIN_CHARS: usize = 8; +/// 新密碼的長度上限(字符數);與密碼頁表單的 `maxlength` 一致 +const PASSWORD_MAX_CHARS: usize = 128; + +/// 變更密碼表單載荷(`application/x-www-form-urlencoded`) +#[derive(Debug, Deserialize)] +struct PasswordForm { + current_password: String, + new_password: String, + confirm_password: String, +} + +#[derive(Template)] +#[template(path = "password.html")] +struct PasswordTemplate { + user_display_name: String, + user_username: String, + /// 側邊導航欄的當前頁標記;主導航無密碼項,僅標記所屬頁 + nav_active: &'static str, + /// 失敗重渲染時的錯誤訊息 + error: Option, +} + +/// 渲染變更密碼頁;`status` 用於失敗重渲染時上報 4xx +fn render_password(user: &CurrentUser, error: Option, status: StatusCode) -> Response { + let tpl = PasswordTemplate { + user_display_name: user.display_name.clone(), + user_username: user.username.clone(), + nav_active: "password", + error, + }; + match tpl.render() { + Ok(html) => (status, Html(html)).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "密碼頁渲染失敗").into_response(), + } +} + +/// 變更密碼頁;`CurrentUser` 提取器保證已登入,未登入者被導向 `/login` +async fn password_page(user: CurrentUser) -> Response { + render_password(&user, None, StatusCode::OK) +} + +/// 提交變更密碼:校驗原密碼與新密碼規則,成功後寫入新雜湊、銷毀該 +/// 帳戶的全部會話(含當前)並導向 `/login?changed=1`,以新密碼重新登入。 +async fn change_password( + State(state): State, + user: CurrentUser, + Form(input): Form, +) -> Response { + if let Err(message) = validate_new_password(&input.new_password, &input.confirm_password) { + return render_password(&user, Some(message), StatusCode::BAD_REQUEST); + } + + let account = match storage::account::get_by_id(&state.db, user.user_id).await { + Ok(account) => account, + Err(err) => { + tracing::error!("變更密碼查詢失敗:{err}"); + return render_password( + &user, + Some("系統暫時不可用,請稍後再試".into()), + StatusCode::OK, + ); + } + }; + let Some(account) = account else { + // 帳號已被刪除:殘留會話一併銷毀,回到登入頁 + state.sessions.remove_all_for_user(user.user_id); + return expired_session_redirect(); + }; + + if !account.is_active { + return render_password( + &user, + Some("帳號已停用,無法變更密碼".into()), + StatusCode::FORBIDDEN, + ); + } + if !verify_password(&input.current_password, &account.password_hash) { + tracing::warn!("變更密碼失敗(原密碼不正確):username={}", user.username); + return render_password(&user, Some("原密碼不正確".into()), StatusCode::UNAUTHORIZED); + } + if verify_password(&input.new_password, &account.password_hash) { + return render_password( + &user, + Some("新密碼不可與原密碼相同".into()), + StatusCode::BAD_REQUEST, + ); + } + + let new_hash = match hash_password(&input.new_password) { + Ok(hash) => hash, + Err(err) => { + tracing::error!("新密碼雜湊失敗:{err}"); + return render_password( + &user, + Some("系統暫時不可用,請稍後再試".into()), + StatusCode::OK, + ); + } + }; + match storage::account::update_password(&state.db, account.id, &new_hash).await { + Ok(true) => {} + Ok(false) => { + // 帳號在驗證後被刪除:與查無帳號同等處理 + state.sessions.remove_all_for_user(account.id); + return expired_session_redirect(); + } + Err(err) => { + tracing::error!("變更密碼寫入失敗:{err}"); + return render_password( + &user, + Some("系統暫時不可用,請稍後再試".into()), + StatusCode::OK, + ); + } + } + + tracing::info!("密碼已變更:username={}", user.username); + state.sessions.remove_all_for_user(account.id); + let mut response = Redirect::to("/login?changed=1").into_response(); + append_set_cookie(&mut response, clear_cookie_header()); + response +} + +/// 帳號已不存在時的回應:清除 Cookie 並回到登入頁 +fn expired_session_redirect() -> Response { + let mut response = Redirect::to("/login").into_response(); + append_set_cookie(&mut response, clear_cookie_header()); + response +} + +/// 校驗新密碼:兩次輸入一致,長度介於 [`PASSWORD_MIN_CHARS`] 與 +/// [`PASSWORD_MAX_CHARS`] 之間。`Err` 的訊息可直接展示在頁面錯誤條。 +fn validate_new_password(new: &str, confirm: &str) -> Result<(), String> { + if new != confirm { + return Err("兩次輸入的新密碼不一致".into()); + } + let length = new.chars().count(); + if length < PASSWORD_MIN_CHARS { + return Err(format!("新密碼長度至少 {PASSWORD_MIN_CHARS} 個字符")); + } + if length > PASSWORD_MAX_CHARS { + return Err(format!("新密碼長度不可超過 {PASSWORD_MAX_CHARS} 個字符")); + } + Ok(()) +} + +/// 校驗登入:帳號需存在、啟用且密碼相符。 +/// +/// 帳號不存在時仍對一個固定雜湊跑一次 Argon2 驗證,使「用戶不存在」 +/// 與「密碼錯誤」的回應時間相近,避免攻擊者以時間差列舉有效用戶名。 +fn credentials_valid(account: Option<&Account>, password: &str) -> bool { + match account { + Some(account) => account.is_active && verify_password(password, &account.password_hash), + None => { + static DUMMY_HASH: OnceLock = OnceLock::new(); + let hash = DUMMY_HASH + .get_or_init(|| hash_password("alterdb-dummy-password").unwrap_or_default()); + let _ = verify_password(password, hash); + false + } + } +} + +fn append_set_cookie(response: &mut Response, value: String) { + let header = HeaderValue::from_str(&value).expect("會話 Cookie 值應為合法標頭"); + response.headers_mut().append(header::SET_COOKIE, header); +} + +pub fn routes() -> Router { + Router::new() + .route("/login", get(login_page).post(login)) + .route("/logout", post(logout)) + .route("/password", get(password_page).post(change_password)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::SessionStore; + use crate::storage::testutil; + use axum::http::header::{COOKIE, LOCATION}; + use std::path::PathBuf; + + /// 建立測試狀態(含初始化完畢、已播種 admin 的暫存資料庫) + async fn test_state(label: &str) -> (AppState, deadpool_sqlite::Pool, PathBuf) { + let (pool, path) = testutil::test_pool(label).await; + let state = AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }; + (state, pool, path) + } + + /// 登入成功後回應中的會話 token + async fn login_token(state: &AppState, username: &str, password: &str) -> Option { + let response = login( + State(state.clone()), + Form(LoginForm { + username: username.into(), + password: password.into(), + }), + ) + .await; + if response.status() != StatusCode::SEE_OTHER { + return None; + } + response + .headers() + .get(header::SET_COOKIE)? + .to_str() + .ok()? + .split(';') + .next()? + .strip_prefix("alterdb_session=") + .map(str::to_string) + } + + /// 讀取回應主體為字串(斷言頁面內容用) + async fn response_body(response: Response) -> String { + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8(body.to_vec()).unwrap() + } + + /// 以既有會話構造 CurrentUser(模擬提取器的解析結果) + fn current_user(state: &AppState, token: &str) -> CurrentUser { + let session = state.sessions.get(token).expect("會話應存在"); + CurrentUser { + user_id: session.user_id, + username: session.username, + display_name: session.display_name, + } + } + + #[tokio::test] + async fn login_success_sets_session_cookie() { + let (state, pool, path) = test_state("auth-login-ok").await; + + let response = login( + State(state.clone()), + Form(LoginForm { + username: " admin ".into(), // 用戶名會被 trim + password: "admin".into(), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get(LOCATION).unwrap(), "/"); + + let cookie = response.headers().get(header::SET_COOKIE).unwrap(); + let cookie = cookie.to_str().unwrap(); + assert!(cookie.starts_with("alterdb_session=")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Lax")); + + // Cookie 對應到有效會話 + let token = cookie.split(';').next().unwrap(); + let token = token.strip_prefix("alterdb_session=").unwrap(); + let session = state.sessions.get(token).expect("會話應已建立"); + assert_eq!(session.username, "admin"); + assert_eq!(session.display_name, "Administrator"); + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn login_failure_re_renders_form_with_401() { + let (state, pool, path) = test_state("auth-login-bad").await; + + for (username, password) in [("admin", "wrong"), ("nobody", "whatever")] { + let response = login( + State(state.clone()), + Form(LoginForm { + username: username.into(), + password: password.into(), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{username}"); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("用戶名或密碼不正確"), "{username}"); + assert!(html.contains("name=\"username\"")); // 表單仍在,可直接重試 + } + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn login_rejects_disabled_account() { + let (state, pool, path) = test_state("auth-login-disabled").await; + + // 建立後即停用的帳戶:即使日後補上正確密碼也不得登入 + storage::account::create( + &pool, + crate::models::account::AccountInput { + username: "bob".into(), + email: "bob@example.com".into(), + display_name: None, + is_active: Some(false), + }, + ) + .await + .unwrap(); + + let response = login( + State(state), + Form(LoginForm { + username: "bob".into(), + password: "whatever".into(), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn login_page_redirects_when_already_logged_in() { + let (state, pool, path) = test_state("auth-page-logged").await; + + // 未登入:渲染表單(200) + let response = login_page( + State(state.clone()), + Query(LoginQuery::default()), + axum::http::HeaderMap::new(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + // 已登入:直接回到首頁 + let token = state.sessions.insert(1, "admin", "Administrator"); + let mut headers = axum::http::HeaderMap::new(); + headers.insert(COOKIE, format!("alterdb_session={token}").parse().unwrap()); + let response = login_page(State(state), Query(LoginQuery::default()), headers).await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get(LOCATION).unwrap(), "/"); + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn login_page_shows_notice_after_password_change() { + let (state, pool, path) = test_state("auth-page-notice").await; + + // ?changed=1:顯示成功提示(無錯誤) + let response = login_page( + State(state.clone()), + Query(LoginQuery { + changed: Some("1".into()), + }), + axum::http::HeaderMap::new(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let html = response_body(response).await; + assert!(html.contains("密碼已更新")); + assert!(!html.contains("role=\"alert\"")); // 非錯誤情境 + + // 無查詢參數:不出現提示 + let response = login_page( + State(state), + Query(LoginQuery::default()), + axum::http::HeaderMap::new(), + ) + .await; + let html = response_body(response).await; + assert!(!html.contains("密碼已更新")); + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn logout_destroys_session_and_clears_cookie() { + let (state, pool, path) = test_state("auth-logout").await; + let token = login_token(&state, "admin", "admin") + .await + .expect("登入應成功"); + assert!(state.sessions.get(&token).is_some()); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert(COOKIE, format!("alterdb_session={token}").parse().unwrap()); + let response = logout(State(state.clone()), headers).await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get(LOCATION).unwrap(), "/login"); + + let cookie = response.headers().get(header::SET_COOKIE).unwrap(); + assert!(cookie.to_str().unwrap().starts_with("alterdb_session=;")); + + // 會話已銷毀;舊 token 無法再換取會話 + assert!(state.sessions.get(&token).is_none()); + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn password_page_renders_form() { + let user = CurrentUser { + user_id: 1, + username: "admin".into(), + display_name: "Administrator".into(), + }; + let response = password_page(user).await; + assert_eq!(response.status(), StatusCode::OK); + let html = response_body(response).await; + // 表單三欄齊全,提交至 /password + assert!(html.contains("變更密碼")); + assert!(html.contains("action=\"/password\"")); + for field in ["current_password", "new_password", "confirm_password"] { + assert!(html.contains(&format!("name=\"{field}\"")), "{field}"); + } + // 外殼要素:登入者資訊與側欄的變更密碼入口 + assert!(html.contains("Administrator")); + assert!(html.contains("href=\"/password\"")); + } + + #[tokio::test] + async fn change_password_updates_hash_and_invalidates_sessions() { + let (state, pool, path) = test_state("auth-pw-change").await; + let token = login_token(&state, "admin", "admin") + .await + .expect("登入應成功"); + + let response = change_password( + State(state.clone()), + current_user(&state, &token), + Form(PasswordForm { + current_password: "admin".into(), + new_password: "new-password-123".into(), + confirm_password: "new-password-123".into(), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(LOCATION).unwrap(), + "/login?changed=1" + ); + // Cookie 被清除(舊會話隨之失效) + assert!( + response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .starts_with("alterdb_session=;") + ); + assert!(state.sessions.get(&token).is_none()); + + // 資料庫已更新:新密碼可登入、原密碼不可 + assert!( + login_token(&state, "admin", "new-password-123") + .await + .is_some() + ); + let response = login( + State(state.clone()), + Form(LoginForm { + username: "admin".into(), + password: "admin".into(), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn change_password_rejects_wrong_current_password() { + let (state, pool, path) = test_state("auth-pw-wrong").await; + let token = login_token(&state, "admin", "admin") + .await + .expect("登入應成功"); + + let response = change_password( + State(state.clone()), + current_user(&state, &token), + Form(PasswordForm { + current_password: "wrong".into(), + new_password: "new-password-123".into(), + confirm_password: "new-password-123".into(), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let html = response_body(response).await; + assert!(html.contains("原密碼不正確")); + assert!(html.contains("name=\"current_password\"")); // 表單仍在,可直接重試 + + // 密碼未變更:原密碼仍可登入,會話未被銷毀 + assert!(state.sessions.get(&token).is_some()); + assert!(login_token(&state, "admin", "admin").await.is_some()); + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn change_password_validates_new_password() { + let (state, pool, path) = test_state("auth-pw-validate").await; + let token = login_token(&state, "admin", "admin") + .await + .expect("登入應成功"); + let user = current_user(&state, &token); + + for (new, confirm, message) in [ + ( + "new-password-123", + "different-456", + "兩次輸入的新密碼不一致", + ), + ("short", "short", "新密碼長度至少 8 個字符"), + ] { + let response = change_password( + State(state.clone()), + user.clone(), + Form(PasswordForm { + current_password: "admin".into(), + new_password: new.into(), + confirm_password: confirm.into(), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{new}"); + let html = response_body(response).await; + assert!(html.contains(message), "{new}"); + } + + // 密碼未變更:原密碼仍可登入 + assert!(login_token(&state, "admin", "admin").await.is_some()); + + pool.close(); + testutil::cleanup(&path); + } + + #[tokio::test] + async fn change_password_rejects_same_as_current() { + let (state, pool, path) = test_state("auth-pw-same").await; + // 原密碼 admin 僅 5 字符,先換成符合長度要求的密碼再測「同原密碼」 + let admin = storage::account::get_by_username(&state.db, "admin") + .await + .unwrap() + .unwrap(); + let hash = hash_password("long-enough-123").unwrap(); + storage::account::update_password(&state.db, admin.id, &hash) + .await + .unwrap(); + + let token = login_token(&state, "admin", "long-enough-123") + .await + .expect("登入應成功"); + let response = change_password( + State(state.clone()), + current_user(&state, &token), + Form(PasswordForm { + current_password: "long-enough-123".into(), + new_password: "long-enough-123".into(), + confirm_password: "long-enough-123".into(), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let html = response_body(response).await; + assert!(html.contains("新密碼不可與原密碼相同")); + + pool.close(); + testutil::cleanup(&path); + } +} diff --git a/src/controllers/database.rs b/src/controllers/database.rs new file mode 100644 index 0000000..65f5831 --- /dev/null +++ b/src/controllers/database.rs @@ -0,0 +1,791 @@ +//! 數據庫連接控制器:`/api/databases` 資源的 REST CRUD, +//! 以及對外部數據庫的實際連線操作(測試連接、列出數據表、 +//! 檢視欄位結構、分頁瀏覽資料、新增 / 刪除欄位與修改資料列)。 + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; + +use crate::connect; +use crate::models::database::{Database, DatabaseInput}; +use crate::state::AppState; +use crate::storage::{self, StorageError}; + +/// 控制器層錯誤:統一轉為帶 JSON 錯誤體的 HTTP 響應。 +#[derive(Debug)] +enum ApiError { + /// 指定的資源不存在(404) + NotFound, + /// 目標庫中不存在指定的資料表(404) + TableNotFound, + /// 目標表中不存在指定的欄位(404) + ColumnNotFound, + /// 唯一性衝突(409),內為可直接展示的錯誤訊息 + Conflict(String), + /// 輸入驗證失敗(400),內為可直接展示的錯誤訊息 + BadRequest(String), + /// 無法連上目標數據庫(502),內為連線錯誤訊息 + Upstream(String), + /// 存儲層錯誤(503 / 500) + Storage(StorageError), +} + +impl From for ApiError { + fn from(err: StorageError) -> Self { + if err.is_unique_violation() { + ApiError::Conflict("name 已被其他數據庫連接使用".to_string()) + } else { + ApiError::Storage(err) + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, message) = match self { + ApiError::NotFound => (StatusCode::NOT_FOUND, "數據庫連接不存在".to_string()), + ApiError::TableNotFound => (StatusCode::NOT_FOUND, "資料表不存在".to_string()), + ApiError::ColumnNotFound => (StatusCode::NOT_FOUND, "欄位不存在".to_string()), + ApiError::Conflict(message) => (StatusCode::CONFLICT, message), + ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message), + ApiError::Upstream(message) => (StatusCode::BAD_GATEWAY, message), + ApiError::Storage(err) => { + tracing::error!("數據庫連接存儲層錯誤:{err}"); + if matches!(err, StorageError::Pool(_)) { + ( + StatusCode::SERVICE_UNAVAILABLE, + "資料庫暫時不可用".to_string(), + ) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, "內部錯誤".to_string()) + } + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +/// 列出所有數據庫連接 +async fn list(State(state): State) -> Result>, ApiError> { + let databases = storage::database::list(&state.db).await?; + Ok(Json(databases)) +} + +/// 建立數據庫連接;輸入會先經 [`DatabaseInput::normalized`] 驗證與正規化 +async fn create( + State(state): State, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let payload = payload.normalized().map_err(ApiError::BadRequest)?; + let database = storage::database::create(&state.db, payload).await?; + Ok((StatusCode::CREATED, Json(database))) +} + +/// 取得單一數據庫連接 +async fn show( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + storage::database::get_by_id(&state.db, id) + .await? + .map(Json) + .ok_or(ApiError::NotFound) +} + +/// 更新連接的全部可編輯欄位 +/// (name / type / host / port / username / password / database_name) +async fn update( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let payload = payload.normalized().map_err(ApiError::BadRequest)?; + storage::database::update(&state.db, id, payload) + .await? + .map(Json) + .ok_or(ApiError::NotFound) +} + +/// 刪除數據庫連接 +async fn remove( + State(state): State, + Path(id): Path, +) -> Result { + match storage::database::delete(&state.db, id).await? { + true => Ok(StatusCode::NO_CONTENT), + false => Err(ApiError::NotFound), + } +} + +/// 測試連接:以連接記錄的參數實際連線目標數據庫。 +/// +/// 連線成功與失敗都以 200 回報(結果本身在 `ok` 欄位), +/// 僅連接記錄不存在時返回 404。 +async fn test_connection( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let database = storage::database::get_by_id(&state.db, id) + .await? + .ok_or(ApiError::NotFound)?; + Ok(Json(connect::test(&database).await)) +} + +/// 列出目標資料庫當前 schema 下的數據表(需可實際連上目標) +async fn tables( + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + let database = storage::database::get_by_id(&state.db, id) + .await? + .ok_or(ApiError::NotFound)?; + let tables = connect::list_tables(&database) + .await + .map_err(ApiError::Upstream)?; + Ok(Json(tables)) +} + +/// 分頁瀏覽資料每頁列數的默認值與上限(保護瀏覽器與目標庫) +const ROWS_LIMIT_DEFAULT: i64 = 50; +const ROWS_LIMIT_MAX: i64 = 200; + +/// `GET .../rows` 的分頁參數(`?limit=&offset=`) +#[derive(Debug, Deserialize)] +struct RowsQuery { + limit: Option, + offset: Option, +} + +impl RowsQuery { + /// 驗證並補默認:limit 1–200(缺省 50)、offset ≥ 0(缺省 0)。 + /// `Err` 的訊息可直接作為 400 響應的錯誤內容。 + fn normalized(self) -> Result<(i64, i64), String> { + let limit = self.limit.unwrap_or(ROWS_LIMIT_DEFAULT); + if !(1..=ROWS_LIMIT_MAX).contains(&limit) { + return Err(format!("limit 須介於 1 與 {ROWS_LIMIT_MAX} 之間")); + } + let offset = self.offset.unwrap_or(0); + if offset < 0 { + return Err("offset 不可為負".into()); + } + Ok((limit, offset)) + } +} + +/// 列出資料表的欄位結構(需可實際連上目標)。 +/// 連接記錄與資料表不存在分別返回 404,連不上目標返回 502。 +async fn table_columns( + State(state): State, + Path((id, table)): Path<(i64, String)>, +) -> Result>, ApiError> { + let database = storage::database::get_by_id(&state.db, id) + .await? + .ok_or(ApiError::NotFound)?; + connect::describe_table(&database, &table) + .await + .map_err(ApiError::Upstream)? + .map(Json) + .ok_or(ApiError::TableNotFound) +} + +/// 分頁瀏覽資料表資料(`?limit=&offset=`,需可實際連上目標) +async fn table_rows( + State(state): State, + Path((id, table)): Path<(i64, String)>, + Query(query): Query, +) -> Result, ApiError> { + let (limit, offset) = query.normalized().map_err(ApiError::BadRequest)?; + let database = storage::database::get_by_id(&state.db, id) + .await? + .ok_or(ApiError::NotFound)?; + connect::select_rows(&database, &table, limit, offset) + .await + .map_err(ApiError::Upstream)? + .map(Json) + .ok_or(ApiError::TableNotFound) +} + +/// 新增欄位(`ALTER TABLE … ADD COLUMN`,直接修改目標庫)。 +/// 先經 [`connect::ColumnInput::normalized`] 驗證、核對資料表存在 +/// 與欄位名未衝突,再執行 DDL;成功返回 204。 +async fn create_column( + State(state): State, + Path((id, table)): Path<(i64, String)>, + Json(payload): Json, +) -> Result { + let payload = payload.normalized().map_err(ApiError::BadRequest)?; + let database = storage::database::get_by_id(&state.db, id) + .await? + .ok_or(ApiError::NotFound)?; + let columns = connect::describe_table(&database, &table) + .await + .map_err(ApiError::Upstream)? + .ok_or(ApiError::TableNotFound)?; + if columns.iter().any(|column| column.name == payload.name) { + return Err(ApiError::Conflict(format!("欄位 {} 已存在", payload.name))); + } + connect::add_column(&database, &table, &payload) + .await + .map_err(ApiError::Upstream)?; + Ok(StatusCode::NO_CONTENT) +} + +/// 刪除欄位(`ALTER TABLE … DROP COLUMN`,該欄位資料隨之丟失且無法復原)。 +/// 先核對資料表與欄位存在再執行 DDL;成功返回 204。 +async fn drop_column( + State(state): State, + Path((id, table, column)): Path<(i64, String, String)>, +) -> Result { + let database = storage::database::get_by_id(&state.db, id) + .await? + .ok_or(ApiError::NotFound)?; + let columns = connect::describe_table(&database, &table) + .await + .map_err(ApiError::Upstream)? + .ok_or(ApiError::TableNotFound)?; + if !columns.iter().any(|existing| existing.name == column) { + return Err(ApiError::ColumnNotFound); + } + connect::drop_column(&database, &table, &column) + .await + .map_err(ApiError::Upstream)?; + Ok(StatusCode::NO_CONTENT) +} + +/// 修改資料列(以主鍵欄位原值定位,`where` 鍵須恰好為主鍵欄位集合)。 +/// 輸入先驗證,再核對資料表存在、主鍵與 `set` 欄位存在,最後執行 +/// UPDATE;目標列不存在(已被刪除或主鍵值已變更)返回 409,成功返回 204。 +async fn update_row( + State(state): State, + Path((id, table)): Path<(i64, String)>, + Json(payload): Json, +) -> Result { + let payload = payload.normalized().map_err(ApiError::BadRequest)?; + let database = storage::database::get_by_id(&state.db, id) + .await? + .ok_or(ApiError::NotFound)?; + let columns = connect::describe_table(&database, &table) + .await + .map_err(ApiError::Upstream)? + .ok_or(ApiError::TableNotFound)?; + + let mut primary_key: Vec<&str> = columns + .iter() + .filter(|column| column.primary_key) + .map(|column| column.name.as_str()) + .collect(); + if primary_key.is_empty() { + return Err(ApiError::BadRequest("此表沒有主鍵,無法定位資料列".into())); + } + // where 的鍵須恰好覆蓋主鍵欄位(順序無關):少了定位不唯一,多了無意義 + let mut filter_columns: Vec<&str> = payload.filter.keys().map(String::as_str).collect(); + primary_key.sort_unstable(); + filter_columns.sort_unstable(); + if filter_columns != primary_key { + return Err(ApiError::BadRequest(format!( + "where 須恰好包含主鍵欄位:{}", + primary_key.join(", ") + ))); + } + for column in payload.set.keys() { + if !columns.iter().any(|existing| &existing.name == column) { + return Err(ApiError::BadRequest(format!("欄位 {column} 不存在"))); + } + } + + match connect::update_row(&database, &table, &payload) + .await + .map_err(ApiError::Upstream)? + { + true => Ok(StatusCode::NO_CONTENT), + false => Err(ApiError::Conflict( + "目標資料列不存在(可能已被刪除或主鍵值已變更)".to_string(), + )), + } +} + +pub fn routes() -> Router { + Router::new() + .route("/api/databases", get(list).post(create)) + .route("/api/databases/{id}", get(show).put(update).delete(remove)) + .route("/api/databases/{id}/test", post(test_connection)) + .route("/api/databases/{id}/tables", get(tables)) + .route( + "/api/databases/{id}/tables/{table}/columns", + get(table_columns).post(create_column), + ) + .route( + "/api/databases/{id}/tables/{table}/columns/{column}", + delete(drop_column), + ) + .route( + "/api/databases/{id}/tables/{table}/rows", + get(table_rows).patch(update_row), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::database::DatabaseType; + use crate::session::SessionStore; + use crate::storage::testutil; + + /// 取得一個「已關閉埠」:綁定後立即釋放,埠上無任何監聽, + /// 連線會即刻被拒,得到確定性的失敗結果 + fn closed_port() -> i64 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + port as i64 + } + + /// 連線類端點(test / tables)的錯誤路徑:目標不可達與連接記錄不存在 + #[tokio::test] + async fn connect_endpoints_handle_unreachable_and_missing() { + let (pool, path) = testutil::test_pool("databases-connect").await; + let state = State(AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }); + + let (_, Json(created)) = create( + state.clone(), + Json(DatabaseInput { + name: "dead".into(), + db_type: DatabaseType::MySql, + host: "127.0.0.1".into(), + port: Some(closed_port()), + username: "app".into(), + password: None, + database_name: "shop".into(), + }), + ) + .await + .unwrap(); + + // 測試連接:連不上也是一種結果 → 200 + ok=false + 錯誤訊息與耗時 + let Json(outcome) = test_connection(state.clone(), Path(created.id)) + .await + .unwrap(); + assert!(!outcome.ok); + assert!(!outcome.error.unwrap_or_default().is_empty()); + assert!(outcome.server_version.is_none()); + + // 列出數據表:連不上屬於錯誤 → 502,錯誤體附原因 + let err = tables(state.clone(), Path(created.id)).await.unwrap_err(); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + serde_json::from_slice::(&body) + .unwrap() + .get("error") + .is_some() + ); + + // 欄位結構 / 資料瀏覽:連不上同樣 → 502 + //(Path 未實作 Clone,先持有 tuple 再各自包裝) + let table = (created.id, "users".to_string()); + let err = table_columns(state.clone(), Path(table.clone())) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY); + let err = table_rows( + state.clone(), + Path(table), + Query(RowsQuery { + limit: None, + offset: None, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY); + + // 新增 / 刪除欄位:連不上 → 502 + let input = connect::ColumnInput { + name: "bio".into(), + data_type: "text".into(), + nullable: None, + default: None, + }; + let err = create_column( + state.clone(), + Path((created.id, "users".into())), + Json(input), + ) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY); + let err = drop_column( + state.clone(), + Path((created.id, "users".into(), "bio".into())), + ) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY); + + // 修改資料列:連不上 → 502 + let err = update_row( + state.clone(), + Path((created.id, "users".into())), + Json(connect::RowUpdateInput { + filter: [("id".into(), Some("7".into()))].into_iter().collect(), + set: [("name".into(), Some("new".into()))].into_iter().collect(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY); + + // 連接記錄不存在 → 各端點都 404 + for id in [created.id + 1000] { + let err = test_connection(state.clone(), Path(id)).await.unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + let err = tables(state.clone(), Path(id)).await.unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + let err = table_columns(state.clone(), Path((id, "users".into()))) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + let err = table_rows( + state.clone(), + Path((id, "users".into())), + Query(RowsQuery { + limit: None, + offset: None, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + let err = create_column( + state.clone(), + Path((id, "users".into())), + Json(connect::ColumnInput { + name: "bio".into(), + data_type: "text".into(), + nullable: None, + default: None, + }), + ) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + let err = drop_column(state.clone(), Path((id, "users".into(), "bio".into()))) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + let err = update_row( + state.clone(), + Path((id, "users".into())), + Json(connect::RowUpdateInput { + filter: [("id".into(), Some("7".into()))].into_iter().collect(), + set: [("name".into(), Some("new".into()))].into_iter().collect(), + }), + ) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + } + + pool.close(); + testutil::cleanup(&path); + } + + /// 新增欄位的輸入驗證在查庫前攔截:名稱 / 類型非法或片段含 + /// 分號、註解、控制字元 → 400,無需連接記錄存在 + #[tokio::test] + async fn create_column_rejects_invalid_input() { + let (pool, path) = testutil::test_pool("databases-column-input").await; + let state = State(AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }); + + let invalid = [ + ("", "int"), + ("bio", ""), + ("bad\nname", "int"), + ("bio", "text; DROP TABLE users"), + ("bio", "text--"), + ("bio", "text/*"), + ]; + for (name, data_type) in invalid { + let err = create_column( + state.clone(), + Path((1, "users".into())), + Json(connect::ColumnInput { + name: name.into(), + data_type: data_type.into(), + nullable: None, + default: None, + }), + ) + .await + .unwrap_err(); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "name={name:?}"); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + serde_json::from_slice::(&body) + .unwrap() + .get("error") + .is_some() + ); + } + + pool.close(); + testutil::cleanup(&path); + } + + /// 修改資料列的輸入驗證在查庫前攔截:set / where 為空、欄位名非法 + /// 或值過長 → 400,無需連接記錄存在 + #[tokio::test] + async fn update_row_rejects_invalid_input() { + let (pool, path) = testutil::test_pool("databases-row-update-input").await; + let state = State(AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }); + + let input = |filter: &[(&str, Option<&str>)], + set: &[(&str, Option<&str>)]| + -> connect::RowUpdateInput { + connect::RowUpdateInput { + filter: filter + .iter() + .map(|(k, v)| ((*k).into(), v.map(str::to_string))) + .collect(), + set: set + .iter() + .map(|(k, v)| ((*k).into(), v.map(str::to_string))) + .collect(), + } + }; + + let invalid = [ + // set 為空 / where 為空 + input(&[("id", None)], &[]), + input(&[], &[("name", None)]), + // 欄位名:空、控制字元 + input(&[("id", None)], &[(" ", Some("v"))]), + input(&[("id", None)], &[("bad\nname", None)]), + input(&[("bad\nkey", None)], &[("name", None)]), + // 值過長 + input( + &[("id", None)], + &[("name", Some("v".repeat(65_537).as_str()))], + ), + ]; + for payload in invalid { + let err = update_row(state.clone(), Path((1, "users".into())), Json(payload)) + .await + .unwrap_err(); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + serde_json::from_slice::(&body) + .unwrap() + .get("error") + .is_some() + ); + } + + pool.close(); + testutil::cleanup(&path); + } + + fn payload(name: &str, db_type: DatabaseType) -> DatabaseInput { + DatabaseInput { + name: name.into(), + db_type, + host: "10.0.0.5".into(), + port: None, + username: "app".into(), + password: Some("secret".into()), + database_name: "shop".into(), + } + } + + /// 分頁參數在查庫前驗證:超出範圍 → 400(錯誤體附可直接展示的訊息), + /// 即使連接記錄不存在也先擋下 + #[tokio::test] + async fn table_rows_reject_out_of_range_pagination() { + let (pool, path) = testutil::test_pool("databases-table-pagination").await; + let state = State(AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }); + + for (limit, offset) in [ + (Some(0), None), + (Some(-3), None), + (Some(201), None), + (None, Some(-1)), + ] { + let err = table_rows( + state.clone(), + Path((1, "users".into())), + Query(RowsQuery { limit, offset }), + ) + .await + .unwrap_err(); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + serde_json::from_slice::(&body) + .unwrap() + .get("error") + .is_some() + ); + } + + pool.close(); + testutil::cleanup(&path); + } + + #[test] + fn rows_query_defaults_and_bounds() { + // 缺省 → 50 / 0 + assert_eq!( + RowsQuery { + limit: None, + offset: None + } + .normalized() + .unwrap(), + (50, 0) + ); + // 邊界值合法 + assert_eq!( + RowsQuery { + limit: Some(200), + offset: Some(12345) + } + .normalized() + .unwrap(), + (200, 12345) + ); + assert_eq!( + RowsQuery { + limit: Some(1), + offset: Some(0) + } + .normalized() + .unwrap(), + (1, 0) + ); + // 超出範圍一律拒絕 + for (limit, offset) in [ + (Some(0), None), + (Some(-1), None), + (Some(201), None), + (None, Some(-1)), + ] { + assert!(RowsQuery { limit, offset }.normalized().is_err()); + } + } + + #[tokio::test] + async fn databases_crud_via_handlers() { + let (pool, path) = testutil::test_pool("databases-api").await; + let state = State(AppState { + db: pool.clone(), + sessions: crate::session::SessionStore::new(), + }); + + // 建立:輸入被 trim、port 按類型補默認 → 201 + let (status, Json(created)) = create( + state.clone(), + Json(payload(" shop-prod ", DatabaseType::MySql)), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert_eq!(created.name, "shop-prod"); + assert_eq!(created.port, 3306); + + // 密碼永不外洩到序列化結果;類型欄位名為 "type" + let json = serde_json::to_value(&created).unwrap(); + assert!(json.get("password").is_none()); + assert_eq!(json.get("type"), Some(&serde_json::json!("mysql"))); + + // 列表包含新連接 + let Json(databases) = list(state.clone()).await.unwrap(); + assert_eq!(databases.len(), 1); + assert_eq!(databases[0].id, created.id); + + // name 重複 → 409;輸入驗證失敗 → 400 + let err = create( + state.clone(), + Json(payload("shop-prod", DatabaseType::Postgres)), + ) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::CONFLICT); + let err = create(state.clone(), Json(payload("", DatabaseType::Postgres))) + .await + .unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST); + + // 查詢:存在 / 不存在 → 200 / 404 + let Json(found) = show(state.clone(), Path(created.id)).await.unwrap(); + assert_eq!(found.id, created.id); + let err = show(state.clone(), Path(9999)).await.unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + + // 更新:欄位被覆寫(含類型變更為 postgres、port 重補默認 5432) + let Json(updated) = update( + state.clone(), + Path(created.id), + Json(DatabaseInput { + name: "shop-prod".into(), + db_type: DatabaseType::Postgres, + host: "10.0.0.6".into(), + port: None, + username: "app2".into(), + password: None, + database_name: "shop2".into(), + }), + ) + .await + .unwrap(); + assert_eq!(updated.db_type, DatabaseType::Postgres); + assert_eq!(updated.port, 5432); + assert_eq!(updated.database_name, "shop2"); + + // 更新時 password 缺省(None)=保留原密碼(回應體不可見,直查存儲層) + let stored = storage::database::get_by_id(&state.db, created.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.password, "secret"); + + // 刪除:→ 204,再刪 → 404 + assert_eq!( + remove(state.clone(), Path(created.id)).await.unwrap(), + StatusCode::NO_CONTENT + ); + let err = remove(state, Path(created.id)).await.unwrap_err(); + assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND); + + pool.close(); + testutil::cleanup(&path); + } +} diff --git a/src/controllers/health.rs b/src/controllers/health.rs new file mode 100644 index 0000000..4f30f12 --- /dev/null +++ b/src/controllers/health.rs @@ -0,0 +1,53 @@ +//! 健康檢查控制器:`GET /api/health`。 +//! +//! 會實際向 SQLite 發出一條查詢,資料庫不可用時返回 503。 + +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Serialize; + +use crate::state::AppState; + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + service: &'static str, + version: &'static str, + database: DatabaseStatus, +} + +#[derive(Serialize)] +struct DatabaseStatus { + status: &'static str, + sqlite_version: String, +} + +async fn health(State(state): State) -> Result, StatusCode> { + let sqlite_version = state + .db + .get() + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)? + .interact(|conn| { + conn.query_row("SELECT sqlite_version()", [], |row| row.get::<_, String>(0)) + }) + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)? + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + + Ok(Json(HealthResponse { + status: "ok", + service: env!("CARGO_PKG_NAME"), + version: env!("CARGO_PKG_VERSION"), + database: DatabaseStatus { + status: "ok", + sqlite_version, + }, + })) +} + +pub fn routes() -> Router { + Router::new().route("/api/health", get(health)) +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs new file mode 100644 index 0000000..be1c22e --- /dev/null +++ b/src/controllers/mod.rs @@ -0,0 +1,370 @@ +//! 控制器層:HTTP API 的路由與處理器。 +//! +//! 每個控制器一個模組,各自透過 `routes()` 暴露子路由, +//! 再由本模組匯總掛載到應用。 + +pub mod accounts; +pub mod auth; +pub mod database; +pub mod health; +pub mod pages; + +use axum::Router; + +use crate::state::AppState; + +/// 匯總所有控制器的路由;未匹配任何路由的請求由 404 頁面接手 +pub fn routes() -> Router { + health::routes() + .merge(pages::routes()) + .merge(auth::routes()) + .merge(accounts::routes()) + .merge(database::routes()) + .fallback(pages::not_found) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::SessionStore; + use crate::storage::testutil; + use axum::body::Body; + use axum::http::{Request, StatusCode, header}; + use tower::ServiceExt; + + /// 以完整路由(oneshot,无需真實 socket)走一遍: + /// 未登入攔截 → 登入失敗 → 登入成功 → 攜 Cookie 訪問首頁 → 登出 → 會話失效 + #[tokio::test] + async fn full_login_flow_through_router() { + let (pool, path) = testutil::test_pool("router-login-flow").await; + let state = AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }; + let app = routes().with_state(state); + + let request = |method: &str, uri: &str, cookie: Option<&str>, body: Body| { + let mut builder = Request::builder().method(method).uri(uri); + if method == "POST" { + builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); + } + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + builder.body(body).unwrap() + }; + let form = |payload: &'static str| Body::from(payload); + + // 未登入訪問首頁 → 303 /login + let response = app + .clone() + .oneshot(request("GET", "/", None, Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login"); + + // 未登入訪問數據庫連接頁 → 同樣 303 /login + let response = app + .clone() + .oneshot(request("GET", "/databases", None, Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login"); + + // 未登入訪問新增 / 詳情 / 編輯 / 表管理頁 / 數據管理頁 → 同樣 303 /login + for uri in [ + "/databases/new", + "/databases/1", + "/databases/1/edit", + "/databases/1/tables/users", + "/databases/1/tables/users/data", + ] { + let response = app + .clone() + .oneshot(request("GET", uri, None, Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER, "{uri}"); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login"); + } + + // 未登入訪問變更密碼頁(GET / POST)→ 同樣 303 /login + let response = app + .clone() + .oneshot(request("GET", "/password", None, Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login"); + let response = app + .clone() + .oneshot( + request("POST", "/password", None, form("current_password=admin&new_password=new-password-123&confirm_password=new-password-123")), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login"); + + // 錯誤密碼 → 401 + let response = app + .clone() + .oneshot(request( + "POST", + "/login", + None, + form("username=admin&password=wrong"), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // 正確登入 → 303 / 且下發會話 Cookie + let response = app + .clone() + .oneshot(request( + "POST", + "/login", + None, + form("username=admin&password=admin"), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // 攜 Cookie 訪問首頁 → 200,渲染登入者與登出按鈕 + let response = app + .clone() + .oneshot(request("GET", "/", Some(&cookie), Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Administrator")); + assert!(html.contains("登出")); + + // 已登入者再訪問 /login → 303 /(不重複登入) + let response = app + .clone() + .oneshot(request("GET", "/login", Some(&cookie), Body::empty())) + .await + .unwrap(); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/"); + + // 登出 → 303 /login 且清除 Cookie + let response = app + .clone() + .oneshot(request("POST", "/logout", Some(&cookie), Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let cleared = response.headers().get(header::SET_COOKIE).unwrap(); + assert!(cleared.to_str().unwrap().starts_with("alterdb_session=;")); + + // 舊 token 已失效:訪問首頁再次被導向 /login + let response = app + .clone() + .oneshot(request("GET", "/", Some(&cookie), Body::empty())) + .await + .unwrap(); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login"); + + pool.close(); + testutil::cleanup(&path); + } + + /// 以完整路由走一遍變更密碼流程: + /// 登入 → 變更密碼(會話全銷毀 + Cookie 清除 + 303 /login?changed=1)→ + /// 舊會話失效、舊密碼不可登入 → 新密碼登入成功 → 登入頁顯示成功提示。 + #[tokio::test] + async fn password_change_flow_through_router() { + let (pool, path) = testutil::test_pool("router-pw-flow").await; + let state = AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }; + let app = routes().with_state(state); + + let request = |method: &str, uri: &str, cookie: Option<&str>, body: Body| { + let mut builder = Request::builder().method(method).uri(uri); + if method == "POST" { + builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); + } + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + builder.body(body).unwrap() + }; + let form = |payload: &'static str| Body::from(payload); + + // 登入取得會話 Cookie + let response = app + .clone() + .oneshot(request( + "POST", + "/login", + None, + form("username=admin&password=admin"), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // 變更密碼頁可訪問(200,含表單) + let response = app + .clone() + .oneshot(request("GET", "/password", Some(&cookie), Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + String::from_utf8(body.to_vec()) + .unwrap() + .contains("變更密碼") + ); + + // 提交變更密碼 → 303 /login?changed=1 且清除 Cookie + let response = app + .clone() + .oneshot(request( + "POST", + "/password", + Some(&cookie), + form("current_password=admin&new_password=new-password-123&confirm_password=new-password-123"), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "/login?changed=1" + ); + assert!( + response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .starts_with("alterdb_session=;") + ); + + // 舊 Cookie 已失效:首頁再次被導向 /login + let response = app + .clone() + .oneshot(request("GET", "/", Some(&cookie), Body::empty())) + .await + .unwrap(); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login"); + + // 舊密碼登入 → 401;新密碼登入 → 303 / + let response = app + .clone() + .oneshot(request( + "POST", + "/login", + None, + form("username=admin&password=admin"), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let response = app + .clone() + .oneshot(request( + "POST", + "/login", + None, + form("username=admin&password=new-password-123"), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + + // 登入頁帶 ?changed=1 時顯示成功提示 + let response = app + .clone() + .oneshot(request("GET", "/login?changed=1", None, Body::empty())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + String::from_utf8(body.to_vec()) + .unwrap() + .contains("密碼已更新") + ); + + pool.close(); + testutil::cleanup(&path); + } + + /// 走完整路由:未知路徑(含未知 /api 端點)一律返回 404 並渲染 404 頁面。 + /// 接線方式與 main.rs 一致,確保 fallback 在 merge + layer 之後仍生效。 + #[tokio::test] + async fn unknown_routes_render_404_page() { + use tower_http::trace::TraceLayer; + + let (pool, path) = testutil::test_pool("router-404").await; + let state = AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }; + let app = axum::Router::new() + .merge(routes()) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + for uri in ["/no-such-page", "/api/no-such-endpoint"] { + let response = app + .clone() + .oneshot(Request::get(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); + assert!( + response.headers().get(header::CONTENT_TYPE).is_some(), + "{uri}" + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("找不到頁面"), "{uri}"); + assert!(html.contains(&format!("{uri}")), "{uri}"); + } + + pool.close(); + testutil::cleanup(&path); + } +} diff --git a/src/controllers/pages.rs b/src/controllers/pages.rs new file mode 100644 index 0000000..36f413a --- /dev/null +++ b/src/controllers/pages.rs @@ -0,0 +1,710 @@ +//! 頁面控制器:以 Askama 渲染 Web 管理介面的 HTML 頁面。 +//! +//! 頁面一律以 [`CurrentUser`] 作提取器,未登入的請求會被 +//! 重定向到 `/login`,處理器內無需再逐一檢查會話。 +//! 頁面僅渲染外殼;`/databases` 等管理頁的數據由內嵌 JS +//! 調用對應的 `/api/*` 端點取得。 + +use askama::Template; +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::{StatusCode, Uri}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; + +use crate::models::database::Database; +use crate::session::CurrentUser; +use crate::state::AppState; +use crate::storage; + +#[derive(Template)] +#[template(path = "index.html")] +struct IndexTemplate { + service: &'static str, + version: &'static str, + user_display_name: String, + user_username: String, + /// 側邊導航欄的當前頁標記(aria-current),由各頁處理器固定提供 + nav_active: &'static str, +} + +async fn index(user: CurrentUser) -> Result, StatusCode> { + let tpl = IndexTemplate { + service: env!("CARGO_PKG_NAME"), + version: env!("CARGO_PKG_VERSION"), + user_display_name: user.display_name, + user_username: user.username, + nav_active: "index", + }; + render(tpl) +} + +#[derive(Template)] +#[template(path = "databases.html")] +struct DatabasesTemplate { + user_display_name: String, + user_username: String, + nav_active: &'static str, +} + +async fn databases_page(user: CurrentUser) -> Result, StatusCode> { + render(DatabasesTemplate { + user_display_name: user.display_name, + user_username: user.username, + nav_active: "databases", + }) +} + +/// 新增 / 編輯連接的共用表單頁;`editing` 為 `None` 時是新增模式。 +/// 密碼不會回填到頁面:API 與模板皆不輸出密碼,客戶端只能選擇覆寫或保留。 +#[derive(Template)] +#[template(path = "database_form.html")] +struct DatabaseFormTemplate { + user_display_name: String, + user_username: String, + editing: Option, + nav_active: &'static str, +} + +async fn database_new_page(user: CurrentUser) -> Result, StatusCode> { + render(DatabaseFormTemplate { + user_display_name: user.display_name, + user_username: user.username, + editing: None, + nav_active: "databases", + }) +} + +/// 編輯頁:服務端依主鍵預填表單;連接不存在時渲染 404 頁面 +async fn database_edit_page( + State(state): State, + user: CurrentUser, + Path(id): Path, + uri: Uri, +) -> Result { + let database = storage::database::get_by_id(&state.db, id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + match database { + Some(database) => Ok(render(DatabaseFormTemplate { + user_display_name: user.display_name, + user_username: user.username, + editing: Some(database), + nav_active: "databases", + })? + .into_response()), + None => render_not_found(uri.path()), + } +} + +/// 連接詳情頁:服務端渲染連接靜態資訊,連接狀態與數據表 +/// 由內嵌 JS 調用 `/api/databases/{id}/test`、`/tables` 即時取得。 +#[derive(Template)] +#[template(path = "database_detail.html")] +struct DatabaseDetailTemplate { + user_display_name: String, + user_username: String, + database: Database, + /// RFC 3339 原值轉為易讀格式(與列表頁 JS 的格式一致),見 [`format_timestamp`] + created_at_display: String, + updated_at_display: String, + nav_active: &'static str, +} + +/// RFC 3339(`2026-01-01T08:00:00.123Z`)→ `2026-01-01 08:00:00 UTC` +fn format_timestamp(iso: &str) -> String { + match iso.split_once('.') { + Some((head, _)) => format!("{} UTC", head.replacen('T', " ", 1)), + None => iso.replacen('T', " ", 1), + } +} + +async fn database_detail_page( + State(state): State, + user: CurrentUser, + Path(id): Path, + uri: Uri, +) -> Result { + let database = storage::database::get_by_id(&state.db, id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + match database { + Some(database) => Ok(render(DatabaseDetailTemplate { + user_display_name: user.display_name, + user_username: user.username, + created_at_display: format_timestamp(&database.created_at), + updated_at_display: format_timestamp(&database.updated_at), + database, + nav_active: "databases", + })? + .into_response()), + None => render_not_found(uri.path()), + } +} + +/// 數據表管理頁:服務端渲染連接與表名等靜態資訊,欄位結構 +/// 由內嵌 JS 調用 `/api/databases/{id}/tables/{table}/columns` 即時取得, +/// 頁面本身不請求表內資料(資料瀏覽在獨立的數據管理頁)。 +/// 表是否存在須實際連線目標庫方能得知,頁面本身不檢查(由 API 回報)。 +#[derive(Template)] +#[template(path = "table_detail.html")] +struct TableDetailTemplate { + user_display_name: String, + user_username: String, + database: Database, + table: String, + nav_active: &'static str, +} + +async fn table_detail_page( + State(state): State, + user: CurrentUser, + Path((id, table)): Path<(i64, String)>, + uri: Uri, +) -> Result { + let database = storage::database::get_by_id(&state.db, id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + match database { + Some(database) => Ok(render(TableDetailTemplate { + user_display_name: user.display_name, + user_username: user.username, + database, + table, + nav_active: "databases", + })? + .into_response()), + None => render_not_found(uri.path()), + } +} + +/// 數據管理頁:表內資料的分頁瀏覽,由內嵌 JS 調用 +/// `/api/databases/{id}/tables/{table}/rows` 即時取得;欄位結構 +/// 管理留在表管理頁。與表管理頁同樣不檢查表是否存在(由 API 回報)。 +#[derive(Template)] +#[template(path = "table_data.html")] +struct TableDataTemplate { + user_display_name: String, + user_username: String, + database: Database, + table: String, + nav_active: &'static str, +} + +async fn table_data_page( + State(state): State, + user: CurrentUser, + Path((id, table)): Path<(i64, String)>, + uri: Uri, +) -> Result { + let database = storage::database::get_by_id(&state.db, id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + match database { + Some(database) => Ok(render(TableDataTemplate { + user_display_name: user.display_name, + user_username: user.username, + database, + table, + nav_active: "databases", + })? + .into_response()), + None => render_not_found(uri.path()), + } +} + +#[derive(Template)] +#[template(path = "404.html")] +struct NotFoundTemplate { + path: String, +} + +/// 渲染 404 頁面;不需要登入,對未登入的訪客同樣可見 +fn render_not_found(path: &str) -> Result { + let html = render(NotFoundTemplate { + path: path.to_string(), + })?; + Ok((StatusCode::NOT_FOUND, html).into_response()) +} + +/// 路由未匹配時的全站 fallback +pub async fn not_found(uri: Uri) -> Result { + render_not_found(uri.path()) +} + +/// 渲染模板為 HTML 回應;渲染失敗統一上報 500 +fn render(tpl: impl Template) -> Result, StatusCode> { + tpl.render() + .map(Html) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +pub fn routes() -> Router { + Router::new() + .route("/", get(index)) + .route("/databases", get(databases_page)) + .route("/databases/new", get(database_new_page)) + .route("/databases/{id}", get(database_detail_page)) + .route("/databases/{id}/edit", get(database_edit_page)) + .route("/databases/{id}/tables/{table}", get(table_detail_page)) + .route("/databases/{id}/tables/{table}/data", get(table_data_page)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::database::{DatabaseInput, DatabaseType}; + use crate::session::SessionStore; + use crate::storage::testutil; + + fn user() -> CurrentUser { + CurrentUser { + user_id: 1, + username: "admin".into(), + display_name: "Administrator".into(), + } + } + + #[tokio::test] + async fn index_renders_logged_in_user() { + let Html(html) = index(user()).await.unwrap(); + assert!(html.contains("Administrator")); + assert!(html.contains("登出")); + // 側邊導航欄含數據庫連接頁連結,首頁為當前頁(aria-current) + assert!(html.contains("href=\"/databases\"")); + assert!(html.contains("首頁")); + assert!(html.contains("數據庫")); + } + + #[tokio::test] + async fn databases_page_renders_shell() { + let Html(html) = databases_page(user()).await.unwrap(); + // 外殼要素:標題、表格掛載點、新增連接入口與腳本 + assert!(html.contains("數據庫連接")); + assert!(html.contains("id=\"db-rows\"")); + assert!(html.contains("href=\"/databases/new\"")); + assert!(html.contains("/api/databases")); + // 內嵌表單已拆到獨立頁面 + assert!(!html.contains("id=\"db-form\"")); + // 頁面同樣受會話保護(側邊欄已渲染登入者與登出) + assert!(html.contains("Administrator")); + assert!(html.contains("登出")); + // 數據庫頁在側邊欄標記為當前頁,首頁不標記 + assert!(html.contains("數據庫")); + assert!(html.contains("首頁")); + } + + #[tokio::test] + async fn database_new_page_renders_empty_form() { + let Html(html) = database_new_page(user()).await.unwrap(); + assert!(html.contains("新增連接")); + assert!(html.contains("data-mode=\"create\"")); + assert!(html.contains("id=\"db-form\"")); + // 新增模式:密碼提示為「可留空」,且無預填值 + assert!(html.contains("可留空")); + assert!(!html.contains("value=\"shop-prod\"")); + // 表單頁隸屬數據庫區,側邊欄高亮數據庫項 + assert!(html.contains("數據庫")); + } + + #[tokio::test] + async fn database_edit_page_prefills_form() { + let (pool, path) = testutil::test_pool("page-edit-prefill").await; + let state = AppState { + db: pool.clone(), + sessions: SessionStore::new(), + }; + let created = storage::database::create( + &pool, + DatabaseInput { + name: "shop-prod".into(), + db_type: DatabaseType::Postgres, + host: "10.0.0.5".into(), + port: Some(5433), + username: "app".into(), + password: Some("secret".into()), + database_name: "shop".into(), + } + .normalized() + .unwrap(), + ) + .await + .unwrap(); + + let uri = format!("/databases/{}/edit", created.id) + .parse::() + .unwrap(); + let response = database_edit_page(State(state), user(), Path(created.id), uri) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + // 預填現值(type 為 postgres)、編輯模式標記與密碼提示 + assert!(html.contains("value=\"shop-prod\"")); + assert!(html.contains("value=\"10.0.0.5\"")); + assert!(html.contains("value=\"5433\"")); + assert!(html.contains("data-mode=\"edit\"")); + assert!(html.contains(&format!("data-id=\"{}\"", created.id))); + assert!(html.contains("