This commit is contained in:
2026-10-02 13:12:49 +08:00
parent 2d4bdd0f85
commit a8ab1dfcc6
38 changed files with 10385 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
# 建置產物與本機環境
target/
.git/
.zcode/
**/.DS_Store
# 本機 SQLite 數據檔(容器應使用乾淨資料庫或掛載 volume)
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
# 其他非建置所需
README.md
.gitignore
.dockerignore
+6
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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
View File
@@ -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"]
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1300
View File
File diff suppressed because it is too large Load Diff
+203
View File
@@ -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);
}
}
+702
View File
@@ -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);
}
}
+791
View File
@@ -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);
}
}
+53
View File
@@ -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))
}
+370
View File
@@ -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);
}
}
+710
View File
@@ -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
View File
@@ -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("伺服器運行錯誤");
}
+212
View File
@@ -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")));
}
}
+336
View File
@@ -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());
}
}
+7
View File
@@ -0,0 +1,7 @@
//! 模型層:定義對外的數據結構與輸入驗證,不含存儲細節。
//!
//! 每個模型一個子模組(如 [`account`]);對應的 SQL 與持久化
//! 實作放在 [`crate::storage`] 的同名子模組中。
pub mod account;
pub mod database;
+257
View File
@@ -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"));
}
}
+11
View File
@@ -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,
}
+473
View File
@@ -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);
}
}
+345
View File
@@ -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);
}
}
+125
View File
@@ -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()));
}
}
}
+47
View File
@@ -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 %}
+136
View File
@@ -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 %}
+98
View File
@@ -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>
+252
View File
@@ -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 %}
+176
View File
@@ -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 %}
+178
View File
@@ -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 %}
+9
View File
@@ -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 %}
+82
View File
@@ -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 %}
+68
View File
@@ -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 %}
+501
View File
@@ -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 %}
+355
View File
@@ -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 %}