fixup
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# 建置產物與本機環境
|
||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.zcode/
|
||||||
|
**/.DS_Store
|
||||||
|
|
||||||
|
# 本機 SQLite 數據檔(容器應使用乾淨資料庫或掛載 volume)
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# 其他非建置所需
|
||||||
|
README.md
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
|
||||||
|
# SQLite 數據檔
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
@@ -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<i64>)` 查存儲層後渲染。共用一個 `render` 輔助(已存在)。
|
||||||
|
|
||||||
|
## 2. 模板
|
||||||
|
|
||||||
|
**`templates/database_form.html`(新,新增/編輯共用)**:
|
||||||
|
- 內容:session bar + 標題(`新增連接` / `編輯連接:{name}`)+ `role="alert"` 錯誤條 + 表單(name、type 下拉、host、port、username、password、database_name)+ 提示文字
|
||||||
|
- 上下文:`editing: Option<Database>`。Askama 條件渲染:各欄位 `value="{% if let Some(db) = editing %}{{ db.name }}{% endif %}"`(自動轉義);type 下拉按 `db.db_type.as_str()` 標記 selected;密碼一律不輸出值,placeholder 按模式顯示「可留空」/「留空保留原密碼」
|
||||||
|
- `<form data-mode="{% if editing %}edit{% else %}create{% endif %}" {% if ... %}data-id="{{ db.id }}"{% endif %}>`
|
||||||
|
- 內嵌 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` 全綠;伺服器 + 瀏覽器走完整流程(臨時資料庫,測完清理)。
|
||||||
@@ -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 中「數據檔位置」說明呼應)。
|
||||||
Generated
+2365
File diff suppressed because it is too large
Load Diff
+26
@@ -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"] }
|
||||||
+59
@@ -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"]
|
||||||
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
+1300
File diff suppressed because it is too large
Load Diff
@@ -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<StorageError> 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<AppState>) -> Result<Json<Vec<Account>>, ApiError> {
|
||||||
|
let accounts = storage::account::list(&state.db).await?;
|
||||||
|
Ok(Json(accounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建立帳戶;輸入會先經 [`AccountInput::normalized`] 驗證與正規化
|
||||||
|
async fn create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<AccountInput>,
|
||||||
|
) -> Result<(StatusCode, Json<Account>), 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Account>, 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(payload): Json<AccountInput>,
|
||||||
|
) -> Result<Json<Account>, 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
match storage::account::delete(&state.db, id).await? {
|
||||||
|
true => Ok(StatusCode::NO_CONTENT),
|
||||||
|
false => Err(ApiError::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "login.html")]
|
||||||
|
struct LoginTemplate {
|
||||||
|
error: Option<String>,
|
||||||
|
notice: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 渲染登入頁;`status` 用於失敗重渲染時上報 401
|
||||||
|
fn render_login(error: Option<String>, notice: Option<String>, 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<AppState>,
|
||||||
|
Query(query): Query<LoginQuery>,
|
||||||
|
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<AppState>, Form(input): Form<LoginForm>) -> 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<AppState>, 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 渲染變更密碼頁;`status` 用於失敗重渲染時上報 4xx
|
||||||
|
fn render_password(user: &CurrentUser, error: Option<String>, 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<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Form(input): Form<PasswordForm>,
|
||||||
|
) -> 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<String> = 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<AppState> {
|
||||||
|
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<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<StorageError> 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<AppState>) -> Result<Json<Vec<Database>>, ApiError> {
|
||||||
|
let databases = storage::database::list(&state.db).await?;
|
||||||
|
Ok(Json(databases))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建立數據庫連接;輸入會先經 [`DatabaseInput::normalized`] 驗證與正規化
|
||||||
|
async fn create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<DatabaseInput>,
|
||||||
|
) -> Result<(StatusCode, Json<Database>), 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Database>, 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(payload): Json<DatabaseInput>,
|
||||||
|
) -> Result<Json<Database>, 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<connect::TestOutcome>, 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Vec<connect::TableSummary>>, 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<i64>,
|
||||||
|
offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AppState>,
|
||||||
|
Path((id, table)): Path<(i64, String)>,
|
||||||
|
) -> Result<Json<Vec<connect::ColumnSummary>>, 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<AppState>,
|
||||||
|
Path((id, table)): Path<(i64, String)>,
|
||||||
|
Query(query): Query<RowsQuery>,
|
||||||
|
) -> Result<Json<connect::TableRows>, 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<AppState>,
|
||||||
|
Path((id, table)): Path<(i64, String)>,
|
||||||
|
Json(payload): Json<connect::ColumnInput>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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<AppState>,
|
||||||
|
Path((id, table, column)): Path<(i64, String, String)>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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<AppState>,
|
||||||
|
Path((id, table)): Path<(i64, String)>,
|
||||||
|
Json(payload): Json<connect::RowUpdateInput>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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<AppState> {
|
||||||
|
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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppState>) -> Result<Json<HealthResponse>, 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<AppState> {
|
||||||
|
Router::new().route("/api/health", get(health))
|
||||||
|
}
|
||||||
@@ -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<AppState> {
|
||||||
|
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!("<code>{uri}</code>")), "{uri}");
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Html<String>, 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<Html<String>, 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<Database>,
|
||||||
|
nav_active: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn database_new_page(user: CurrentUser) -> Result<Html<String>, 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<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
uri: Uri,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
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<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
uri: Uri,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
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<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path((id, table)): Path<(i64, String)>,
|
||||||
|
uri: Uri,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
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<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path((id, table)): Path<(i64, String)>,
|
||||||
|
uri: Uri,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
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<Response, StatusCode> {
|
||||||
|
let html = render(NotFoundTemplate {
|
||||||
|
path: path.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok((StatusCode::NOT_FOUND, html).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 路由未匹配時的全站 fallback
|
||||||
|
pub async fn not_found(uri: Uri) -> Result<Response, StatusCode> {
|
||||||
|
render_not_found(uri.path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 渲染模板為 HTML 回應;渲染失敗統一上報 500
|
||||||
|
fn render(tpl: impl Template) -> Result<Html<String>, StatusCode> {
|
||||||
|
tpl.render()
|
||||||
|
.map(Html)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Router<crate::state::AppState> {
|
||||||
|
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("<a href=\"/\" aria-current=\"page\">首頁</a>"));
|
||||||
|
assert!(html.contains("<a href=\"/databases\">數據庫</a>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||||
|
assert!(html.contains("<a href=\"/\">首頁</a>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<Uri>()
|
||||||
|
.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("<option value=\"postgres\" selected>"));
|
||||||
|
assert!(html.contains("留空保留原密碼"));
|
||||||
|
// 密碼永不回填到頁面
|
||||||
|
assert!(!html.contains("secret"));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn database_edit_page_returns_404_for_missing_id() {
|
||||||
|
let (pool, path) = testutil::test_pool("page-edit-404").await;
|
||||||
|
let state = AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
sessions: SessionStore::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let uri = "/databases/9999/edit".parse::<Uri>().unwrap();
|
||||||
|
let response = database_edit_page(State(state), user(), Path(9999), uri)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
// 404 一律渲染 404 頁面(含返回首頁連結),而非純文字
|
||||||
|
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("找不到頁面"));
|
||||||
|
assert!(html.contains("href=\"/\""));
|
||||||
|
assert!(html.contains("/databases/9999/edit"));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn database_detail_page_renders_info_and_live_sections() {
|
||||||
|
let (pool, path) = testutil::test_pool("page-detail").await;
|
||||||
|
let state = AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
sessions: SessionStore::new(),
|
||||||
|
};
|
||||||
|
let created = storage::database::create(
|
||||||
|
&pool,
|
||||||
|
DatabaseInput {
|
||||||
|
name: "shop-prod".into(),
|
||||||
|
db_type: DatabaseType::MySql,
|
||||||
|
host: "10.0.0.5".into(),
|
||||||
|
port: Some(3307),
|
||||||
|
username: "app".into(),
|
||||||
|
password: Some("secret".into()),
|
||||||
|
database_name: "shop".into(),
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let uri = format!("/databases/{}", created.id).parse::<Uri>().unwrap();
|
||||||
|
let response = database_detail_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();
|
||||||
|
// 服務端預填的靜態資訊
|
||||||
|
assert!(html.contains("shop-prod"));
|
||||||
|
assert!(html.contains("10.0.0.5:3307"));
|
||||||
|
assert!(html.contains("mysql"));
|
||||||
|
assert!(html.contains(&format!("data-database-id=\"{}\"", created.id)));
|
||||||
|
// JS 掛載點:連接狀態與數據表
|
||||||
|
assert!(html.contains("id=\"status\""));
|
||||||
|
assert!(html.contains("id=\"table-rows\""));
|
||||||
|
assert!(html.contains("/api/databases/' + databaseId + '/test"));
|
||||||
|
assert!(html.contains("/api/databases/' + databaseId + '/tables"));
|
||||||
|
assert!(html.contains("重新測試"));
|
||||||
|
// 時間戳已轉為易讀格式(T 分隔與毫秒尾已去除)
|
||||||
|
assert!(!html.contains(&created.created_at));
|
||||||
|
assert!(html.contains(" UTC"));
|
||||||
|
// 密碼永不輸出到頁面
|
||||||
|
assert!(!html.contains("secret"));
|
||||||
|
// 側邊欄高亮數據庫項
|
||||||
|
assert!(html.contains("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn database_detail_page_returns_404_for_missing_id() {
|
||||||
|
let (pool, path) = testutil::test_pool("page-detail-404").await;
|
||||||
|
let state = AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
sessions: SessionStore::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let uri = "/databases/9999".parse::<Uri>().unwrap();
|
||||||
|
let response = database_detail_page(State(state), user(), Path(9999), uri)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
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("找不到頁面"));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn table_detail_page_renders_shell_and_mount_points() {
|
||||||
|
let (pool, path) = testutil::test_pool("page-table-detail").await;
|
||||||
|
let state = AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
sessions: SessionStore::new(),
|
||||||
|
};
|
||||||
|
let created = storage::database::create(
|
||||||
|
&pool,
|
||||||
|
DatabaseInput {
|
||||||
|
name: "shop-prod".into(),
|
||||||
|
db_type: DatabaseType::MySql,
|
||||||
|
host: "10.0.0.5".into(),
|
||||||
|
port: Some(3307),
|
||||||
|
username: "app".into(),
|
||||||
|
password: Some("secret".into()),
|
||||||
|
database_name: "shop".into(),
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let uri = format!("/databases/{}/tables/users", created.id)
|
||||||
|
.parse::<Uri>()
|
||||||
|
.unwrap();
|
||||||
|
let response = table_detail_page(
|
||||||
|
State(state),
|
||||||
|
user(),
|
||||||
|
Path((created.id, "users".into())),
|
||||||
|
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();
|
||||||
|
// 服務端預填的靜態資訊:表名(屬性轉義後仍可原樣讀回)與所屬連接
|
||||||
|
assert!(html.contains("data-table=\"users\""));
|
||||||
|
assert!(html.contains(&format!("data-database-id=\"{}\"", created.id)));
|
||||||
|
assert!(html.contains("shop-prod"));
|
||||||
|
assert!(html.contains("10.0.0.5:3307"));
|
||||||
|
// JS 掛載點:欄位結構(本頁不再請求表內資料)
|
||||||
|
assert!(html.contains("欄位結構"));
|
||||||
|
assert!(html.contains("id=\"column-rows\""));
|
||||||
|
assert!(!html.contains("id=\"row-body\""));
|
||||||
|
assert!(!html.contains("/rows?limit="));
|
||||||
|
assert!(html.contains(
|
||||||
|
"/api/databases/' + databaseId + '/tables/' + encodeURIComponent(tableName)"
|
||||||
|
));
|
||||||
|
// 工具列提供通往數據管理頁的連結(表名路徑段已編碼)
|
||||||
|
assert!(html.contains(&format!(
|
||||||
|
"href=\"/databases/{}/tables/users/data\"",
|
||||||
|
created.id
|
||||||
|
)));
|
||||||
|
// 新增 / 刪除欄位的內嵌表單與 DDL 調用
|
||||||
|
assert!(html.contains("新增欄位"));
|
||||||
|
assert!(html.contains("id=\"column-form\""));
|
||||||
|
assert!(html.contains("id=\"column-name\""));
|
||||||
|
assert!(html.contains("id=\"column-type\""));
|
||||||
|
assert!(html.contains("id=\"column-nullable\""));
|
||||||
|
assert!(html.contains("id=\"column-default\""));
|
||||||
|
assert!(html.contains("tableApi + '/columns', {"));
|
||||||
|
assert!(html.contains("tableApi + '/columns/' + encodeURIComponent(name)"));
|
||||||
|
assert!(html.contains("確定刪除欄位"));
|
||||||
|
// 密碼永不輸出到頁面;側邊欄高亮數據庫項
|
||||||
|
assert!(!html.contains("secret"));
|
||||||
|
assert!(html.contains("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn table_detail_page_returns_404_for_missing_database() {
|
||||||
|
let (pool, path) = testutil::test_pool("page-table-detail-404").await;
|
||||||
|
let state = AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
sessions: SessionStore::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let uri = "/databases/9999/tables/users".parse::<Uri>().unwrap();
|
||||||
|
let response = table_detail_page(State(state), user(), Path((9999, "users".into())), uri)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
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("找不到頁面"));
|
||||||
|
assert!(html.contains("/databases/9999/tables/users"));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn table_data_page_renders_shell_and_mount_points() {
|
||||||
|
let (pool, path) = testutil::test_pool("page-table-data").await;
|
||||||
|
let state = AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
sessions: SessionStore::new(),
|
||||||
|
};
|
||||||
|
let created = storage::database::create(
|
||||||
|
&pool,
|
||||||
|
DatabaseInput {
|
||||||
|
name: "shop-prod".into(),
|
||||||
|
db_type: DatabaseType::MySql,
|
||||||
|
host: "10.0.0.5".into(),
|
||||||
|
port: Some(3307),
|
||||||
|
username: "app".into(),
|
||||||
|
password: Some("secret".into()),
|
||||||
|
database_name: "shop".into(),
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let uri = format!("/databases/{}/tables/users/data", created.id)
|
||||||
|
.parse::<Uri>()
|
||||||
|
.unwrap();
|
||||||
|
let response = table_data_page(
|
||||||
|
State(state),
|
||||||
|
user(),
|
||||||
|
Path((created.id, "users".into())),
|
||||||
|
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();
|
||||||
|
// 服務端預填的靜態資訊:表名與所屬連接
|
||||||
|
assert!(html.contains("data-table=\"users\""));
|
||||||
|
assert!(html.contains(&format!("data-database-id=\"{}\"", created.id)));
|
||||||
|
assert!(html.contains("shop-prod"));
|
||||||
|
assert!(html.contains("10.0.0.5:3307"));
|
||||||
|
// JS 掛載點:資料表頭 / 表身與分頁控件;資料端點與分頁參數拼接
|
||||||
|
assert!(html.contains("id=\"row-head\""));
|
||||||
|
assert!(html.contains("id=\"row-body\""));
|
||||||
|
assert!(html.contains("id=\"page-size\""));
|
||||||
|
assert!(html.contains("id=\"prev\""));
|
||||||
|
assert!(html.contains("id=\"next\""));
|
||||||
|
assert!(html.contains(
|
||||||
|
"/api/databases/' + databaseId + '/tables/' + encodeURIComponent(tableName)"
|
||||||
|
));
|
||||||
|
assert!(html.contains("tableApi + '/rows?limit=' + limit + '&offset=' + offset"));
|
||||||
|
// 編輯資料列:對話框表單、欄位 metadata 載入與 PATCH 調用;
|
||||||
|
// 無主鍵時的僅瀏覽提示也在頁面上
|
||||||
|
assert!(html.contains("id=\"row-dialog\""));
|
||||||
|
assert!(html.contains("id=\"row-form\""));
|
||||||
|
assert!(html.contains("id=\"row-fields\""));
|
||||||
|
assert!(html.contains("id=\"no-pk-note\""));
|
||||||
|
assert!(html.contains("column.primary_key"));
|
||||||
|
assert!(html.contains("method: 'PATCH'"));
|
||||||
|
assert!(html.contains("editButton.textContent = '編輯';"));
|
||||||
|
// 返回表管理頁(結構管理)與連接詳情的連結;欄位管理表單留在表管理頁
|
||||||
|
assert!(html.contains(&format!(
|
||||||
|
"href=\"/databases/{}/tables/users\"",
|
||||||
|
created.id
|
||||||
|
)));
|
||||||
|
assert!(!html.contains("id=\"column-form\""));
|
||||||
|
// 密碼永不輸出到頁面;側邊欄高亮數據庫項
|
||||||
|
assert!(!html.contains("secret"));
|
||||||
|
assert!(html.contains("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn table_data_page_returns_404_for_missing_database() {
|
||||||
|
let (pool, path) = testutil::test_pool("page-table-data-404").await;
|
||||||
|
let state = AppState {
|
||||||
|
db: pool.clone(),
|
||||||
|
sessions: SessionStore::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let uri = "/databases/9999/tables/users/data".parse::<Uri>().unwrap();
|
||||||
|
let response = table_data_page(State(state), user(), Path((9999, "users".into())), uri)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
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("找不到頁面"));
|
||||||
|
assert!(html.contains("/databases/9999/tables/users/data"));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_timestamp_strips_separator_and_millis() {
|
||||||
|
assert_eq!(
|
||||||
|
format_timestamp("2026-01-02T03:04:05.678Z"),
|
||||||
|
"2026-01-02 03:04:05 UTC"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_timestamp("2026-01-02T03:04:05Z"),
|
||||||
|
"2026-01-02 03:04:05Z"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pages_ship_theme_toggle() {
|
||||||
|
// 深淺色切換:head 內聯腳本(防閃爍)與 data-theme-toggle 按鈕由 base 模板全站供應
|
||||||
|
let Html(html) = index(user()).await.unwrap();
|
||||||
|
assert!(html.contains("data-theme-toggle"));
|
||||||
|
assert!(html.contains("localStorage.getItem('theme')"));
|
||||||
|
|
||||||
|
// 獨立版式頁(404,無側欄)同樣帶有切換按鈕
|
||||||
|
let uri = "/no/such/page".parse::<Uri>().unwrap();
|
||||||
|
let response = not_found(uri).await.unwrap();
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let html_404 = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(html_404.contains("data-theme-toggle"));
|
||||||
|
assert!(html_404.contains("localStorage.getItem('theme')"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn not_found_renders_page_with_requested_path() {
|
||||||
|
let uri = "/no/such/page".parse::<Uri>().unwrap();
|
||||||
|
let response = not_found(uri).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
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("找不到頁面"));
|
||||||
|
assert!(html.contains("<code>/no/such/page</code>"));
|
||||||
|
assert!(html.contains("href=\"/\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
//! alterdb 程序入口:初始化存儲層與 HTTP API 伺服器。
|
||||||
|
|
||||||
|
mod connect;
|
||||||
|
mod controllers;
|
||||||
|
mod models;
|
||||||
|
mod session;
|
||||||
|
mod state;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// 日誌級別可透過 RUST_LOG 環境變數覆蓋,默認 info
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// 資料庫檔案路徑可透過 ALTERDB_DB_PATH 覆蓋,默認 alterdb.db
|
||||||
|
let db_path =
|
||||||
|
std::env::var("ALTERDB_DB_PATH").unwrap_or_else(|_| storage::DEFAULT_DB_PATH.to_string());
|
||||||
|
let pool = storage::create_pool(&db_path).expect("無法建立資料庫連接池");
|
||||||
|
storage::init_db(&pool).await.expect("資料庫初始化失敗");
|
||||||
|
tracing::info!("SQLite 資料庫已就緒:{db_path}");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: pool,
|
||||||
|
sessions: session::SessionStore::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.merge(controllers::routes())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
// 綁定位址可透過 ALTERDB_BIND 覆蓋(容器內需設 0.0.0.0:8080),默認僅本機可達
|
||||||
|
let bind_addr =
|
||||||
|
std::env::var("ALTERDB_BIND").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
|
||||||
|
let listener = tokio::net::TcpListener::bind(&bind_addr)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("無法綁定 {bind_addr}:{e}"));
|
||||||
|
tracing::info!(
|
||||||
|
"alterdb 伺服器已啟動:http://{}",
|
||||||
|
listener.local_addr().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
axum::serve(listener, app).await.expect("伺服器運行錯誤");
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
//! Account 模型:管理介面用戶帳戶的數據結構與輸入驗證。
|
||||||
|
//!
|
||||||
|
//! 本模組只定義「數據長什麼樣」:實體結構([`Account`])、
|
||||||
|
//! 建立與更新的請求載荷([`AccountInput`])及其正規化規則;
|
||||||
|
//! SQL 與持久化細節見 [`crate::storage::account`]。
|
||||||
|
|
||||||
|
use argon2::Argon2;
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 帳戶實體,對應存儲層 `accounts` 表的一行。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Account {
|
||||||
|
/// 主鍵(SQLite rowid)
|
||||||
|
pub id: i64,
|
||||||
|
/// 登入名,全表唯一
|
||||||
|
pub username: String,
|
||||||
|
/// 電子郵件,全表唯一
|
||||||
|
pub email: String,
|
||||||
|
/// 密碼的 Argon2 雜湊(PHC 格式,含隨機鹽);永不透過 API 序列化輸出
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub password_hash: String,
|
||||||
|
/// 顯示名稱;未提供時默認同 `username`
|
||||||
|
pub display_name: String,
|
||||||
|
/// 是否啟用
|
||||||
|
pub is_active: bool,
|
||||||
|
/// 建立時間(RFC 3339 UTC,如 `2026-01-01T08:00:00.123Z`)
|
||||||
|
pub created_at: String,
|
||||||
|
/// 最後更新時間(RFC 3339 UTC)
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建立與更新帳戶的請求載荷(POST / PUT 共用)。
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AccountInput {
|
||||||
|
/// 登入名(必填)
|
||||||
|
pub username: String,
|
||||||
|
/// 電子郵件(必填)
|
||||||
|
pub email: String,
|
||||||
|
/// 顯示名稱(可選,默認同 `username`)
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
/// 是否啟用(可選,默認 `true`)
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `username` 的長度上限(字符數)
|
||||||
|
const USERNAME_MAX_CHARS: usize = 64;
|
||||||
|
/// `email` 的長度上限(字符數)
|
||||||
|
const EMAIL_MAX_CHARS: usize = 255;
|
||||||
|
/// `display_name` 的長度上限(字符數)
|
||||||
|
const DISPLAY_NAME_MAX_CHARS: usize = 128;
|
||||||
|
|
||||||
|
/// 以 Argon2(含隨機鹽)產生密碼雜湊,輸出為 PHC 格式字串(如 `$argon2id$...`)。
|
||||||
|
///
|
||||||
|
/// Argon2 為記憶體困難的密碼雜湊演算法,同一密碼每次雜湊的結果都不同
|
||||||
|
/// (鹽隨機),無法從雜湊反推明文。
|
||||||
|
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 驗證密碼是否與儲存的 PHC 格式雜湊相符;雜湊格式無效時視為不相符。
|
||||||
|
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
|
||||||
|
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountInput {
|
||||||
|
/// 驗證並正規化輸入:去除首尾空白、補齊默認值。
|
||||||
|
///
|
||||||
|
/// 返回值的 `display_name` 與 `is_active` 必為 `Some`,
|
||||||
|
/// 可直接交給存儲層;`Err` 的訊息可直接作為 API 400
|
||||||
|
/// 響應的錯誤內容。
|
||||||
|
pub fn normalized(self) -> Result<Self, String> {
|
||||||
|
let username = self.username.trim().to_string();
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err("username 不可為空".into());
|
||||||
|
}
|
||||||
|
if username.chars().count() > USERNAME_MAX_CHARS {
|
||||||
|
return Err(format!("username 長度不可超過 {USERNAME_MAX_CHARS} 個字符"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = self.email.trim().to_string();
|
||||||
|
if email.is_empty() {
|
||||||
|
return Err("email 不可為空".into());
|
||||||
|
}
|
||||||
|
if !email.contains('@') {
|
||||||
|
return Err("email 格式不正確".into());
|
||||||
|
}
|
||||||
|
if email.chars().count() > EMAIL_MAX_CHARS {
|
||||||
|
return Err(format!("email 長度不可超過 {EMAIL_MAX_CHARS} 個字符"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let provided_name = self
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|name| !name.is_empty());
|
||||||
|
let display_name = match provided_name {
|
||||||
|
Some(name) => name.to_string(),
|
||||||
|
None => username.clone(),
|
||||||
|
};
|
||||||
|
if display_name.chars().count() > DISPLAY_NAME_MAX_CHARS {
|
||||||
|
return Err(format!(
|
||||||
|
"display_name 長度不可超過 {DISPLAY_NAME_MAX_CHARS} 個字符"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name: Some(display_name),
|
||||||
|
is_active: Some(self.is_active.unwrap_or(true)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn input(username: &str, email: &str) -> AccountInput {
|
||||||
|
AccountInput {
|
||||||
|
username: username.into(),
|
||||||
|
email: email.into(),
|
||||||
|
display_name: None,
|
||||||
|
is_active: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_trims_and_fills_defaults() {
|
||||||
|
let normalized = input(" alice ", " alice@example.com ")
|
||||||
|
.normalized()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(normalized.username, "alice");
|
||||||
|
assert_eq!(normalized.email, "alice@example.com");
|
||||||
|
// display_name 默認同 username、is_active 默認啟用
|
||||||
|
assert_eq!(normalized.display_name.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(normalized.is_active, Some(true));
|
||||||
|
|
||||||
|
// 明確提供 display_name / is_active 時:trim 後保留原值
|
||||||
|
let normalized = AccountInput {
|
||||||
|
display_name: Some(" Alice Chen ".into()),
|
||||||
|
is_active: Some(false),
|
||||||
|
..input("alice", "alice@example.com")
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(normalized.display_name.as_deref(), Some("Alice Chen"));
|
||||||
|
assert_eq!(normalized.is_active, Some(false));
|
||||||
|
|
||||||
|
// 純空白的 display_name 視同未提供
|
||||||
|
let normalized = AccountInput {
|
||||||
|
display_name: Some(" ".into()),
|
||||||
|
..input("alice", "alice@example.com")
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(normalized.display_name.as_deref(), Some("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_rejects_invalid_input() {
|
||||||
|
// 缺 username / email、email 缺 @、超長
|
||||||
|
assert!(input("", "a@b.c").normalized().is_err());
|
||||||
|
assert!(input("alice", "").normalized().is_err());
|
||||||
|
assert!(input("alice", "not-an-email").normalized().is_err());
|
||||||
|
assert!(input(&"x".repeat(65), "a@b.c").normalized().is_err());
|
||||||
|
|
||||||
|
// 純空白等價於空值
|
||||||
|
assert!(input(" ", "a@b.c").normalized().is_err());
|
||||||
|
assert!(input("alice", " ").normalized().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_hash_roundtrips_with_random_salt() {
|
||||||
|
let hash = hash_password("admin").unwrap();
|
||||||
|
// PHC 格式($argon2...)且可驗證
|
||||||
|
assert!(hash.starts_with("$argon2"));
|
||||||
|
assert!(verify_password("admin", &hash));
|
||||||
|
assert!(!verify_password("wrong-password", &hash));
|
||||||
|
// 格式無效的雜湊一律視為不相符
|
||||||
|
assert!(!verify_password("admin", "not-a-valid-hash"));
|
||||||
|
|
||||||
|
// 相同密碼因隨機鹽產生不同雜湊
|
||||||
|
assert_ne!(hash, hash_password("admin").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn account_serialization_hides_password_hash() {
|
||||||
|
let account = Account {
|
||||||
|
id: 1,
|
||||||
|
username: "alice".into(),
|
||||||
|
email: "alice@example.com".into(),
|
||||||
|
password_hash: "$argon2id$secret".into(),
|
||||||
|
display_name: "Alice".into(),
|
||||||
|
is_active: true,
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z".into(),
|
||||||
|
updated_at: "2026-01-01T00:00:00.000Z".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&account).unwrap();
|
||||||
|
assert!(json.get("password_hash").is_none());
|
||||||
|
assert_eq!(json.get("username"), Some(&serde_json::json!("alice")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
//! Database 模型:外部數據庫連接的數據結構與輸入驗證。
|
||||||
|
//!
|
||||||
|
//! 本模組只定義「數據長什麼樣」:連接類型([`DatabaseType`])、
|
||||||
|
//! 實體結構([`Database`])、建立與更新的請求載荷([`DatabaseInput`])
|
||||||
|
//! 及其正規化規則;SQL 與持久化細節見 [`crate::storage::database`]。
|
||||||
|
//!
|
||||||
|
//! 目前僅做連接的建模與管理(CRUD),尚不實際連到目標伺服器。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 數據庫類型;目前支援 MySQL 與 PostgreSQL。
|
||||||
|
///
|
||||||
|
/// API 與存儲層的規範字串為小寫 `"mysql"` / `"postgres"`
|
||||||
|
/// (serde 重新命名,見枚舉屬性)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DatabaseType {
|
||||||
|
/// MySQL
|
||||||
|
MySql,
|
||||||
|
/// PostgreSQL
|
||||||
|
Postgres,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseType {
|
||||||
|
/// 規範字串(`"mysql"` / `"postgres"`),同存儲層 `db_type` 欄位內容
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DatabaseType::MySql => "mysql",
|
||||||
|
DatabaseType::Postgres => "postgres",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 該類型的默認埠:MySQL 3306、PostgreSQL 5432
|
||||||
|
pub fn default_port(self) -> i64 {
|
||||||
|
match self {
|
||||||
|
DatabaseType::MySql => 3306,
|
||||||
|
DatabaseType::Postgres => 5432,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 由字串解析類型(大小寫不敏感);不支援的類型返回 `None`
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"mysql" => Some(DatabaseType::MySql),
|
||||||
|
"postgres" => Some(DatabaseType::Postgres),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 數據庫連接實體,對應存儲層 `databases` 表的一行。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Database {
|
||||||
|
/// 主鍵(SQLite rowid)
|
||||||
|
pub id: i64,
|
||||||
|
/// 連接顯示名稱,全表唯一
|
||||||
|
pub name: String,
|
||||||
|
/// 數據庫類型;API 序列化欄位名為 `type`
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub db_type: DatabaseType,
|
||||||
|
/// 主機名或 IP(暫不支援 URL 形式)
|
||||||
|
pub host: String,
|
||||||
|
/// 埠(1–65535)
|
||||||
|
pub port: i64,
|
||||||
|
/// 連接用戶名
|
||||||
|
pub username: String,
|
||||||
|
/// 連接密碼;日後實際連線需明文可用,故不做單向雜湊,但永不透過 API 序列化輸出。
|
||||||
|
/// 連線功能接入前僅測試讀取
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub password: String,
|
||||||
|
/// 目標資料庫名
|
||||||
|
pub database_name: String,
|
||||||
|
/// 建立時間(RFC 3339 UTC,如 `2026-01-01T08:00:00.123Z`)
|
||||||
|
pub created_at: String,
|
||||||
|
/// 最後更新時間(RFC 3339 UTC)
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建立與更新連接的請求載荷(POST / PUT 共用)。
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DatabaseInput {
|
||||||
|
/// 連接顯示名稱(必填,全表唯一)
|
||||||
|
pub name: String,
|
||||||
|
/// 數據庫類型(必填,`"mysql"` / `"postgres"`;API 欄位名為 `type`,
|
||||||
|
/// 非法值由 serde 反序列化直接拒絕)
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub db_type: DatabaseType,
|
||||||
|
/// 主機名或 IP(必填)
|
||||||
|
pub host: String,
|
||||||
|
/// 埠(可選,默認按類型補 3306 / 5432)
|
||||||
|
pub port: Option<i64>,
|
||||||
|
/// 連接用戶名(必填)
|
||||||
|
pub username: String,
|
||||||
|
/// 連接密碼(可選;更新時缺省=保留原值,見 [`DatabaseInput::normalized`])
|
||||||
|
pub password: Option<String>,
|
||||||
|
/// 目標資料庫名(必填)
|
||||||
|
pub database_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `name` 的長度上限(字符數)
|
||||||
|
const NAME_MAX_CHARS: usize = 128;
|
||||||
|
/// `host` 的長度上限(字符數)
|
||||||
|
const HOST_MAX_CHARS: usize = 255;
|
||||||
|
/// `username` 的長度上限(字符數)
|
||||||
|
const USERNAME_MAX_CHARS: usize = 64;
|
||||||
|
/// `password` 的長度上限(字符數)
|
||||||
|
const PASSWORD_MAX_CHARS: usize = 128;
|
||||||
|
/// `database_name` 的長度上限(字符數;MySQL 資料庫名上限為 64)
|
||||||
|
const DATABASE_NAME_MAX_CHARS: usize = 64;
|
||||||
|
/// 埠的合法範圍
|
||||||
|
const PORT_MIN: i64 = 1;
|
||||||
|
const PORT_MAX: i64 = 65535;
|
||||||
|
|
||||||
|
impl DatabaseInput {
|
||||||
|
/// 驗證並正規化輸入:去除首尾空白(密碼除外)、補齊默認值。
|
||||||
|
///
|
||||||
|
/// 返回值的 `port` 必為 `Some`,可直接交給存儲層。`password` 保持
|
||||||
|
/// `Option` 貫穿:`None`(JSON 缺省該鍵)在**更新**時表示保留原密碼
|
||||||
|
/// ——API 回應不會輸出密碼,客戶端編輯時無從回填,只能選擇覆寫或不變;
|
||||||
|
/// 在**建立**時視為空密碼。`Err` 的訊息可直接作為 API 400 響應的錯誤內容。
|
||||||
|
pub fn normalized(self) -> Result<Self, String> {
|
||||||
|
let name = self.name.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("name 不可為空".into());
|
||||||
|
}
|
||||||
|
if name.chars().count() > NAME_MAX_CHARS {
|
||||||
|
return Err(format!("name 長度不可超過 {NAME_MAX_CHARS} 個字符"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = self.host.trim().to_string();
|
||||||
|
if host.is_empty() {
|
||||||
|
return Err("host 不可為空".into());
|
||||||
|
}
|
||||||
|
if host.chars().count() > HOST_MAX_CHARS {
|
||||||
|
return Err(format!("host 長度不可超過 {HOST_MAX_CHARS} 個字符"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = match self.port {
|
||||||
|
Some(port) if !(PORT_MIN..=PORT_MAX).contains(&port) => {
|
||||||
|
return Err(format!("port 須介於 {PORT_MIN} 與 {PORT_MAX} 之間"));
|
||||||
|
}
|
||||||
|
other => other.or(Some(self.db_type.default_port())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let username = self.username.trim().to_string();
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err("username 不可為空".into());
|
||||||
|
}
|
||||||
|
if username.chars().count() > USERNAME_MAX_CHARS {
|
||||||
|
return Err(format!("username 長度不可超過 {USERNAME_MAX_CHARS} 個字符"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(password) = self.password.as_deref() {
|
||||||
|
if password.chars().count() > PASSWORD_MAX_CHARS {
|
||||||
|
return Err(format!("password 長度不可超過 {PASSWORD_MAX_CHARS} 個字符"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_name = self.database_name.trim().to_string();
|
||||||
|
if database_name.is_empty() {
|
||||||
|
return Err("database_name 不可為空".into());
|
||||||
|
}
|
||||||
|
if database_name.chars().count() > DATABASE_NAME_MAX_CHARS {
|
||||||
|
return Err(format!(
|
||||||
|
"database_name 長度不可超過 {DATABASE_NAME_MAX_CHARS} 個字符"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
name,
|
||||||
|
db_type: self.db_type,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password: self.password,
|
||||||
|
database_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn input(name: &str, db_type: DatabaseType) -> DatabaseInput {
|
||||||
|
DatabaseInput {
|
||||||
|
name: name.into(),
|
||||||
|
db_type,
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: None,
|
||||||
|
username: "app".into(),
|
||||||
|
password: None,
|
||||||
|
database_name: "shop".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_type_roundtrips_through_canonical_string() {
|
||||||
|
for db_type in [DatabaseType::MySql, DatabaseType::Postgres] {
|
||||||
|
// as_str ↔ parse 往返;解析大小寫不敏感、容忍首尾空白
|
||||||
|
assert_eq!(DatabaseType::parse(db_type.as_str()), Some(db_type));
|
||||||
|
assert_eq!(
|
||||||
|
DatabaseType::parse(&db_type.as_str().to_uppercase()),
|
||||||
|
Some(db_type)
|
||||||
|
);
|
||||||
|
assert_eq!(DatabaseType::parse(" oracle "), None);
|
||||||
|
assert_eq!(DatabaseType::parse(""), None);
|
||||||
|
}
|
||||||
|
assert_eq!(DatabaseType::MySql.as_str(), "mysql");
|
||||||
|
assert_eq!(DatabaseType::Postgres.as_str(), "postgres");
|
||||||
|
|
||||||
|
// 默認埠
|
||||||
|
assert_eq!(DatabaseType::MySql.default_port(), 3306);
|
||||||
|
assert_eq!(DatabaseType::Postgres.default_port(), 5432);
|
||||||
|
|
||||||
|
// serde 以小寫規範字串序列化 / 反序列化
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&DatabaseType::Postgres).unwrap(),
|
||||||
|
"\"postgres\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<DatabaseType>("\"mysql\"").unwrap(),
|
||||||
|
DatabaseType::MySql
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_trims_and_fills_defaults() {
|
||||||
|
// 缺省 port 按類型補默認;password 缺省保持 None(建立視為空、更新保留原值)
|
||||||
|
let normalized = input(" shop-prod ", DatabaseType::MySql)
|
||||||
|
.normalized()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(normalized.name, "shop-prod");
|
||||||
|
assert_eq!(normalized.host, "127.0.0.1");
|
||||||
|
assert_eq!(normalized.port, Some(3306));
|
||||||
|
assert_eq!(normalized.password, None);
|
||||||
|
|
||||||
|
let normalized = input("shop-prod", DatabaseType::Postgres)
|
||||||
|
.normalized()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(normalized.port, Some(5432));
|
||||||
|
|
||||||
|
// 明確提供 port / password 時保留原值(password 不 trim,可含空白)
|
||||||
|
let normalized = DatabaseInput {
|
||||||
|
port: Some(5433),
|
||||||
|
password: Some(" pass word ".into()),
|
||||||
|
..input("shop-prod", DatabaseType::Postgres)
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(normalized.port, Some(5433));
|
||||||
|
assert_eq!(normalized.password.as_deref(), Some(" pass word "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_rejects_invalid_input() {
|
||||||
|
// 必填欄位空或純空白
|
||||||
|
assert!(input("", DatabaseType::MySql).normalized().is_err());
|
||||||
|
assert!(input(" ", DatabaseType::MySql).normalized().is_err());
|
||||||
|
|
||||||
|
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||||
|
payload.host = " ".into();
|
||||||
|
assert!(payload.normalized().is_err());
|
||||||
|
|
||||||
|
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||||
|
payload.username = "".into();
|
||||||
|
assert!(payload.normalized().is_err());
|
||||||
|
|
||||||
|
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||||
|
payload.database_name = " ".into();
|
||||||
|
assert!(payload.normalized().is_err());
|
||||||
|
|
||||||
|
// 埠超出範圍
|
||||||
|
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||||
|
payload.port = Some(0);
|
||||||
|
assert!(payload.normalized().is_err());
|
||||||
|
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||||
|
payload.port = Some(65536);
|
||||||
|
assert!(payload.normalized().is_err());
|
||||||
|
|
||||||
|
// 超長
|
||||||
|
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||||
|
payload.name = "x".repeat(129);
|
||||||
|
assert!(payload.normalized().is_err());
|
||||||
|
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||||
|
payload.password = Some("x".repeat(129));
|
||||||
|
assert!(payload.normalized().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_serialization_hides_password_and_renames_type() {
|
||||||
|
let database = Database {
|
||||||
|
id: 1,
|
||||||
|
name: "shop-prod".into(),
|
||||||
|
db_type: DatabaseType::MySql,
|
||||||
|
host: "10.0.0.5".into(),
|
||||||
|
port: 3306,
|
||||||
|
username: "app".into(),
|
||||||
|
password: "secret".into(),
|
||||||
|
database_name: "shop".into(),
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z".into(),
|
||||||
|
updated_at: "2026-01-01T00:00:00.000Z".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&database).unwrap();
|
||||||
|
// 密碼永不輸出;類型欄位名為 "type"、值為小寫規範字串
|
||||||
|
assert!(json.get("password").is_none());
|
||||||
|
assert_eq!(json.get("type"), Some(&serde_json::json!("mysql")));
|
||||||
|
assert_eq!(json.get("database_name"), Some(&serde_json::json!("shop")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_deserializes_type_field_and_rejects_unknown_variants() {
|
||||||
|
// API 欄位名為 "type"
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"name": "shop-prod",
|
||||||
|
"type": "postgres",
|
||||||
|
"host": "10.0.0.5",
|
||||||
|
"username": "app",
|
||||||
|
"database_name": "shop"
|
||||||
|
});
|
||||||
|
let parsed: DatabaseInput = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(parsed.db_type, DatabaseType::Postgres);
|
||||||
|
assert_eq!(parsed.port, None);
|
||||||
|
|
||||||
|
// 尚未支援的類型在反序列化階段即被拒絕
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"name": "shop-prod",
|
||||||
|
"type": "oracle",
|
||||||
|
"host": "10.0.0.5",
|
||||||
|
"username": "app",
|
||||||
|
"database_name": "shop"
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<DatabaseInput>(payload).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! 模型層:定義對外的數據結構與輸入驗證,不含存儲細節。
|
||||||
|
//!
|
||||||
|
//! 每個模型一個子模組(如 [`account`]);對應的 SQL 與持久化
|
||||||
|
//! 實作放在 [`crate::storage`] 的同名子模組中。
|
||||||
|
|
||||||
|
pub mod account;
|
||||||
|
pub mod database;
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
//! 會話管理:登入狀態的服務端存儲與 [`CurrentUser`] 提取器。
|
||||||
|
//!
|
||||||
|
//! 會話保存在進程內存(`HashMap`),以 OS 級亂數產生的 256 位 token
|
||||||
|
//! 作為鑰匙,token 透過 `HttpOnly` Cookie 傳遞。伺服器重啟後會話
|
||||||
|
//! 全部失效(重新登入即可),對本地管理工具而言可接受,且無需
|
||||||
|
//! 外部依賴。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use axum::extract::FromRequestParts;
|
||||||
|
use axum::http::request::Parts;
|
||||||
|
use axum::http::{HeaderMap, header};
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// 會話 Cookie 的名稱
|
||||||
|
pub const COOKIE_NAME: &str = "alterdb_session";
|
||||||
|
|
||||||
|
/// 會話有效期;與 Cookie 的 `Max-Age` 保持一致
|
||||||
|
const SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
|
/// 單筆會話數據(登入當下對帳戶的快照;帳戶日後變更不回寫)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
/// 所屬帳戶主鍵;變更密碼時以 [`SessionStore::remove_all_for_user`]
|
||||||
|
/// 批次銷毀該帳戶的全部會話
|
||||||
|
pub user_id: i64,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
/// 過期時間;到期後 [`SessionStore::get`] 視同不存在
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 進程內會話存儲;`Clone` 後內部仍共享同一份數據。
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
inner: Arc<Mutex<HashMap<String, Session>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 為帳戶建立會話,返回隨機 token(同時作為 Cookie 值與查詢鑰匙)
|
||||||
|
pub fn insert(&self, user_id: i64, username: &str, display_name: &str) -> String {
|
||||||
|
let session = Session {
|
||||||
|
user_id,
|
||||||
|
username: username.to_string(),
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
expires_at: Instant::now() + SESSION_TTL,
|
||||||
|
};
|
||||||
|
let token = generate_token();
|
||||||
|
let mut guard = self.lock();
|
||||||
|
// 順手清理已過期會話,避免長時間運行下無界增長
|
||||||
|
guard.retain(|_, session| session.expires_at > Instant::now());
|
||||||
|
guard.insert(token.clone(), session);
|
||||||
|
token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 以 token 查詢有效會話;不存在或已過期返回 `None`
|
||||||
|
pub fn get(&self, token: &str) -> Option<Session> {
|
||||||
|
self.lock()
|
||||||
|
.get(token)
|
||||||
|
.filter(|session| session.expires_at > Instant::now())
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刪除會話(登出);返回會話是否原本存在
|
||||||
|
pub fn remove(&self, token: &str) -> bool {
|
||||||
|
self.lock().remove(token).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 銷毀指定帳戶的全部會話(變更密碼時呼叫,含當前會話);
|
||||||
|
/// 返回被銷毀的會話數。
|
||||||
|
pub fn remove_all_for_user(&self, user_id: i64) -> usize {
|
||||||
|
let mut guard = self.lock();
|
||||||
|
let before = guard.len();
|
||||||
|
guard.retain(|_, session| session.user_id != user_id);
|
||||||
|
before - guard.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Session>> {
|
||||||
|
self.inner.lock().expect("會話存儲鎖被毒化")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 256 位隨機 token(64 個十六進制字符),由 OS 級密碼學安全亂數產生
|
||||||
|
fn generate_token() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 從請求的 Cookie 標頭中取出會話 token;可能有多個 Cookie 標頭,逐一掃描
|
||||||
|
pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||||
|
for value in headers.get_all(header::COOKIE) {
|
||||||
|
let Ok(raw) = value.to_str() else { continue };
|
||||||
|
for pair in raw.split(';') {
|
||||||
|
let Some(rest) = pair.trim().strip_prefix(COOKIE_NAME) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(token) = rest.strip_prefix('=') {
|
||||||
|
if !token.is_empty() {
|
||||||
|
return Some(token.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 構造設置會話 Cookie 的 `Set-Cookie` 值。
|
||||||
|
///
|
||||||
|
/// `HttpOnly` 阻擋 JavaScript 讀取、`SameSite=Lax` 緩解 CSRF;
|
||||||
|
/// 本地以 HTTP 提供服務,故不加 `Secure`(加上後 Cookie 不會被送出)。
|
||||||
|
pub fn set_cookie_header(token: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{COOKIE_NAME}={token}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax",
|
||||||
|
SESSION_TTL.as_secs()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 構造清除會話 Cookie 的 `Set-Cookie` 值(登出時覆寫瀏覽器端的舊值)
|
||||||
|
pub fn clear_cookie_header() -> String {
|
||||||
|
format!("{COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已登入用戶(頁面處理器的提取器)。
|
||||||
|
///
|
||||||
|
/// 未持有效會話的請求會被重定向到 `/login`,頁面處理器無需
|
||||||
|
/// 自行檢查登入狀態。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CurrentUser {
|
||||||
|
/// 所屬帳戶主鍵;變更密碼等自身帳戶操作以此定位資料行
|
||||||
|
pub user_id: i64,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for CurrentUser {
|
||||||
|
type Rejection = Redirect;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let token = token_from_headers(&parts.headers).ok_or_else(login_redirect)?;
|
||||||
|
let session = state.sessions.get(&token).ok_or_else(login_redirect)?;
|
||||||
|
Ok(Self {
|
||||||
|
user_id: session.user_id,
|
||||||
|
username: session.username,
|
||||||
|
display_name: session.display_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_redirect() -> Redirect {
|
||||||
|
Redirect::to("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokens_are_unique_64_hex_chars() {
|
||||||
|
let token = generate_token();
|
||||||
|
assert_eq!(token.len(), 64);
|
||||||
|
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
assert_ne!(token, generate_token());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_roundtrip_and_remove() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let token = store.insert(7, "alice", "Alice");
|
||||||
|
|
||||||
|
let session = store.get(&token).expect("剛建立的會話應可查得");
|
||||||
|
assert_eq!(session.user_id, 7);
|
||||||
|
assert_eq!(session.username, "alice");
|
||||||
|
assert_eq!(session.display_name, "Alice");
|
||||||
|
|
||||||
|
assert!(store.remove(&token));
|
||||||
|
assert!(store.get(&token).is_none());
|
||||||
|
assert!(!store.remove(&token)); // 再刪無此會話
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_all_for_user_destroys_only_that_users_sessions() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let alice = store.insert(1, "alice", "Alice");
|
||||||
|
let alice_again = store.insert(1, "alice", "Alice");
|
||||||
|
let bob = store.insert(2, "bob", "Bob");
|
||||||
|
|
||||||
|
assert_eq!(store.remove_all_for_user(1), 2);
|
||||||
|
assert!(store.get(&alice).is_none());
|
||||||
|
assert!(store.get(&alice_again).is_none());
|
||||||
|
// 其他帳戶的會話不受影響
|
||||||
|
assert!(store.get(&bob).is_some());
|
||||||
|
// 帳戶已無會話時再銷毀:返回 0
|
||||||
|
assert_eq!(store.remove_all_for_user(1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_session_is_rejected_and_purged() {
|
||||||
|
let store = SessionStore::new();
|
||||||
|
let token = "expired-token".to_string();
|
||||||
|
store.lock().insert(
|
||||||
|
token.clone(),
|
||||||
|
Session {
|
||||||
|
user_id: 1,
|
||||||
|
username: "u".into(),
|
||||||
|
display_name: "d".into(),
|
||||||
|
expires_at: Instant::now() - Duration::from_secs(1),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(store.get(&token).is_none());
|
||||||
|
|
||||||
|
// 過期會話在下次 insert 時被清理
|
||||||
|
store.insert(2, "v", "V");
|
||||||
|
assert!(!store.lock().contains_key(&token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_token_from_cookie_headers() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
"theme=dark; alterdb_session=abc123".parse().unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(token_from_headers(&headers).as_deref(), Some("abc123"));
|
||||||
|
|
||||||
|
// 空 token 視同沒有會話
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::COOKIE, "alterdb_session=".parse().unwrap());
|
||||||
|
assert_eq!(token_from_headers(&headers), None);
|
||||||
|
|
||||||
|
// 沒有任何 Cookie 標頭
|
||||||
|
assert_eq!(token_from_headers(&HeaderMap::new()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cookie_headers_carry_security_attributes() {
|
||||||
|
let set = set_cookie_header("tok");
|
||||||
|
assert!(set.starts_with("alterdb_session=tok; Path=/; Max-Age=86400"));
|
||||||
|
assert!(set.contains("HttpOnly"));
|
||||||
|
assert!(set.contains("SameSite=Lax"));
|
||||||
|
|
||||||
|
let clear = clear_cookie_header();
|
||||||
|
assert!(clear.starts_with("alterdb_session=; Path=/; Max-Age=0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//! 應用共享狀態:控制器透過 `State<AppState>` 取用。
|
||||||
|
|
||||||
|
use crate::session::SessionStore;
|
||||||
|
use deadpool_sqlite::Pool;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Pool,
|
||||||
|
/// 登入會話存儲(進程內存,伺服器重啟後全部失效)
|
||||||
|
pub sessions: SessionStore,
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
//! Account 模型的存儲層:`accounts` 表結構與 CRUD 查詢。
|
||||||
|
|
||||||
|
use deadpool_sqlite::Pool;
|
||||||
|
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||||
|
|
||||||
|
use super::StorageError;
|
||||||
|
use crate::models::account::{Account, AccountInput};
|
||||||
|
|
||||||
|
/// `accounts` 表的建表語句(`IF NOT EXISTS`,可重複執行)。
|
||||||
|
///
|
||||||
|
/// 時間戳以 RFC 3339 UTC 文字存儲,由 SQLite 於寫入時生成,
|
||||||
|
/// 因此所有時間來源一致,無需依賴應用端的時鐘。
|
||||||
|
const CREATE_TABLE_SQL: &str = "\
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)";
|
||||||
|
|
||||||
|
/// 查詢時選取的欄位清單(與 [`row_to_account`] 的讀取順序對應)
|
||||||
|
const ACCOUNT_COLUMNS: &str =
|
||||||
|
"id, username, email, password_hash, display_name, is_active, created_at, updated_at";
|
||||||
|
|
||||||
|
/// 默認管理員帳號的用戶名與初始密碼
|
||||||
|
pub const ADMIN_USERNAME: &str = "admin";
|
||||||
|
pub const ADMIN_DEFAULT_PASSWORD: &str = "admin";
|
||||||
|
|
||||||
|
/// 確保 `accounts` 表的結構符合最新 schema;由 [`super::init_db`] 於啟動時呼叫。
|
||||||
|
///
|
||||||
|
/// 冪等可重複執行;對舊版表結構(缺 `password_hash` 欄位)會做輕量遷移。
|
||||||
|
pub fn ensure_table(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
conn.execute_batch(CREATE_TABLE_SQL)?;
|
||||||
|
migrate(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 輕量遷移:為舊版 `accounts` 表補上缺失的欄位(既有資料填默認值)
|
||||||
|
fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
if !has_column(conn, "accounts", "password_hash")? {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 檢查表是否存在指定欄位(PRAGMA table_info 的 name 欄位位於索引 1)
|
||||||
|
fn has_column(conn: &Connection, table: &str, column: &str) -> rusqlite::Result<bool> {
|
||||||
|
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||||
|
let names = stmt.query_map([], |row| row.get::<_, String>(1))?;
|
||||||
|
for name in names {
|
||||||
|
if name? == column {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 確保默認管理員帳號(`admin` / `admin`)存在。
|
||||||
|
///
|
||||||
|
/// 僅在 `admin` 不存在時建立,重啟不會覆蓋既有帳號(含日後改過的密碼);
|
||||||
|
/// 返回是否實際建立。密碼雜湊由呼叫端([`super::init_db`])先行產生後傳入。
|
||||||
|
pub fn seed_admin(conn: &Connection, password_hash: &str) -> rusqlite::Result<bool> {
|
||||||
|
let exists: bool = conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM accounts WHERE username = ?1)",
|
||||||
|
params![ADMIN_USERNAME],
|
||||||
|
|row| Ok(row.get::<_, i64>(0)? != 0),
|
||||||
|
)?;
|
||||||
|
if exists {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO accounts (username, email, display_name, is_active, password_hash)
|
||||||
|
VALUES (?1, ?2, ?3, 1, ?4)",
|
||||||
|
params![
|
||||||
|
ADMIN_USERNAME,
|
||||||
|
"admin@example.com",
|
||||||
|
"Administrator",
|
||||||
|
password_hash
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 將查詢結果行映射為 [`Account`]
|
||||||
|
fn row_to_account(row: &Row<'_>) -> rusqlite::Result<Account> {
|
||||||
|
Ok(Account {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
email: row.get(2)?,
|
||||||
|
password_hash: row.get(3)?,
|
||||||
|
display_name: row.get(4)?,
|
||||||
|
is_active: row.get::<_, i64>(5)? != 0,
|
||||||
|
created_at: row.get(6)?,
|
||||||
|
updated_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出所有帳戶(按 `id` 升序)
|
||||||
|
pub async fn list(pool: &Pool) -> Result<Vec<Account>, StorageError> {
|
||||||
|
let sql = format!("SELECT {ACCOUNT_COLUMNS} FROM accounts ORDER BY id");
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let accounts = stmt
|
||||||
|
.query_map([], row_to_account)?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(accounts)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建立帳戶;`input` 應先經 [`AccountInput::normalized`] 正規化。
|
||||||
|
///
|
||||||
|
/// 唯一欄位(username / email)衝突時,返回的錯誤可由
|
||||||
|
/// [`StorageError::is_unique_violation`] 判別。
|
||||||
|
pub async fn create(pool: &Pool, input: AccountInput) -> Result<Account, StorageError> {
|
||||||
|
// 經正規化的輸入必為 Some;此處僅作防禦性默認值
|
||||||
|
let display_name = input.display_name.unwrap_or_default();
|
||||||
|
let is_active = input.is_active.unwrap_or(true);
|
||||||
|
let sql = format!(
|
||||||
|
"INSERT INTO accounts (username, email, display_name, is_active)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
RETURNING {ACCOUNT_COLUMNS}"
|
||||||
|
);
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.query_row(
|
||||||
|
&sql,
|
||||||
|
params![input.username, input.email, display_name, is_active],
|
||||||
|
row_to_account,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依主鍵查詢帳戶;不存在時返回 `None`
|
||||||
|
pub async fn get_by_id(pool: &Pool, id: i64) -> Result<Option<Account>, StorageError> {
|
||||||
|
let sql = format!("SELECT {ACCOUNT_COLUMNS} FROM accounts WHERE id = ?1");
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| conn.query_row(&sql, params![id], row_to_account).optional())
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依用戶名查詢帳戶(登入用);不存在時返回 `None`
|
||||||
|
pub async fn get_by_username(pool: &Pool, username: &str) -> Result<Option<Account>, StorageError> {
|
||||||
|
let username = username.to_string(); // interact 閉包需 'static
|
||||||
|
let sql = format!("SELECT {ACCOUNT_COLUMNS} FROM accounts WHERE username = ?1");
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.query_row(&sql, params![username], row_to_account)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依主鍵更新帳戶的可編輯欄位並刷新 `updated_at`;不存在時返回 `None`
|
||||||
|
pub async fn update(
|
||||||
|
pool: &Pool,
|
||||||
|
id: i64,
|
||||||
|
input: AccountInput,
|
||||||
|
) -> Result<Option<Account>, StorageError> {
|
||||||
|
let display_name = input.display_name.unwrap_or_default();
|
||||||
|
let is_active = input.is_active.unwrap_or(true);
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE accounts
|
||||||
|
SET username = ?1,
|
||||||
|
email = ?2,
|
||||||
|
display_name = ?3,
|
||||||
|
is_active = ?4,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ?5
|
||||||
|
RETURNING {ACCOUNT_COLUMNS}"
|
||||||
|
);
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.query_row(
|
||||||
|
&sql,
|
||||||
|
params![input.username, input.email, display_name, is_active, id],
|
||||||
|
row_to_account,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依主鍵更新密碼雜湊並刷新 `updated_at`;返回帳戶是否存在(不存在時無行受影響)。
|
||||||
|
///
|
||||||
|
/// `password_hash` 應為 Argon2 PHC 格式字串(由
|
||||||
|
/// [`crate::models::account::hash_password`] 產生),本層不做密碼規則驗證。
|
||||||
|
pub async fn update_password(
|
||||||
|
pool: &Pool,
|
||||||
|
id: i64,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<bool, StorageError> {
|
||||||
|
let password_hash = password_hash.to_string(); // interact 閉包需 'static
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts
|
||||||
|
SET password_hash = ?1,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ?2",
|
||||||
|
params![password_hash, id],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
.map(|affected| affected > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依主鍵刪除帳戶;返回是否有行被刪除
|
||||||
|
pub async fn delete(pool: &Pool, id: i64) -> Result<bool, StorageError> {
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| conn.execute("DELETE FROM accounts WHERE id = ?1", params![id]))
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
.map(|affected| affected > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::storage::testutil;
|
||||||
|
|
||||||
|
/// 構造已正規化的輸入(與控制器層的實際調用路徑一致)
|
||||||
|
fn input(username: &str, email: &str) -> AccountInput {
|
||||||
|
AccountInput {
|
||||||
|
username: username.into(),
|
||||||
|
email: email.into(),
|
||||||
|
display_name: None,
|
||||||
|
is_active: None,
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn account_crud_roundtrip() {
|
||||||
|
let (pool, path) = testutil::test_pool("storage-account").await;
|
||||||
|
|
||||||
|
// 建立:默認值生效(display_name 同 username、is_active = true)
|
||||||
|
let created = create(&pool, input("alice", "alice@example.com"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(created.username, "alice");
|
||||||
|
assert_eq!(created.display_name, "alice");
|
||||||
|
assert!(created.is_active);
|
||||||
|
assert!(created.created_at.ends_with('Z')); // RFC 3339 UTC
|
||||||
|
|
||||||
|
// 依主鍵查詢:存在 / 不存在
|
||||||
|
let fetched = get_by_id(&pool, created.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(fetched.email, "alice@example.com");
|
||||||
|
assert!(get_by_id(&pool, created.id + 1000).await.unwrap().is_none());
|
||||||
|
|
||||||
|
// 列表:按 id 升序包含全部帳戶(含初始化自動建立的 admin)
|
||||||
|
create(&pool, input("bob", "bob@example.com"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let accounts = list(&pool).await.unwrap();
|
||||||
|
assert_eq!(accounts.len(), 3);
|
||||||
|
assert!(accounts.windows(2).all(|pair| pair[0].id < pair[1].id));
|
||||||
|
|
||||||
|
// 更新:可編輯欄位被覆寫
|
||||||
|
let updated = update(
|
||||||
|
&pool,
|
||||||
|
created.id,
|
||||||
|
AccountInput {
|
||||||
|
username: "alice".into(),
|
||||||
|
email: "alice@new.example.com".into(),
|
||||||
|
display_name: Some("Alice".into()),
|
||||||
|
is_active: Some(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("被更新的帳戶應存在");
|
||||||
|
assert_eq!(updated.display_name, "Alice");
|
||||||
|
assert!(!updated.is_active);
|
||||||
|
assert_eq!(updated.email, "alice@new.example.com");
|
||||||
|
|
||||||
|
// 刪除:成功一次,再刪無行受影響
|
||||||
|
assert!(delete(&pool, created.id).await.unwrap());
|
||||||
|
assert!(get_by_id(&pool, created.id).await.unwrap().is_none());
|
||||||
|
assert!(!delete(&pool, created.id).await.unwrap());
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn updates_password_hash_by_id() {
|
||||||
|
let (pool, path) = testutil::test_pool("storage-update-password").await;
|
||||||
|
let admin = get_by_username(&pool, super::ADMIN_USERNAME)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("admin 應存在");
|
||||||
|
|
||||||
|
let new_hash = crate::models::account::hash_password("new-secret").unwrap();
|
||||||
|
assert!(update_password(&pool, admin.id, &new_hash).await.unwrap());
|
||||||
|
|
||||||
|
let updated = get_by_id(&pool, admin.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(updated.password_hash, new_hash);
|
||||||
|
// 其餘欄位不受影響
|
||||||
|
assert_eq!(updated.username, admin.username);
|
||||||
|
assert_eq!(updated.email, admin.email);
|
||||||
|
assert!(updated.is_active);
|
||||||
|
|
||||||
|
// 不存在的帳號:無行受影響 → false
|
||||||
|
assert!(
|
||||||
|
!update_password(&pool, admin.id + 1000, &new_hash)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn finds_account_by_username() {
|
||||||
|
let (pool, path) = testutil::test_pool("storage-by-username").await;
|
||||||
|
|
||||||
|
// 初始化播種的 admin 可用用戶名查得(登入路徑)
|
||||||
|
let admin = get_by_username(&pool, super::ADMIN_USERNAME)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("admin 應可依用戶名查得");
|
||||||
|
assert_eq!(admin.email, "admin@example.com");
|
||||||
|
|
||||||
|
// 不存在的用戶名 → None;區分大小寫(SQLite TEXT 預設 BINARY 排序)
|
||||||
|
assert!(
|
||||||
|
get_by_username(&pool, "no-such-user")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(get_by_username(&pool, "Admin").await.unwrap().is_none());
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unique_constraint_conflicts() {
|
||||||
|
let (pool, path) = testutil::test_pool("storage-account-unique").await;
|
||||||
|
|
||||||
|
create(&pool, input("alice", "alice@example.com"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let duplicate_username = create(&pool, input("alice", "other@example.com"))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(duplicate_username.is_unique_violation());
|
||||||
|
|
||||||
|
let duplicate_email = create(&pool, input("bob", "alice@example.com"))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(duplicate_email.is_unique_violation());
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seeds_admin_once_with_hashed_password() {
|
||||||
|
// test_pool 內部走的就是 init_db:應已建立 admin
|
||||||
|
let (pool, path) = testutil::test_pool("seed-admin").await;
|
||||||
|
|
||||||
|
let accounts = list(&pool).await.unwrap();
|
||||||
|
assert_eq!(accounts.len(), 1);
|
||||||
|
let admin = &accounts[0];
|
||||||
|
assert_eq!(admin.username, super::ADMIN_USERNAME);
|
||||||
|
assert_eq!(admin.display_name, "Administrator");
|
||||||
|
assert!(admin.is_active);
|
||||||
|
assert!(crate::models::account::verify_password(
|
||||||
|
super::ADMIN_DEFAULT_PASSWORD,
|
||||||
|
&admin.password_hash,
|
||||||
|
));
|
||||||
|
assert!(!crate::models::account::verify_password(
|
||||||
|
"wrong",
|
||||||
|
&admin.password_hash
|
||||||
|
));
|
||||||
|
|
||||||
|
// 再次初始化:不重複建立、不覆蓋(日後改過密碼也不會被重置)
|
||||||
|
crate::storage::init_db(&pool).await.unwrap();
|
||||||
|
assert_eq!(list(&pool).await.unwrap().len(), 1);
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migrates_legacy_schema_then_seeds_admin() {
|
||||||
|
let (pool, path) = testutil::raw_pool("legacy-migrate").await;
|
||||||
|
|
||||||
|
// 舊版結構:accounts 表沒有 password_hash 欄位,且已有舊資料
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.interact(|conn| {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
INSERT INTO accounts (username, email) VALUES ('legacy', 'legacy@example.com');",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// init_db 應完成遷移(補欄位)並播種 admin
|
||||||
|
crate::storage::init_db(&pool).await.unwrap();
|
||||||
|
|
||||||
|
let accounts = list(&pool).await.unwrap();
|
||||||
|
assert_eq!(accounts.len(), 2);
|
||||||
|
let legacy = accounts
|
||||||
|
.iter()
|
||||||
|
.find(|account| account.username == "legacy")
|
||||||
|
.expect("舊資料應保留");
|
||||||
|
assert_eq!(legacy.password_hash, ""); // 舊資料補默認空值
|
||||||
|
let admin = accounts
|
||||||
|
.iter()
|
||||||
|
.find(|account| account.username == super::ADMIN_USERNAME)
|
||||||
|
.expect("admin 應被播種");
|
||||||
|
assert!(crate::models::account::verify_password(
|
||||||
|
super::ADMIN_DEFAULT_PASSWORD,
|
||||||
|
&admin.password_hash,
|
||||||
|
));
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
//! Database 模型的存儲層:`databases` 表結構與 CRUD 查詢。
|
||||||
|
|
||||||
|
use deadpool_sqlite::Pool;
|
||||||
|
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
|
||||||
|
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||||
|
|
||||||
|
use super::StorageError;
|
||||||
|
use crate::models::database::{Database, DatabaseInput, DatabaseType};
|
||||||
|
|
||||||
|
/// `databases` 表的建表語句(`IF NOT EXISTS`,可重複執行)。
|
||||||
|
///
|
||||||
|
/// 時間戳以 RFC 3339 UTC 文字存儲,由 SQLite 於寫入時生成,
|
||||||
|
/// 因此所有時間來源一致,無需依賴應用端的時鐘。
|
||||||
|
/// `db_type` 以小寫規範字串存儲,CHECK 約束保證只含支援的類型。
|
||||||
|
const CREATE_TABLE_SQL: &str = "\
|
||||||
|
CREATE TABLE IF NOT EXISTS databases (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
db_type TEXT NOT NULL CHECK (db_type IN ('mysql', 'postgres')),
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
database_name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)";
|
||||||
|
|
||||||
|
/// 查詢時選取的欄位清單(與 [`row_to_database`] 的讀取順序對應)
|
||||||
|
const DATABASE_COLUMNS: &str =
|
||||||
|
"id, name, db_type, host, port, username, password, database_name, created_at, updated_at";
|
||||||
|
|
||||||
|
impl ToSql for DatabaseType {
|
||||||
|
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
|
||||||
|
Ok(ToSqlOutput::Borrowed(self.as_str().into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for DatabaseType {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
let text = value.as_str()?;
|
||||||
|
DatabaseType::parse(text).ok_or(FromSqlError::InvalidType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 確保 `databases` 表的結構存在;由 [`super::init_db`] 於啟動時呼叫。
|
||||||
|
///
|
||||||
|
/// 冪等可重複執行;全新表,無舊版結構需遷移。
|
||||||
|
pub fn ensure_table(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
conn.execute_batch(CREATE_TABLE_SQL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 將查詢結果行映射為 [`Database`]
|
||||||
|
fn row_to_database(row: &Row<'_>) -> rusqlite::Result<Database> {
|
||||||
|
Ok(Database {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
db_type: row.get(2)?,
|
||||||
|
host: row.get(3)?,
|
||||||
|
port: row.get(4)?,
|
||||||
|
username: row.get(5)?,
|
||||||
|
password: row.get(6)?,
|
||||||
|
database_name: row.get(7)?,
|
||||||
|
created_at: row.get(8)?,
|
||||||
|
updated_at: row.get(9)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出所有數據庫連接(按 `id` 升序)
|
||||||
|
pub async fn list(pool: &Pool) -> Result<Vec<Database>, StorageError> {
|
||||||
|
let sql = format!("SELECT {DATABASE_COLUMNS} FROM databases ORDER BY id");
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let databases = stmt
|
||||||
|
.query_map([], row_to_database)?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(databases)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建立數據庫連接;`input` 應先經 [`DatabaseInput::normalized`] 正規化。
|
||||||
|
///
|
||||||
|
/// `name` 衝突時,返回的錯誤可由 [`StorageError::is_unique_violation`] 判別。
|
||||||
|
pub async fn create(pool: &Pool, input: DatabaseInput) -> Result<Database, StorageError> {
|
||||||
|
// port 經正規化必為 Some;password 為 None 時建立視為空密碼
|
||||||
|
let port = input.port.unwrap_or_else(|| input.db_type.default_port());
|
||||||
|
let password = input.password.unwrap_or_default();
|
||||||
|
let sql = format!(
|
||||||
|
"INSERT INTO databases (name, db_type, host, port, username, password, database_name)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
|
RETURNING {DATABASE_COLUMNS}"
|
||||||
|
);
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.query_row(
|
||||||
|
&sql,
|
||||||
|
params![
|
||||||
|
input.name,
|
||||||
|
input.db_type,
|
||||||
|
input.host,
|
||||||
|
port,
|
||||||
|
input.username,
|
||||||
|
password,
|
||||||
|
input.database_name
|
||||||
|
],
|
||||||
|
row_to_database,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依主鍵查詢數據庫連接;不存在時返回 `None`
|
||||||
|
pub async fn get_by_id(pool: &Pool, id: i64) -> Result<Option<Database>, StorageError> {
|
||||||
|
let sql = format!("SELECT {DATABASE_COLUMNS} FROM databases WHERE id = ?1");
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.query_row(&sql, params![id], row_to_database)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依主鍵更新連接的全部可編輯欄位並刷新 `updated_at`;不存在時返回 `None`。
|
||||||
|
///
|
||||||
|
/// `input.password` 為 `None`(JSON 缺省該鍵)時保留原密碼:API 回應不
|
||||||
|
/// 輸出密碼,編輯端無從回填,缺省即「不變更」;`Some` 則覆寫(含清空)。
|
||||||
|
pub async fn update(
|
||||||
|
pool: &Pool,
|
||||||
|
id: i64,
|
||||||
|
input: DatabaseInput,
|
||||||
|
) -> Result<Option<Database>, StorageError> {
|
||||||
|
let port = input.port.unwrap_or_else(|| input.db_type.default_port());
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE databases
|
||||||
|
SET name = ?1,
|
||||||
|
db_type = ?2,
|
||||||
|
host = ?3,
|
||||||
|
port = ?4,
|
||||||
|
username = ?5,
|
||||||
|
password = COALESCE(?6, password),
|
||||||
|
database_name = ?7,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ?8
|
||||||
|
RETURNING {DATABASE_COLUMNS}"
|
||||||
|
);
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.query_row(
|
||||||
|
&sql,
|
||||||
|
params![
|
||||||
|
input.name,
|
||||||
|
input.db_type,
|
||||||
|
input.host,
|
||||||
|
port,
|
||||||
|
input.username,
|
||||||
|
input.password,
|
||||||
|
input.database_name,
|
||||||
|
id
|
||||||
|
],
|
||||||
|
row_to_database,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依主鍵刪除數據庫連接;返回是否有行被刪除
|
||||||
|
pub async fn delete(pool: &Pool, id: i64) -> Result<bool, StorageError> {
|
||||||
|
pool.get()
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::pool)?
|
||||||
|
.interact(move |conn| conn.execute("DELETE FROM databases WHERE id = ?1", params![id]))
|
||||||
|
.await
|
||||||
|
.map_err(StorageError::interact)?
|
||||||
|
.map_err(StorageError::Sqlite)
|
||||||
|
.map(|affected| affected > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::storage::testutil;
|
||||||
|
|
||||||
|
/// 構造已正規化的輸入(與控制器層的實際調用路徑一致)
|
||||||
|
fn input(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(),
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn database_crud_roundtrip() {
|
||||||
|
let (pool, path) = testutil::test_pool("storage-database").await;
|
||||||
|
|
||||||
|
// 建立:port 按類型補默認(MySQL → 3306)
|
||||||
|
let created = create(&pool, input("shop-prod", DatabaseType::MySql))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(created.name, "shop-prod");
|
||||||
|
assert_eq!(created.db_type, DatabaseType::MySql);
|
||||||
|
assert_eq!(created.port, 3306);
|
||||||
|
assert_eq!(created.password, "secret"); // 內部讀取可得,僅 API 序列化時隱藏
|
||||||
|
assert!(created.created_at.ends_with('Z')); // RFC 3339 UTC
|
||||||
|
|
||||||
|
// 依主鍵查詢:存在 / 不存在
|
||||||
|
let fetched = get_by_id(&pool, created.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(fetched.host, "10.0.0.5");
|
||||||
|
assert!(get_by_id(&pool, created.id + 1000).await.unwrap().is_none());
|
||||||
|
|
||||||
|
// 列表:按 id 升序包含全部連接
|
||||||
|
create(&pool, input("reporting", DatabaseType::Postgres))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let databases = list(&pool).await.unwrap();
|
||||||
|
assert_eq!(databases.len(), 2);
|
||||||
|
assert!(databases.windows(2).all(|pair| pair[0].id < pair[1].id));
|
||||||
|
assert_eq!(databases[1].db_type, DatabaseType::Postgres);
|
||||||
|
assert_eq!(databases[1].port, 5432); // PostgreSQL 默認埠
|
||||||
|
|
||||||
|
// 更新:可編輯欄位被覆寫(含類型變更)
|
||||||
|
let updated = update(
|
||||||
|
&pool,
|
||||||
|
created.id,
|
||||||
|
DatabaseInput {
|
||||||
|
name: "shop-prod".into(),
|
||||||
|
db_type: DatabaseType::Postgres,
|
||||||
|
host: "10.0.0.6".into(),
|
||||||
|
port: Some(5433),
|
||||||
|
username: "app2".into(),
|
||||||
|
password: Some("new-secret".into()),
|
||||||
|
database_name: "shop2".into(),
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("被更新的連接應存在");
|
||||||
|
assert_eq!(updated.db_type, DatabaseType::Postgres);
|
||||||
|
assert_eq!(updated.host, "10.0.0.6");
|
||||||
|
assert_eq!(updated.port, 5433);
|
||||||
|
assert_eq!(updated.password, "new-secret");
|
||||||
|
|
||||||
|
// 更新時 password 缺省(None)=保留原密碼
|
||||||
|
let updated = update(
|
||||||
|
&pool,
|
||||||
|
created.id,
|
||||||
|
DatabaseInput {
|
||||||
|
name: "shop-prod".into(),
|
||||||
|
db_type: DatabaseType::Postgres,
|
||||||
|
host: "10.0.0.6".into(),
|
||||||
|
port: Some(5433),
|
||||||
|
username: "app2".into(),
|
||||||
|
password: None,
|
||||||
|
database_name: "shop2".into(),
|
||||||
|
}
|
||||||
|
.normalized()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("被更新的連接應存在");
|
||||||
|
assert_eq!(updated.password, "new-secret");
|
||||||
|
|
||||||
|
// 刪除:成功一次,再刪無行受影響
|
||||||
|
assert!(delete(&pool, created.id).await.unwrap());
|
||||||
|
assert!(get_by_id(&pool, created.id).await.unwrap().is_none());
|
||||||
|
assert!(!delete(&pool, created.id).await.unwrap());
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unique_name_conflicts() {
|
||||||
|
let (pool, path) = testutil::test_pool("storage-database-unique").await;
|
||||||
|
|
||||||
|
create(&pool, input("shop-prod", DatabaseType::MySql))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let duplicate = create(&pool, input("shop-prod", DatabaseType::Postgres))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(duplicate.is_unique_violation());
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn db_type_column_roundtrips_as_canonical_string() {
|
||||||
|
let (pool, path) = testutil::test_pool("storage-database-type").await;
|
||||||
|
|
||||||
|
let created = create(&pool, input("shop-prod", DatabaseType::Postgres))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 存儲內容為小寫規範字串(非 Debug 格式 "Postgres")
|
||||||
|
let stored: String = pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT db_type FROM databases WHERE id = ?1",
|
||||||
|
params![created.id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored, "postgres");
|
||||||
|
|
||||||
|
pool.close();
|
||||||
|
testutil::cleanup(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! 存儲層:SQLite 連接池的建立、資料庫初始化與各模型的持久化實作。
|
||||||
|
//!
|
||||||
|
//! 每個模型一個子模組(如 [`account`]),各自封裝對應表的 SQL;
|
||||||
|
//! 共用的錯誤類型 [`StorageError`] 定義於本模組。
|
||||||
|
|
||||||
|
pub mod account;
|
||||||
|
pub mod database;
|
||||||
|
|
||||||
|
use deadpool_sqlite::{Config, Pool, Runtime};
|
||||||
|
|
||||||
|
/// 預設資料庫檔案路徑,可透過環境變數 `ALTERDB_DB_PATH` 覆蓋
|
||||||
|
pub const DEFAULT_DB_PATH: &str = "alterdb.db";
|
||||||
|
|
||||||
|
/// 建立 SQLite 連接池
|
||||||
|
pub fn create_pool(db_path: &str) -> Result<Pool, Box<dyn std::error::Error>> {
|
||||||
|
Ok(Config::new(db_path).create_pool(Runtime::Tokio1)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化資料庫:啟用 WAL 日誌模式(一次寫入即持久生效)、確保各模型的
|
||||||
|
/// 表結構存在(冪等,含輕量遷移),並播種默認管理員帳號(僅在不存在時建立)。
|
||||||
|
pub async fn init_db(pool: &Pool) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Argon2 雜湊需耗時數十至數百毫秒,放在互動閉包外先行計算
|
||||||
|
let admin_hash = crate::models::account::hash_password(account::ADMIN_DEFAULT_PASSWORD)?;
|
||||||
|
pool.get()
|
||||||
|
.await?
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))?;
|
||||||
|
account::ensure_table(conn)?;
|
||||||
|
database::ensure_table(conn)?;
|
||||||
|
if account::seed_admin(conn, &admin_hash)? {
|
||||||
|
tracing::info!(
|
||||||
|
"已自動建立默認管理員帳號:{}(初始密碼同帳號,建議儘早變更)",
|
||||||
|
account::ADMIN_USERNAME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok::<(), rusqlite::Error>(())
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 存儲層錯誤:涵蓋連接池、連線互動與 SQLite 執行三類來源。
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum StorageError {
|
||||||
|
/// 無法從連接池取得連線(資料庫可能不可用)
|
||||||
|
Pool(String),
|
||||||
|
/// 與連線的互動失敗(如 SQL 閉包 panic)
|
||||||
|
Interact(String),
|
||||||
|
/// SQLite 執行錯誤(語法、約束等)
|
||||||
|
Sqlite(rusqlite::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StorageError {
|
||||||
|
fn pool(err: impl std::fmt::Display) -> Self {
|
||||||
|
StorageError::Pool(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interact(err: impl std::fmt::Display) -> Self {
|
||||||
|
StorageError::Interact(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否為唯一性約束衝突(控制器可上報為 HTTP 409)
|
||||||
|
pub fn is_unique_violation(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Sqlite(rusqlite::Error::SqliteFailure(err, _))
|
||||||
|
if err.code == rusqlite::ErrorCode::ConstraintViolation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for StorageError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Pool(err) => write!(f, "無法取得資料庫連線:{err}"),
|
||||||
|
Self::Interact(err) => write!(f, "資料庫連線互動失敗:{err}"),
|
||||||
|
Self::Sqlite(err) => write!(f, "SQLite 錯誤:{err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for StorageError {}
|
||||||
|
|
||||||
|
/// 測試輔助:以暫存檔建立彼此獨立的資料庫,避免污染工作目錄。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod testutil {
|
||||||
|
use super::{create_pool, init_db};
|
||||||
|
use deadpool_sqlite::Pool;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// 生成測試專用的暫存資料庫路徑(以進程 ID + 奈秒時間戳保證唯一)
|
||||||
|
fn temp_path(label: &str) -> PathBuf {
|
||||||
|
let unique = format!(
|
||||||
|
"{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos(),
|
||||||
|
);
|
||||||
|
std::env::temp_dir().join(format!("alterdb-{label}-{unique}.db"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建立一個指向暫存檔的連接池(不做任何初始化,供遷移測試自建舊版結構)
|
||||||
|
pub async fn raw_pool(label: &str) -> (Pool, PathBuf) {
|
||||||
|
let path = temp_path(label);
|
||||||
|
let pool = create_pool(path.to_str().expect("暫存路徑應為合法 UTF-8"))
|
||||||
|
.expect("應能建立測試連接池");
|
||||||
|
(pool, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建立並初始化一個指向暫存檔的連接池
|
||||||
|
pub async fn test_pool(label: &str) -> (Pool, PathBuf) {
|
||||||
|
let (pool, path) = raw_pool(label).await;
|
||||||
|
init_db(&pool).await.expect("測試資料庫初始化應成功");
|
||||||
|
(pool, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 盡力清理測試資料庫檔案(含 WAL / SHM 旁車檔)
|
||||||
|
pub fn cleanup(path: &Path) {
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}404 找不到頁面 — alterdb{% endblock %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.not-found {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: 3rem auto 0;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.not-found .status {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
.not-found h2 { margin: .5rem 0 0; }
|
||||||
|
.not-found p { opacity: .8; margin: 1rem 0 0; }
|
||||||
|
.not-found a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: .5rem 1.2rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.not-found a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<header class="site-header">
|
||||||
|
<h1>alterdb</h1>
|
||||||
|
<button type="button" class="theme-toggle" data-theme-toggle>深色模式</button>
|
||||||
|
</header>
|
||||||
|
<div class="not-found">
|
||||||
|
<p class="status">404</p>
|
||||||
|
<h2>找不到頁面</h2>
|
||||||
|
<p>你訪問的頁面 <code>{{ path }}</code> 不存在或已被移動,請檢查網址是否正確。</p>
|
||||||
|
<a href="/">返回首頁</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block layout_style %}
|
||||||
|
<style>
|
||||||
|
/* 應用外殼:左側固定側邊欄 + 右側主內容區;窄屏折疊為頂欄 */
|
||||||
|
.shell { display: flex; min-height: 100vh; min-height: 100dvh; }
|
||||||
|
.sidebar {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 15rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.4rem 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||||
|
}
|
||||||
|
.sidebar .brand h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; }
|
||||||
|
.sidebar .brand a { color: inherit; text-decoration: none; }
|
||||||
|
.sidebar .brand p { margin: .25rem 0 0; font-size: .78rem; opacity: .65; }
|
||||||
|
.sidebar nav { display: flex; flex-direction: column; gap: .25rem; }
|
||||||
|
.sidebar nav a {
|
||||||
|
padding: .5rem .8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.sidebar nav a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
.sidebar nav a[aria-current="page"] {
|
||||||
|
font-weight: 600;
|
||||||
|
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||||
|
}
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .6rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
.sidebar-footer form { margin: 0; }
|
||||||
|
.sidebar-footer .footer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
.sidebar-footer .footer-actions a {
|
||||||
|
font: inherit;
|
||||||
|
padding: .3rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.sidebar-footer .footer-actions a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
.sidebar-footer button {
|
||||||
|
font: inherit;
|
||||||
|
padding: .3rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sidebar-footer button:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
.main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 56rem;
|
||||||
|
padding: 2.5rem 3rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.shell { display: block; min-height: auto; }
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem 1.25rem;
|
||||||
|
padding: .8rem 1rem;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.sidebar .brand p { display: none; }
|
||||||
|
.sidebar nav { flex-direction: row; gap: .25rem; }
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: .8rem;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
.main { padding: 1.5rem 1rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<h1><a href="/">alterdb</a></h1>
|
||||||
|
<p>以 SQLite 為底層存儲的自定義數據庫引擎</p>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="主導航">
|
||||||
|
<a href="/"{% if nav_active == "index" %} aria-current="page"{% endif %}>首頁</a>
|
||||||
|
<a href="/databases"{% if nav_active == "databases" %} aria-current="page"{% endif %}>數據庫</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<span>已登入:<strong>{{ user_display_name }}</strong>{% if user_display_name != user_username %} <code>{{ user_username }}</code>{% endif %}</span>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button type="button" class="theme-toggle" data-theme-toggle>深色模式</button>
|
||||||
|
<a href="/password">變更密碼</a>
|
||||||
|
<form method="post" action="/logout">
|
||||||
|
<button type="submit">登出</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="main">
|
||||||
|
{% block page %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}alterdb{% endblock %}</title>
|
||||||
|
<script>
|
||||||
|
// 首幀繪製前套用手動選擇的主題,避免閃爍;無記錄時跟隨系統偏好。
|
||||||
|
// 必須內聯且同步執行,置於樣式之後、body 之前。
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var theme = localStorage.getItem('theme');
|
||||||
|
if (theme === 'light' || theme === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
} catch (_) { /* 存儲不可用(如隱私模式)時跟隨系統 */ }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
/* 手動固定主題:覆蓋 color-scheme 後,預設畫布/文字色與 light-dark() 一併翻轉 */
|
||||||
|
:root[data-theme="light"] { color-scheme: light; }
|
||||||
|
:root[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
/* 獨立版式頁(登入 / 404)用的置中容器 */
|
||||||
|
.page {
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 3rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
header.site-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
header.site-header h1 { margin: 0; }
|
||||||
|
code { background: color-mix(in srgb, currentColor 10%, transparent); padding: 0.1em 0.4em; border-radius: 4px; }
|
||||||
|
/* 深淺色切換按鈕(各版式自行放置,樣式與側欄按鈕一致) */
|
||||||
|
.theme-toggle {
|
||||||
|
font: inherit;
|
||||||
|
font-size: .85rem;
|
||||||
|
padding: .3rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
</style>
|
||||||
|
{% block layout_style %}{% endblock %}
|
||||||
|
{% block style %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
{% block script %}{% endblock %}
|
||||||
|
<script>
|
||||||
|
// 深淺色切換:全站共用,綁定所有帶 data-theme-toggle 的按鈕。
|
||||||
|
// 主題固定在 <html data-theme>;未設置時由 color-scheme: light dark 跟隨系統。
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function effectiveTheme() {
|
||||||
|
const pinned = document.documentElement.getAttribute('data-theme');
|
||||||
|
if (pinned === 'light' || pinned === 'dark') { return pinned; }
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按鈕文字顯示點擊後將切換到的模式
|
||||||
|
function refreshLabels() {
|
||||||
|
const label = effectiveTheme() === 'dark' ? '淺色模式' : '深色模式';
|
||||||
|
for (const button of document.querySelectorAll('[data-theme-toggle]')) {
|
||||||
|
button.textContent = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const button of document.querySelectorAll('[data-theme-toggle]')) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const next = effectiveTheme() === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
try { localStorage.setItem('theme', next); } catch (_) { /* 存儲不可用時僅本次生效 */ }
|
||||||
|
refreshLabels();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 未手動固定主題時,頁面配色由 CSS 自動跟隨系統,這裡僅同步按鈕文字
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', refreshLabels);
|
||||||
|
refreshLabels();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
{% extends "_shell.html" %}
|
||||||
|
{% block title %}連接詳情:{{ database.name }} — alterdb{% endblock %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.toolbar .title { display: flex; align-items: baseline; gap: .7rem; margin: 0; }
|
||||||
|
.toolbar h2 { margin: 0; }
|
||||||
|
.toolbar .type-badge {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: .1rem .5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.toolbar .actions { display: flex; gap: .6rem; }
|
||||||
|
.toolbar .actions a {
|
||||||
|
font: inherit;
|
||||||
|
padding: .35rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.toolbar .actions a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
.meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||||
|
gap: .6rem 1.4rem;
|
||||||
|
margin: 0 0 1.8rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.meta div { min-width: 0; }
|
||||||
|
.meta dt {
|
||||||
|
font-size: .8rem;
|
||||||
|
opacity: .65;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.meta dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
section h3 { margin: 0 0 .7rem; }
|
||||||
|
.status-line { display: flex; align-items: center; gap: .8rem; margin-bottom: 1.6rem; }
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .45rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status::before {
|
||||||
|
content: "";
|
||||||
|
width: .55rem;
|
||||||
|
height: .55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.status.pending { opacity: .65; }
|
||||||
|
.status.ok { color: light-dark(#146c2e, #6ddb8c); }
|
||||||
|
.status.bad { color: light-dark(#b3261e, #ffb4ab); }
|
||||||
|
#retest {
|
||||||
|
font: inherit;
|
||||||
|
padding: .3rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#retest:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
#retest:disabled { opacity: .5; cursor: wait; }
|
||||||
|
.status-detail { opacity: .8; font-size: .9rem; overflow-wrap: anywhere; }
|
||||||
|
.error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: light-dark(#b3261e, #ffb4ab);
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
.db-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
.db-table th, .db-table td {
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.db-table th { font-weight: 600; }
|
||||||
|
.db-table td.empty { text-align: center; opacity: .7; padding: 1.5rem 0; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block page %}
|
||||||
|
<div id="db-detail" data-database-id="{{ database.id }}">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="title">
|
||||||
|
<h2>{{ database.name }}</h2>
|
||||||
|
<span class="type-badge">{{ database.db_type.as_str() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/databases/{{ database.id }}/edit">編輯</a>
|
||||||
|
<a href="/databases">返回列表</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="meta">
|
||||||
|
<div><dt>主機</dt><dd>{{ database.host }}:{{ database.port }}</dd></div>
|
||||||
|
<div><dt>資料庫</dt><dd>{{ database.database_name }}</dd></div>
|
||||||
|
<div><dt>用戶</dt><dd>{{ database.username }}</dd></div>
|
||||||
|
<div><dt>建立於</dt><dd>{{ created_at_display }}</dd></div>
|
||||||
|
<div><dt>更新於</dt><dd>{{ updated_at_display }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section aria-label="連接狀態">
|
||||||
|
<h3>連接狀態</h3>
|
||||||
|
<div class="status-line">
|
||||||
|
<span id="status" class="status pending">測試中…</span>
|
||||||
|
<span id="status-detail" class="status-detail"></span>
|
||||||
|
<button type="button" id="retest">重新測試</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="數據表">
|
||||||
|
<h3>數據表</h3>
|
||||||
|
<p id="tables-error" class="error" role="alert" hidden></p>
|
||||||
|
<table class="db-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>名稱</th><th>類型</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const root = document.getElementById('db-detail');
|
||||||
|
const databaseId = root.dataset.databaseId;
|
||||||
|
const statusBadge = document.getElementById('status');
|
||||||
|
const statusDetail = document.getElementById('status-detail');
|
||||||
|
const retestButton = document.getElementById('retest');
|
||||||
|
const tablesError = document.getElementById('tables-error');
|
||||||
|
const tbody = document.getElementById('table-rows');
|
||||||
|
|
||||||
|
// information_schema 的 TABLE_TYPE → 中文標籤;未知值原樣展示
|
||||||
|
const KIND_LABELS = { 'BASE TABLE': '表', 'VIEW': '視圖' };
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
const response = await fetch(path, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = '請求失敗(HTTP ' + response.status + ')';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.error) { message = body.error; }
|
||||||
|
} catch (_) { /* 非 JSON 錯誤體,保留默認訊息 */ }
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return response.status === 204 ? null : response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageRow(text) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.colSpan = 2;
|
||||||
|
td.className = 'empty';
|
||||||
|
td.textContent = text;
|
||||||
|
tr.append(td);
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
retestButton.disabled = true;
|
||||||
|
statusBadge.textContent = '測試中…';
|
||||||
|
statusBadge.className = 'status pending';
|
||||||
|
statusDetail.textContent = '';
|
||||||
|
try {
|
||||||
|
const outcome = await api('/api/databases/' + databaseId + '/test', { method: 'POST' });
|
||||||
|
if (outcome.ok) {
|
||||||
|
statusBadge.textContent = '已連接';
|
||||||
|
statusBadge.className = 'status ok';
|
||||||
|
statusDetail.textContent =
|
||||||
|
(outcome.server_version || '') + ' · ' + outcome.elapsed_ms + ' ms';
|
||||||
|
} else {
|
||||||
|
statusBadge.textContent = '無法連接';
|
||||||
|
statusBadge.className = 'status bad';
|
||||||
|
statusDetail.textContent = outcome.error || '未知錯誤';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
statusBadge.textContent = '測試失敗';
|
||||||
|
statusBadge.className = 'status bad';
|
||||||
|
statusDetail.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
retestButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有動態值一律經 textContent 寫入,避免以字串拼接 HTML 引入注入風險
|
||||||
|
function renderTables(tables) {
|
||||||
|
if (tables.length === 0) {
|
||||||
|
tbody.replaceChildren(messageRow('此資料庫目前 schema 下沒有數據表。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = tables.map((table) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const name = document.createElement('td');
|
||||||
|
// 表名連到表管理頁(欄位結構管理,資料瀏覽在其「數據管理」頁);路徑段編碼後拼接
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = '/databases/' + databaseId + '/tables/' + encodeURIComponent(table.name);
|
||||||
|
link.textContent = table.name;
|
||||||
|
name.append(link);
|
||||||
|
const kind = document.createElement('td');
|
||||||
|
kind.textContent = KIND_LABELS[table.kind] || table.kind;
|
||||||
|
tr.append(name, kind);
|
||||||
|
return tr;
|
||||||
|
});
|
||||||
|
tbody.replaceChildren(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTables() {
|
||||||
|
tablesError.hidden = true;
|
||||||
|
tbody.replaceChildren(messageRow('載入中…'));
|
||||||
|
try {
|
||||||
|
renderTables(await api('/api/databases/' + databaseId + '/tables'));
|
||||||
|
} catch (err) {
|
||||||
|
tbody.replaceChildren();
|
||||||
|
tablesError.textContent = err.message;
|
||||||
|
tablesError.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
runTest();
|
||||||
|
loadTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
retestButton.addEventListener('click', refresh);
|
||||||
|
refresh();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
{% extends "_shell.html" %}
|
||||||
|
{% block title %}{% if let Some(db) = editing %}編輯連接:{{ db.name }}{% else %}新增連接{% endif %} — alterdb{% endblock %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.form-card {
|
||||||
|
padding: 1.4rem 1.6rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.form-card h2 { margin: 0 0 1.1rem; }
|
||||||
|
.error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: light-dark(#b3261e, #ffb4ab);
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
.db-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem 1.4rem;
|
||||||
|
}
|
||||||
|
.db-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.db-form input, .db-form select {
|
||||||
|
font: inherit;
|
||||||
|
padding: .45rem .65rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.db-form .form-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
.db-form .form-actions button,
|
||||||
|
.db-form .form-actions a {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: .5rem 1.1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
.db-form .form-actions a {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
}
|
||||||
|
.db-form .form-actions button:hover,
|
||||||
|
.db-form .form-actions a:hover { background: color-mix(in srgb, currentColor 22%, transparent); }
|
||||||
|
.db-form .hint { font-size: .85rem; opacity: .7; font-weight: 400; }
|
||||||
|
@media (max-width: 34rem) { .db-form { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block page %}
|
||||||
|
<div class="form-card">
|
||||||
|
<h2>{% if let Some(db) = editing %}編輯連接:{{ db.name }}{% else %}新增連接{% endif %}</h2>
|
||||||
|
<p id="form-error" class="error" role="alert" hidden></p>
|
||||||
|
<form id="db-form" class="db-form"
|
||||||
|
data-mode="{% if editing.is_some() %}edit{% else %}create{% endif %}"
|
||||||
|
{% if let Some(db) = editing %}data-id="{{ db.id }}"{% endif %}>
|
||||||
|
<label>名稱
|
||||||
|
<input name="name" required maxlength="128" autocomplete="off"
|
||||||
|
value="{% if let Some(db) = editing %}{{ db.name }}{% endif %}">
|
||||||
|
</label>
|
||||||
|
<label>類型
|
||||||
|
<select name="type">
|
||||||
|
<option value="mysql"{% if let Some(db) = editing %}{% if db.db_type.as_str() == "mysql" %} selected{% endif %}{% endif %}>MySQL</option>
|
||||||
|
<option value="postgres"{% if let Some(db) = editing %}{% if db.db_type.as_str() == "postgres" %} selected{% endif %}{% endif %}>PostgreSQL</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>主機
|
||||||
|
<input name="host" required maxlength="255" autocomplete="off" placeholder="如 10.0.0.5 或 db.example.com"
|
||||||
|
value="{% if let Some(db) = editing %}{{ db.host }}{% endif %}">
|
||||||
|
</label>
|
||||||
|
<label>埠
|
||||||
|
<input name="port" type="number" min="1" max="65535" inputmode="numeric"
|
||||||
|
value="{% if let Some(db) = editing %}{{ db.port }}{% endif %}">
|
||||||
|
</label>
|
||||||
|
<label>用戶名
|
||||||
|
<input name="username" required maxlength="64" autocomplete="off"
|
||||||
|
value="{% if let Some(db) = editing %}{{ db.username }}{% endif %}">
|
||||||
|
</label>
|
||||||
|
<label>密碼
|
||||||
|
<input name="password" type="password" maxlength="128" autocomplete="new-password"
|
||||||
|
placeholder="{% if editing.is_some() %}留空保留原密碼{% else %}可留空{% endif %}">
|
||||||
|
</label>
|
||||||
|
<label>資料庫名
|
||||||
|
<input name="database_name" required maxlength="64" autocomplete="off"
|
||||||
|
value="{% if let Some(db) = editing %}{{ db.database_name }}{% endif %}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">儲存</button>
|
||||||
|
<a href="/databases">取消</a>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="grid-column: 1 / -1; margin: 0;">埠留空按類型補默認(MySQL 3306 / PostgreSQL 5432);密碼不會顯示與回填。</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const TYPE_PORTS = { mysql: 3306, postgres: 5432 };
|
||||||
|
|
||||||
|
const form = document.getElementById('db-form');
|
||||||
|
const formError = document.getElementById('form-error');
|
||||||
|
const typeSelect = form.elements['type'];
|
||||||
|
const portInput = form.elements['port'];
|
||||||
|
const passwordInput = form.elements['password'];
|
||||||
|
const editing = form.dataset.mode === 'edit';
|
||||||
|
const editingId = form.dataset.id || null;
|
||||||
|
|
||||||
|
function updatePortHint() {
|
||||||
|
portInput.placeholder = '留空使用默認 ' + TYPE_PORTS[typeSelect.value];
|
||||||
|
}
|
||||||
|
updatePortHint();
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
const response = await fetch(path, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = '請求失敗(HTTP ' + response.status + ')';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.error) { message = body.error; }
|
||||||
|
} catch (_) { /* 非 JSON 錯誤體,保留默認訊息 */ }
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return response.status === 204 ? null : response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
formError.hidden = true;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: form.elements['name'].value.trim(),
|
||||||
|
type: typeSelect.value,
|
||||||
|
host: form.elements['host'].value.trim(),
|
||||||
|
username: form.elements['username'].value.trim(),
|
||||||
|
database_name: form.elements['database_name'].value.trim(),
|
||||||
|
};
|
||||||
|
const port = portInput.value.trim();
|
||||||
|
if (port !== '') { payload.port = Number(port); }
|
||||||
|
// 密碼留空時省略該鍵:新增視為空密碼(後端默認)、編輯保留原值
|
||||||
|
if (passwordInput.value !== '') { payload.password = passwordInput.value; }
|
||||||
|
|
||||||
|
const url = editing ? '/api/databases/' + editingId : '/api/databases';
|
||||||
|
try {
|
||||||
|
await api(url, {
|
||||||
|
method: editing ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
window.location.assign('/databases');
|
||||||
|
} catch (err) {
|
||||||
|
formError.textContent = err.message;
|
||||||
|
formError.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
typeSelect.addEventListener('change', updatePortHint);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
{% extends "_shell.html" %}
|
||||||
|
{% block title %}數據庫連接 — alterdb{% endblock %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.toolbar h2 { margin: 0; }
|
||||||
|
.toolbar a, .db-table .actions a, .db-table .actions button {
|
||||||
|
font: inherit;
|
||||||
|
padding: .35rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.toolbar a:hover, .db-table .actions a:hover, .db-table .actions button:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
}
|
||||||
|
.db-table .actions { white-space: nowrap; }
|
||||||
|
.db-table .actions a, .db-table .actions button {
|
||||||
|
padding: .2rem .6rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
margin-left: .3rem;
|
||||||
|
}
|
||||||
|
.db-table .actions .danger { color: light-dark(#b3261e, #ffb4ab); }
|
||||||
|
.db-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
.db-table th, .db-table td {
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.db-table th { font-weight: 600; }
|
||||||
|
.db-table td.type { text-transform: uppercase; font-size: .8rem; letter-spacing: .04em; }
|
||||||
|
.db-table td.empty { text-align: center; opacity: .7; padding: 1.5rem 0; }
|
||||||
|
.error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: light-dark(#b3261e, #ffb4ab);
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block page %}
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>數據庫連接</h2>
|
||||||
|
<a href="/databases/new">新增連接</a>
|
||||||
|
</div>
|
||||||
|
<p id="list-error" class="error" role="alert" hidden></p>
|
||||||
|
<table class="db-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名稱</th><th>類型</th><th>主機:埠</th><th>資料庫</th><th>用戶</th><th>更新時間</th><th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="db-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const listError = document.getElementById('list-error');
|
||||||
|
const tbody = document.getElementById('db-rows');
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
const response = await fetch(path, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = '請求失敗(HTTP ' + response.status + ')';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.error) { message = body.error; }
|
||||||
|
} catch (_) { /* 非 JSON 錯誤體,保留默認訊息 */ }
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return response.status === 204 ? null : response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
return iso.replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(databases) {
|
||||||
|
const rows = [];
|
||||||
|
if (databases.length === 0) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.colSpan = 7;
|
||||||
|
td.className = 'empty';
|
||||||
|
td.textContent = '尚無連接,點擊「新增連接」建立第一個。';
|
||||||
|
tr.append(td);
|
||||||
|
rows.push(tr);
|
||||||
|
} else {
|
||||||
|
for (const item of databases) { rows.push(renderRow(item)); }
|
||||||
|
}
|
||||||
|
tbody.replaceChildren(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有動態值一律經 textContent 寫入,避免以字串拼接 HTML 引入注入風險
|
||||||
|
function renderRow(item) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
// 名稱連到詳情頁(連接狀態與數據表瀏覽)
|
||||||
|
const nameCell = document.createElement('td');
|
||||||
|
const nameLink = document.createElement('a');
|
||||||
|
nameLink.href = '/databases/' + item.id;
|
||||||
|
nameLink.textContent = item.name;
|
||||||
|
nameCell.append(nameLink);
|
||||||
|
tr.append(nameCell);
|
||||||
|
|
||||||
|
const cells = [
|
||||||
|
{ text: item.type, className: 'type' },
|
||||||
|
{ text: item.host + ':' + item.port },
|
||||||
|
{ text: item.database_name },
|
||||||
|
{ text: item.username },
|
||||||
|
{ text: formatTime(item.updated_at) },
|
||||||
|
];
|
||||||
|
for (const cell of cells) {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.textContent = cell.text;
|
||||||
|
if (cell.className) { td.className = cell.className; }
|
||||||
|
tr.append(td);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('td');
|
||||||
|
actions.className = 'actions';
|
||||||
|
const editLink = document.createElement('a');
|
||||||
|
editLink.href = '/databases/' + item.id + '/edit';
|
||||||
|
editLink.textContent = '編輯';
|
||||||
|
const deleteButton = document.createElement('button');
|
||||||
|
deleteButton.type = 'button';
|
||||||
|
deleteButton.textContent = '刪除';
|
||||||
|
deleteButton.className = 'danger';
|
||||||
|
deleteButton.addEventListener('click', () => removeDatabase(item));
|
||||||
|
actions.append(editLink, deleteButton);
|
||||||
|
tr.append(actions);
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
listError.hidden = true;
|
||||||
|
try {
|
||||||
|
renderRows(await api('/api/databases'));
|
||||||
|
} catch (err) {
|
||||||
|
listError.textContent = err.message;
|
||||||
|
listError.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDatabase(item) {
|
||||||
|
if (!window.confirm('確定刪除連接「' + item.name + '」?')) { return; }
|
||||||
|
try {
|
||||||
|
await api('/api/databases/' + item.id, { method: 'DELETE' });
|
||||||
|
await loadList();
|
||||||
|
} catch (err) {
|
||||||
|
listError.textContent = err.message;
|
||||||
|
listError.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadList();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "_shell.html" %}
|
||||||
|
{% block page %}
|
||||||
|
<p>服務:{{ service }} <code>v{{ version }}</code></p>
|
||||||
|
<p>管理介面:</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/databases">數據庫連接</a> — 管理 MySQL / PostgreSQL 連接</li>
|
||||||
|
<li><a href="/api/health"><code>GET /api/health</code></a> — 健康檢查(含資料庫狀態)</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}登入 — alterdb{% endblock %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.login-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
margin: 3rem auto 0;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.login-card h2 { margin: 0; }
|
||||||
|
.login-card label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.login-card input {
|
||||||
|
font: inherit;
|
||||||
|
padding: .5rem .65rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.login-card button {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: .55rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
.login-card button:hover { background: color-mix(in srgb, currentColor 22%, transparent); }
|
||||||
|
.login-card .error {
|
||||||
|
margin: 0;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: light-dark(#b3261e, #ffb4ab);
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
.login-card .notice {
|
||||||
|
margin: 0;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: light-dark(#146c2e, #6fdd8b);
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
.login-card .hint { font-size: .85rem; opacity: .7; margin: 0; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<header class="site-header">
|
||||||
|
<h1>alterdb</h1>
|
||||||
|
<button type="button" class="theme-toggle" data-theme-toggle>深色模式</button>
|
||||||
|
</header>
|
||||||
|
<form class="login-card" method="post" action="/login">
|
||||||
|
<h2>登入管理介面</h2>
|
||||||
|
{% if let Some(message) = notice %}
|
||||||
|
<p class="notice" role="status">{{ message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if let Some(message) = error %}
|
||||||
|
<p class="error" role="alert">{{ message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<label>用戶名
|
||||||
|
<input type="text" name="username" required autofocus autocomplete="username">
|
||||||
|
</label>
|
||||||
|
<label>密碼
|
||||||
|
<input type="password" name="password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<button type="submit">登入</button>
|
||||||
|
<p class="hint">默認管理員帳號為 <code>admin</code> / <code>admin</code>,建議登入後儘早於側邊欄變更密碼。</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "_shell.html" %}
|
||||||
|
{% block title %}變更密碼 — alterdb{% endblock %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.password-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
padding: 1.6rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.password-card h2 { margin: 0; }
|
||||||
|
.password-card label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.password-card input {
|
||||||
|
font: inherit;
|
||||||
|
padding: .5rem .65rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.password-card button {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: .55rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
.password-card button:hover { background: color-mix(in srgb, currentColor 22%, transparent); }
|
||||||
|
.password-card .error {
|
||||||
|
margin: 0;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: light-dark(#b3261e, #ffb4ab);
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
.password-card .hint { font-size: .85rem; opacity: .7; margin: 0; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block page %}
|
||||||
|
<form class="password-card" method="post" action="/password">
|
||||||
|
<h2>變更密碼</h2>
|
||||||
|
{% if let Some(message) = error %}
|
||||||
|
<p class="error" role="alert">{{ message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<label>原密碼
|
||||||
|
<input type="password" name="current_password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<label>新密碼
|
||||||
|
<input type="password" name="new_password" required minlength="8" maxlength="128" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>確認新密碼
|
||||||
|
<input type="password" name="confirm_password" required minlength="8" maxlength="128" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<button type="submit">更新密碼</button>
|
||||||
|
<p class="hint">新密碼至少 8 個字符;更新成功後所有會話會失效,需以新密碼重新登入。</p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
{% extends "_shell.html" %}
|
||||||
|
{% block title %}數據管理:{{ table }} — alterdb{% endblock %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.toolbar .title { display: flex; align-items: baseline; gap: .7rem; margin: 0; min-width: 0; }
|
||||||
|
.toolbar h2 { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.toolbar .type-badge {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: .1rem .5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar .actions { display: flex; gap: .6rem; }
|
||||||
|
.toolbar .actions a {
|
||||||
|
font: inherit;
|
||||||
|
padding: .35rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar .actions a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
.crumb { margin: 0 0 1.8rem; font-size: .9rem; opacity: .8; }
|
||||||
|
.crumb code { opacity: 1; }
|
||||||
|
section h3 { margin: 0 0 .7rem; }
|
||||||
|
.error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: light-dark(#b3261e, #ffb4ab);
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.note { margin: 0 0 .8rem; font-size: .9rem; opacity: .75; }
|
||||||
|
/* 儲存格過長時橫向滾動,不撐破版面 */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.db-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
.db-table th, .db-table td {
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.db-table tr:last-child td { border-bottom: none; }
|
||||||
|
.db-table th { font-weight: 600; }
|
||||||
|
.db-table td.empty { text-align: center; opacity: .7; padding: 1.5rem 0; }
|
||||||
|
/* 資料儲存格過長時截斷,完整內容以 title 提示呈現 */
|
||||||
|
.db-table td.cell {
|
||||||
|
max-width: 24rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.db-table td.null { opacity: .55; font-style: italic; }
|
||||||
|
.db-table .actions { white-space: nowrap; }
|
||||||
|
.db-table .actions button {
|
||||||
|
font: inherit;
|
||||||
|
padding: .2rem .6rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.db-table .actions button:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
.data-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
margin-bottom: .8rem;
|
||||||
|
}
|
||||||
|
.data-toolbar select, .data-toolbar button, .pager button, #row-form button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: .9rem;
|
||||||
|
padding: .3rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.data-toolbar button:hover, .pager button:hover, #row-form button:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
}
|
||||||
|
.data-toolbar button:disabled, .pager button:disabled, #row-form button:disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: .8rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.pager span { opacity: .8; }
|
||||||
|
/* 編輯資料列的對話框:逐欄輸入 + 可空欄位的 NULL 勾選 */
|
||||||
|
#row-dialog {
|
||||||
|
width: min(44rem, 92vw);
|
||||||
|
padding: 1.2rem 1.4rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
#row-dialog::backdrop { background: rgb(0 0 0 / .45); }
|
||||||
|
#row-dialog h3 { margin: 0 0 .8rem; }
|
||||||
|
#row-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
|
gap: .8rem 1.2rem;
|
||||||
|
margin: .8rem 0;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#row-fields .field { display: flex; flex-direction: column; gap: .25rem; min-width: 0; }
|
||||||
|
#row-fields label { font-size: .85rem; opacity: .75; overflow-wrap: anywhere; }
|
||||||
|
#row-fields input[type="text"] {
|
||||||
|
font: inherit;
|
||||||
|
padding: .35rem .6rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
#row-fields input[type="text"]:disabled { opacity: .5; }
|
||||||
|
#row-fields .null-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
#row-fields .null-line input[type="checkbox"] { font: inherit; }
|
||||||
|
#row-form .hint { margin: 0 0 .8rem; font-size: .8rem; opacity: .65; }
|
||||||
|
#row-form .form-actions { display: flex; gap: .6rem; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block page %}
|
||||||
|
<div id="table-data" data-database-id="{{ database.id }}" data-table="{{ table }}">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="title">
|
||||||
|
<h2>{{ table }}</h2>
|
||||||
|
<span class="type-badge">數據管理</span>
|
||||||
|
<span class="type-badge">{{ database.db_type.as_str() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/databases/{{ database.id }}/tables/{{ table|urlencode_strict }}">結構管理</a>
|
||||||
|
<a href="/databases/{{ database.id }}">返回連接詳情</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="crumb">
|
||||||
|
連接 <a href="/databases/{{ database.id }}">{{ database.name }}</a> ·
|
||||||
|
<code>{{ database.host }}:{{ database.port }}</code> ·
|
||||||
|
資料庫 <code>{{ database.database_name }}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section aria-label="資料">
|
||||||
|
<h3>資料</h3>
|
||||||
|
<p id="no-pk-note" class="note" hidden>此表沒有主鍵,僅能瀏覽資料,無法編輯。</p>
|
||||||
|
<div class="data-toolbar">
|
||||||
|
<select id="page-size" aria-label="每頁列數">
|
||||||
|
<option value="25">25 列 / 頁</option>
|
||||||
|
<option value="50" selected>50 列 / 頁</option>
|
||||||
|
<option value="100">100 列 / 頁</option>
|
||||||
|
<option value="200">200 列 / 頁</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="refresh">刷新</button>
|
||||||
|
</div>
|
||||||
|
<p id="rows-error" class="error" role="alert" hidden></p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="db-table">
|
||||||
|
<thead id="row-head"></thead>
|
||||||
|
<tbody id="row-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pager">
|
||||||
|
<button type="button" id="prev" disabled>上一頁</button>
|
||||||
|
<span id="page-info"></span>
|
||||||
|
<button type="button" id="next" disabled>下一頁</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<dialog id="row-dialog" aria-labelledby="row-dialog-title">
|
||||||
|
<form id="row-form" method="dialog" novalidate>
|
||||||
|
<h3 id="row-dialog-title">編輯資料列</h3>
|
||||||
|
<p id="row-form-error" class="error" role="alert" hidden></p>
|
||||||
|
<div id="row-fields"></div>
|
||||||
|
<p class="hint">僅送出有變更的欄位;勾選 NULL 會將該欄設為 NULL(不可空的欄位無此選項)。</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" id="row-submit">儲存</button>
|
||||||
|
<button type="button" id="row-cancel">取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const root = document.getElementById('table-data');
|
||||||
|
const databaseId = root.dataset.databaseId;
|
||||||
|
const tableName = root.dataset.table;
|
||||||
|
const tableApi = '/api/databases/' + databaseId + '/tables/' + encodeURIComponent(tableName);
|
||||||
|
|
||||||
|
const rowsError = document.getElementById('rows-error');
|
||||||
|
const rowHead = document.getElementById('row-head');
|
||||||
|
const rowBody = document.getElementById('row-body');
|
||||||
|
const pageSize = document.getElementById('page-size');
|
||||||
|
const refreshButton = document.getElementById('refresh');
|
||||||
|
const prevButton = document.getElementById('prev');
|
||||||
|
const nextButton = document.getElementById('next');
|
||||||
|
const pageInfo = document.getElementById('page-info');
|
||||||
|
const noPkNote = document.getElementById('no-pk-note');
|
||||||
|
const rowDialog = document.getElementById('row-dialog');
|
||||||
|
const rowForm = document.getElementById('row-form');
|
||||||
|
const rowFields = document.getElementById('row-fields');
|
||||||
|
const rowFormError = document.getElementById('row-form-error');
|
||||||
|
const rowSubmit = document.getElementById('row-submit');
|
||||||
|
const rowCancel = document.getElementById('row-cancel');
|
||||||
|
|
||||||
|
let limit = Number(pageSize.value) || 50;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// 欄位結構 metadata(名稱 / 類型 / 可空 / 主鍵):編輯表單與主鍵判定都依賴它
|
||||||
|
let columnsMeta = [];
|
||||||
|
let pkColumns = [];
|
||||||
|
// 當前頁的原始資料:編輯時以其主鍵值組 where、以原值比對變更
|
||||||
|
let currentRows = null;
|
||||||
|
let editingIndex = -1;
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
const response = await fetch(path, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = '請求失敗(HTTP ' + response.status + ')';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.error) { message = body.error; }
|
||||||
|
} catch (_) { /* 非 JSON 錯誤體,保留默認訊息 */ }
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return response.status === 204 ? null : response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageRow(colspan, text) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.colSpan = colspan;
|
||||||
|
td.className = 'empty';
|
||||||
|
td.textContent = text;
|
||||||
|
tr.append(td);
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得欄位結構 metadata;失敗不阻斷瀏覽,僅停用編輯能力
|
||||||
|
async function loadColumnsMeta() {
|
||||||
|
try {
|
||||||
|
columnsMeta = await api(tableApi + '/columns');
|
||||||
|
pkColumns = columnsMeta
|
||||||
|
.filter((column) => column.primary_key)
|
||||||
|
.map((column) => column.name);
|
||||||
|
} catch (_) {
|
||||||
|
columnsMeta = [];
|
||||||
|
pkColumns = [];
|
||||||
|
}
|
||||||
|
noPkNote.hidden = pkColumns.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有動態值一律經 textContent 寫入,避免以字串拼接 HTML 引入注入風險
|
||||||
|
function renderRows(data) {
|
||||||
|
currentRows = data;
|
||||||
|
const editable = pkColumns.length > 0;
|
||||||
|
|
||||||
|
const head = document.createElement('tr');
|
||||||
|
for (const name of data.columns) {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.scope = 'col';
|
||||||
|
th.textContent = name;
|
||||||
|
head.append(th);
|
||||||
|
}
|
||||||
|
if (editable) {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.scope = 'col';
|
||||||
|
th.textContent = '操作';
|
||||||
|
head.append(th);
|
||||||
|
}
|
||||||
|
rowHead.replaceChildren(head);
|
||||||
|
|
||||||
|
if (data.rows.length === 0) {
|
||||||
|
rowBody.replaceChildren(
|
||||||
|
messageRow(data.columns.length + (editable ? 1 : 0), '沒有資料。')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const rows = data.rows.map((row, index) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
for (const cell of row) {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
if (cell === null) {
|
||||||
|
td.className = 'null';
|
||||||
|
td.textContent = 'NULL';
|
||||||
|
} else {
|
||||||
|
td.className = 'cell';
|
||||||
|
td.textContent = cell;
|
||||||
|
td.setAttribute('title', cell);
|
||||||
|
}
|
||||||
|
tr.append(td);
|
||||||
|
}
|
||||||
|
if (editable) {
|
||||||
|
const actions = document.createElement('td');
|
||||||
|
actions.className = 'actions';
|
||||||
|
const editButton = document.createElement('button');
|
||||||
|
editButton.type = 'button';
|
||||||
|
editButton.textContent = '編輯';
|
||||||
|
editButton.addEventListener('click', () => openEditor(index));
|
||||||
|
actions.append(editButton);
|
||||||
|
tr.append(actions);
|
||||||
|
}
|
||||||
|
return tr;
|
||||||
|
});
|
||||||
|
rowBody.replaceChildren(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = Math.min(data.offset + data.limit, data.total);
|
||||||
|
pageInfo.textContent = data.total === 0
|
||||||
|
? '沒有資料'
|
||||||
|
: '顯示第 ' + (data.offset + 1) + '–' + to + ' 列,共 ' + data.total + ' 列';
|
||||||
|
prevButton.disabled = data.offset === 0;
|
||||||
|
nextButton.disabled = to >= data.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRows() {
|
||||||
|
rowsError.hidden = true;
|
||||||
|
rowHead.replaceChildren();
|
||||||
|
rowBody.replaceChildren(messageRow(1, '載入中…'));
|
||||||
|
try {
|
||||||
|
const data = await api(tableApi + '/rows?limit=' + limit + '&offset=' + offset);
|
||||||
|
// 目標頁已無資料(總列數變少)→ 跳到最後一頁重取一次
|
||||||
|
if (data.rows.length === 0 && data.offset > 0) {
|
||||||
|
const lastPage = Math.floor(Math.max(0, data.total - 1) / data.limit) * data.limit;
|
||||||
|
if (lastPage < data.offset) {
|
||||||
|
offset = lastPage;
|
||||||
|
await loadRows();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderRows(data);
|
||||||
|
} catch (err) {
|
||||||
|
rowBody.replaceChildren();
|
||||||
|
rowsError.textContent = err.message;
|
||||||
|
rowsError.hidden = false;
|
||||||
|
pageInfo.textContent = '';
|
||||||
|
prevButton.disabled = true;
|
||||||
|
nextButton.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellInputId(index) { return 'cell-input-' + index; }
|
||||||
|
function cellNullId(index) { return 'cell-null-' + index; }
|
||||||
|
|
||||||
|
// 以當前頁第 index 列的原值預填編輯表單:逐欄輸入框,
|
||||||
|
// 可空欄位附 NULL 勾選(勾選時停用輸入框,送出 null)
|
||||||
|
function openEditor(index) {
|
||||||
|
editingIndex = index;
|
||||||
|
const original = currentRows.rows[index];
|
||||||
|
rowFormError.hidden = true;
|
||||||
|
rowFields.replaceChildren();
|
||||||
|
|
||||||
|
columnsMeta.forEach((meta, i) => {
|
||||||
|
const value = original[i] === undefined ? null : original[i];
|
||||||
|
const field = document.createElement('div');
|
||||||
|
field.className = 'field';
|
||||||
|
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.textContent = meta.primary_key ? meta.name + '(主鍵)' : meta.name;
|
||||||
|
label.htmlFor = cellInputId(i);
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.id = cellInputId(i);
|
||||||
|
input.value = value ?? '';
|
||||||
|
input.autocomplete = 'off';
|
||||||
|
|
||||||
|
field.append(label, input);
|
||||||
|
|
||||||
|
if (meta.nullable) {
|
||||||
|
const nullLine = document.createElement('div');
|
||||||
|
nullLine.className = 'null-line';
|
||||||
|
const toggle = document.createElement('input');
|
||||||
|
toggle.type = 'checkbox';
|
||||||
|
toggle.id = cellNullId(i);
|
||||||
|
toggle.checked = value === null;
|
||||||
|
const nullLabel = document.createElement('label');
|
||||||
|
nullLabel.textContent = 'NULL';
|
||||||
|
nullLabel.htmlFor = cellNullId(i);
|
||||||
|
toggle.addEventListener('change', () => {
|
||||||
|
input.disabled = toggle.checked;
|
||||||
|
if (!toggle.checked) { input.focus(); }
|
||||||
|
});
|
||||||
|
input.disabled = toggle.checked;
|
||||||
|
nullLine.append(toggle, nullLabel);
|
||||||
|
field.append(nullLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
rowFields.append(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
rowDialog.showModal();
|
||||||
|
const first = rowFields.querySelector('input[type="text"]:not(:disabled)');
|
||||||
|
if (first) { first.focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
rowForm.addEventListener('submit', async (event) => {
|
||||||
|
// 阻止 method="dialog" 的默認關閉行為:錯誤時保持對話框開啟
|
||||||
|
event.preventDefault();
|
||||||
|
const original = currentRows.rows[editingIndex];
|
||||||
|
const set = {};
|
||||||
|
columnsMeta.forEach((meta, i) => {
|
||||||
|
const input = document.getElementById(cellInputId(i));
|
||||||
|
const nullToggle = document.getElementById(cellNullId(i));
|
||||||
|
const isNull = nullToggle !== null && nullToggle.checked;
|
||||||
|
const newValue = isNull ? null : input.value;
|
||||||
|
const oldValue = original[i] === undefined ? null : original[i];
|
||||||
|
// 未變更的欄位不送出,避免對未觸碰的欄位做不必要的型別轉換
|
||||||
|
if (newValue === oldValue) { return; }
|
||||||
|
set[meta.name] = newValue;
|
||||||
|
});
|
||||||
|
if (Object.keys(set).length === 0) {
|
||||||
|
rowDialog.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以編輯前的主鍵原值定位資料列(主鍵欄位本身被修改時亦然)
|
||||||
|
const where = {};
|
||||||
|
for (const name of pkColumns) {
|
||||||
|
const index = columnsMeta.findIndex((meta) => meta.name === name);
|
||||||
|
where[name] = index === -1 ? null : (original[index] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
rowSubmit.disabled = true;
|
||||||
|
try {
|
||||||
|
await api(tableApi + '/rows', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ where: where, set: set }),
|
||||||
|
});
|
||||||
|
rowDialog.close();
|
||||||
|
loadRows();
|
||||||
|
} catch (err) {
|
||||||
|
rowFormError.textContent = err.message;
|
||||||
|
rowFormError.hidden = false;
|
||||||
|
} finally {
|
||||||
|
rowSubmit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rowCancel.addEventListener('click', () => rowDialog.close());
|
||||||
|
|
||||||
|
pageSize.addEventListener('change', () => {
|
||||||
|
limit = Number(pageSize.value) || 50;
|
||||||
|
offset = 0;
|
||||||
|
loadRows();
|
||||||
|
});
|
||||||
|
// 刷新同時重取欄位結構(他頁可能已變更結構 / 主鍵)
|
||||||
|
refreshButton.addEventListener('click', () => {
|
||||||
|
loadColumnsMeta().then(loadRows);
|
||||||
|
});
|
||||||
|
prevButton.addEventListener('click', () => {
|
||||||
|
offset = Math.max(0, offset - limit);
|
||||||
|
loadRows();
|
||||||
|
});
|
||||||
|
nextButton.addEventListener('click', () => {
|
||||||
|
offset += limit;
|
||||||
|
loadRows();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadColumnsMeta().then(loadRows);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
{% extends "_shell.html" %}
|
||||||
|
{% block title %}數據表:{{ table }} — alterdb{% endblock %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.toolbar .title { display: flex; align-items: baseline; gap: .7rem; margin: 0; min-width: 0; }
|
||||||
|
.toolbar h2 { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.toolbar .type-badge {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: .1rem .5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar .actions { display: flex; gap: .6rem; }
|
||||||
|
.toolbar .actions a {
|
||||||
|
font: inherit;
|
||||||
|
padding: .35rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar .actions a:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
.crumb { margin: 0 0 1.8rem; font-size: .9rem; opacity: .8; }
|
||||||
|
.crumb code { opacity: 1; }
|
||||||
|
section h3 { margin: 0 0 .7rem; }
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: .7rem;
|
||||||
|
}
|
||||||
|
.section-head h3 { margin: 0; }
|
||||||
|
.section-head button, #column-form button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: .9rem;
|
||||||
|
padding: .3rem .9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.section-head button:hover, #column-form button:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
}
|
||||||
|
.section-head button:disabled, #column-form button:disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: light-dark(#b3261e, #ffb4ab);
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
/* 新增欄位的內嵌表單面板 */
|
||||||
|
#column-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
|
gap: .8rem 1.2rem;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
#column-form[hidden] { display: none; }
|
||||||
|
#column-form .field { display: flex; flex-direction: column; gap: .25rem; min-width: 0; }
|
||||||
|
#column-form label { font-size: .85rem; opacity: .75; }
|
||||||
|
#column-form input[type="text"], #column-form input[type="checkbox"] { font: inherit; }
|
||||||
|
#column-form input[type="text"] {
|
||||||
|
padding: .35rem .6rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
#column-form .form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: .6rem;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
#column-form .hint { font-size: .8rem; opacity: .65; }
|
||||||
|
/* 欄位過多時橫向滾動,不撐破版面 */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.db-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
.db-table th, .db-table td {
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.db-table tr:last-child td { border-bottom: none; }
|
||||||
|
.db-table th { font-weight: 600; }
|
||||||
|
.db-table td.empty { text-align: center; opacity: .7; padding: 1.5rem 0; }
|
||||||
|
.db-table td.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: .85em;
|
||||||
|
}
|
||||||
|
.db-table .actions { white-space: nowrap; }
|
||||||
|
.db-table .actions button {
|
||||||
|
font: inherit;
|
||||||
|
padding: .2rem .6rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.db-table .actions button:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||||
|
.db-table .actions .danger { color: light-dark(#b3261e, #ffb4ab); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block page %}
|
||||||
|
<div id="table-detail" data-database-id="{{ database.id }}" data-table="{{ table }}">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="title">
|
||||||
|
<h2>{{ table }}</h2>
|
||||||
|
<span class="type-badge">{{ database.db_type.as_str() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/databases/{{ database.id }}/tables/{{ table|urlencode_strict }}/data">數據管理</a>
|
||||||
|
<a href="/databases/{{ database.id }}">返回連接詳情</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="crumb">
|
||||||
|
連接 <a href="/databases/{{ database.id }}">{{ database.name }}</a> ·
|
||||||
|
<code>{{ database.host }}:{{ database.port }}</code> ·
|
||||||
|
資料庫 <code>{{ database.database_name }}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section aria-label="欄位結構">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>欄位結構</h3>
|
||||||
|
<button type="button" id="add-column">新增欄位</button>
|
||||||
|
</div>
|
||||||
|
<form id="column-form" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<label for="column-name">名稱</label>
|
||||||
|
<input type="text" id="column-name" maxlength="64" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="column-type">類型</label>
|
||||||
|
<input type="text" id="column-type" maxlength="128" required autocomplete="off"
|
||||||
|
placeholder="varchar(255)" list="type-suggestions">
|
||||||
|
<datalist id="type-suggestions">
|
||||||
|
<option value="int"></option>
|
||||||
|
<option value="bigint"></option>
|
||||||
|
<option value="varchar(255)"></option>
|
||||||
|
<option value="text"></option>
|
||||||
|
<option value="decimal(10,2)"></option>
|
||||||
|
<option value="double"></option>
|
||||||
|
<option value="boolean"></option>
|
||||||
|
<option value="date"></option>
|
||||||
|
<option value="datetime"></option>
|
||||||
|
<option value="timestamp"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="column-nullable">可空</label>
|
||||||
|
<input type="checkbox" id="column-nullable" checked>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="column-default">默認值(可留空)</label>
|
||||||
|
<input type="text" id="column-default" maxlength="256" autocomplete="off"
|
||||||
|
placeholder="0、'文字'、CURRENT_TIMESTAMP">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" id="column-submit">新增</button>
|
||||||
|
<button type="button" id="column-cancel">取消</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">類型與默認值以原生 SQL 片段提交;新增會直接修改目標資料庫。</p>
|
||||||
|
</form>
|
||||||
|
<p id="columns-error" class="error" role="alert" hidden></p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="db-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>欄位</th><th>類型</th><th>可空</th><th>默認值</th><th>操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="column-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const root = document.getElementById('table-detail');
|
||||||
|
const databaseId = root.dataset.databaseId;
|
||||||
|
const tableName = root.dataset.table;
|
||||||
|
const tableApi = '/api/databases/' + databaseId + '/tables/' + encodeURIComponent(tableName);
|
||||||
|
|
||||||
|
const columnsError = document.getElementById('columns-error');
|
||||||
|
const columnRows = document.getElementById('column-rows');
|
||||||
|
const addColumnButton = document.getElementById('add-column');
|
||||||
|
const columnForm = document.getElementById('column-form');
|
||||||
|
const columnName = document.getElementById('column-name');
|
||||||
|
const columnType = document.getElementById('column-type');
|
||||||
|
const columnNullable = document.getElementById('column-nullable');
|
||||||
|
const columnDefault = document.getElementById('column-default');
|
||||||
|
const columnSubmit = document.getElementById('column-submit');
|
||||||
|
const columnCancel = document.getElementById('column-cancel');
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
const response = await fetch(path, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = '請求失敗(HTTP ' + response.status + ')';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.error) { message = body.error; }
|
||||||
|
} catch (_) { /* 非 JSON 錯誤體,保留默認訊息 */ }
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return response.status === 204 ? null : response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageRow(colspan, text) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.colSpan = colspan;
|
||||||
|
td.className = 'empty';
|
||||||
|
td.textContent = text;
|
||||||
|
tr.append(td);
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有動態值一律經 textContent 寫入,避免以字串拼接 HTML 引入注入風險
|
||||||
|
function renderColumns(columns) {
|
||||||
|
if (columns.length === 0) {
|
||||||
|
columnRows.replaceChildren(messageRow(5, '找不到此資料表。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = columns.map((column) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const name = document.createElement('td');
|
||||||
|
name.className = 'mono';
|
||||||
|
name.textContent = column.name;
|
||||||
|
const type = document.createElement('td');
|
||||||
|
type.className = 'mono';
|
||||||
|
type.textContent = column.type;
|
||||||
|
const nullable = document.createElement('td');
|
||||||
|
nullable.textContent = column.nullable ? '是' : '否';
|
||||||
|
const def = document.createElement('td');
|
||||||
|
def.className = 'mono';
|
||||||
|
def.textContent = column.default ?? '—';
|
||||||
|
const actions = document.createElement('td');
|
||||||
|
actions.className = 'actions';
|
||||||
|
const dropButton = document.createElement('button');
|
||||||
|
dropButton.type = 'button';
|
||||||
|
dropButton.className = 'danger';
|
||||||
|
dropButton.textContent = '刪除';
|
||||||
|
dropButton.addEventListener('click', () => dropColumn(column.name));
|
||||||
|
actions.append(dropButton);
|
||||||
|
tr.append(name, type, nullable, def, actions);
|
||||||
|
return tr;
|
||||||
|
});
|
||||||
|
columnRows.replaceChildren(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadColumns() {
|
||||||
|
columnsError.hidden = true;
|
||||||
|
columnRows.replaceChildren(messageRow(5, '載入中…'));
|
||||||
|
try {
|
||||||
|
renderColumns(await api(tableApi + '/columns'));
|
||||||
|
} catch (err) {
|
||||||
|
columnRows.replaceChildren();
|
||||||
|
columnsError.textContent = err.message;
|
||||||
|
columnsError.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addColumnButton.addEventListener('click', () => {
|
||||||
|
columnsError.hidden = true;
|
||||||
|
columnForm.hidden = false;
|
||||||
|
columnName.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
columnCancel.addEventListener('click', () => {
|
||||||
|
columnForm.hidden = true;
|
||||||
|
columnForm.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
columnForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
name: columnName.value,
|
||||||
|
type: columnType.value,
|
||||||
|
nullable: columnNullable.checked,
|
||||||
|
};
|
||||||
|
const defaultValue = columnDefault.value.trim();
|
||||||
|
if (defaultValue !== '') { payload.default = defaultValue; }
|
||||||
|
|
||||||
|
columnSubmit.disabled = true;
|
||||||
|
try {
|
||||||
|
await api(tableApi + '/columns', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
columnForm.hidden = true;
|
||||||
|
columnForm.reset();
|
||||||
|
loadColumns();
|
||||||
|
} catch (err) {
|
||||||
|
columnsError.textContent = err.message;
|
||||||
|
columnsError.hidden = false;
|
||||||
|
} finally {
|
||||||
|
columnSubmit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function dropColumn(name) {
|
||||||
|
if (!window.confirm('確定刪除欄位「' + name + '」?該欄位的資料將一併刪除且無法復原。')) { return; }
|
||||||
|
try {
|
||||||
|
await api(tableApi + '/columns/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||||
|
loadColumns();
|
||||||
|
} catch (err) {
|
||||||
|
columnsError.textContent = err.message;
|
||||||
|
columnsError.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadColumns();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user