feat(edit): P2 条目就地编辑 + P3 备份/审计/改密
- 迁移 v17:entry.updated_at(乐观锁)+ audit 表
- GET/PATCH /api/items/{key}:字段编辑、标签替换、409 冲突、400 校验
- 详情页 [编辑] → Drawer 表单(标题/简介/标签/评价/正文/封面/跳转),保存后局部刷新
- GET /api/backup:VACUUM INTO 一致性快照,附件下载
- /admin 系统 Tab:修改密码(清空会话)、下载备份
- 审计:item.update / settings.update
- 测试 46 passed(entries 4 例)
- 文档:ADMIN-PLAN P2/P3 记录、README
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
//! 条目的读取与编辑(P2)。
|
||||
//!
|
||||
//! 编辑用 `updated_at` 做乐观锁:带上打开表单时的时间戳,
|
||||
//! 别人已经改过就返回冲突,而不是静默覆盖。
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{db::map_error, error::StashError};
|
||||
|
||||
const MAX_NAME: usize = 120;
|
||||
const MAX_BODY: usize = 100_000;
|
||||
const MAX_TAGS: usize = 30;
|
||||
const MAX_TAG_LEN: usize = 40;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct EntryRecord {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
pub href: String,
|
||||
pub cover: String,
|
||||
pub banner: String,
|
||||
pub badge: String,
|
||||
pub comment: String,
|
||||
pub summary: String,
|
||||
pub body: String,
|
||||
pub tags: Vec<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct EntryPatch {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub icon: String,
|
||||
#[serde(default)]
|
||||
pub href: String,
|
||||
#[serde(default)]
|
||||
pub cover: String,
|
||||
#[serde(default)]
|
||||
pub banner: String,
|
||||
#[serde(default)]
|
||||
pub badge: String,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
#[serde(default)]
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
|
||||
if patch.name.trim().is_empty() {
|
||||
return Err(StashError::InvalidDirectory("标题不能为空".into()));
|
||||
}
|
||||
if patch.name.chars().count() > MAX_NAME {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"标题最多 {MAX_NAME} 个字"
|
||||
)));
|
||||
}
|
||||
if patch.body.chars().count() > MAX_BODY {
|
||||
return Err(StashError::InvalidDirectory("正文过长".into()));
|
||||
}
|
||||
if patch.tags.len() > MAX_TAGS {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"标签最多 {MAX_TAGS} 个"
|
||||
)));
|
||||
}
|
||||
for tag in &patch.tags {
|
||||
if tag.chars().count() > MAX_TAG_LEN {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"单个标签最多 {MAX_TAG_LEN} 个字"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读一条条目(编辑表单用),不存在返回 None。
|
||||
pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashError> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT key, name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
|
||||
COALESCE(cover, ''), COALESCE(banner, ''), COALESCE(badge, ''),
|
||||
COALESCE(comment, ''), COALESCE(summary, ''), COALESCE(body, ''), updated_at
|
||||
FROM entry WHERE key = ?1",
|
||||
[key],
|
||||
|row| {
|
||||
Ok(EntryRecord {
|
||||
key: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
icon: row.get(3)?,
|
||||
href: row.get(4)?,
|
||||
cover: row.get(5)?,
|
||||
banner: row.get(6)?,
|
||||
badge: row.get(7)?,
|
||||
comment: row.get(8)?,
|
||||
summary: row.get(9)?,
|
||||
body: row.get(10)?,
|
||||
tags: Vec::new(),
|
||||
updated_at: row.get(11)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some(mut entry) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut statement = conn
|
||||
.prepare("SELECT label FROM entry_tag WHERE entry_key = ?1 ORDER BY sort")
|
||||
.map_err(map_error)?;
|
||||
entry.tags = statement
|
||||
.query_map([key], |row| row.get::<_, String>(0))
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
|
||||
pub fn update(
|
||||
conn: &mut Connection,
|
||||
key: &str,
|
||||
patch: EntryPatch,
|
||||
expected_updated_at: i64,
|
||||
) -> Result<EntryRecord, StashError> {
|
||||
validate(&patch)?;
|
||||
|
||||
let tags: Vec<String> = patch
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.trim().to_string())
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.collect();
|
||||
|
||||
let timestamp = now();
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
|
||||
let updated = tx
|
||||
.execute(
|
||||
"UPDATE entry SET
|
||||
name = ?1, description = ?2, icon = ?3, href = ?4,
|
||||
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
|
||||
summary = ?9, body = ?10, updated_at = ?11
|
||||
WHERE key = ?12 AND updated_at = ?13",
|
||||
params![
|
||||
patch.name.trim(),
|
||||
patch.description,
|
||||
patch.icon,
|
||||
patch.href,
|
||||
patch.cover,
|
||||
patch.banner,
|
||||
patch.badge,
|
||||
patch.comment,
|
||||
patch.summary,
|
||||
patch.body,
|
||||
timestamp,
|
||||
key,
|
||||
expected_updated_at
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
if updated == 0 {
|
||||
return Err(StashError::Conflict(
|
||||
"条目已被修改,请刷新后重试".into(),
|
||||
));
|
||||
}
|
||||
|
||||
tx.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
|
||||
.map_err(map_error)?;
|
||||
for (index, tag) in tags.iter().enumerate() {
|
||||
tx.execute(
|
||||
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
|
||||
params![key, index as i64, tag],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.update', ?2, ?3)",
|
||||
params![timestamp, key, patch.name.trim()],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
tx.commit().map_err(map_error)?;
|
||||
|
||||
load(conn, key)?
|
||||
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
|
||||
}
|
||||
|
||||
/// 记录一条审计(设置保存等)。
|
||||
pub fn audit(conn: &Connection, action: &str, target: &str, detail: &str) -> Result<(), StashError> {
|
||||
conn.execute(
|
||||
"INSERT INTO audit (at, action, target, detail) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![now(), action, target, detail],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE entry (
|
||||
key TEXT PRIMARY KEY,
|
||||
node_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT, icon TEXT, href TEXT, cover TEXT, banner TEXT,
|
||||
badge TEXT, comment TEXT, summary TEXT, body TEXT,
|
||||
hot INTEGER NOT NULL DEFAULT 0, views INTEGER NOT NULL DEFAULT 0,
|
||||
likes INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE entry_tag (
|
||||
entry_key TEXT NOT NULL,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT NOT NULL,
|
||||
PRIMARY KEY (entry_key, sort)
|
||||
);
|
||||
CREATE TABLE audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);",
|
||||
)
|
||||
.expect("应能建表");
|
||||
conn
|
||||
}
|
||||
|
||||
fn patch(name: &str) -> EntryPatch {
|
||||
EntryPatch {
|
||||
name: name.into(),
|
||||
description: "简介".into(),
|
||||
icon: String::new(),
|
||||
href: "https://example.com".into(),
|
||||
cover: String::new(),
|
||||
banner: String::new(),
|
||||
badge: "推荐".into(),
|
||||
comment: "一句话".into(),
|
||||
summary: "正文".into(),
|
||||
body: "## 标题".into(),
|
||||
tags: vec!["推荐电影".into(), "动画".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_replaces_fields_and_tags() {
|
||||
let mut conn = db();
|
||||
let saved = update(&mut conn, "movie-1", patch("千与千寻 2"), 0).expect("应能更新");
|
||||
assert_eq!(saved.name, "千与千寻 2");
|
||||
assert_eq!(saved.tags, vec!["推荐电影".to_string(), "动画".to_string()]);
|
||||
assert!(saved.updated_at > 0);
|
||||
|
||||
let audit: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM audit", [], |row| row.get(0))
|
||||
.expect("应能查审计");
|
||||
assert_eq!(audit, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_timestamp_conflicts() {
|
||||
let mut conn = db();
|
||||
let saved = update(&mut conn, "movie-1", patch("A"), 0).expect("应能更新");
|
||||
let error = update(&mut conn, "movie-1", patch("B"), saved.updated_at - 1)
|
||||
.expect_err("应冲突");
|
||||
assert!(matches!(error, StashError::Conflict(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_name() {
|
||||
let mut conn = db();
|
||||
assert!(update(&mut conn, "movie-1", patch(" "), 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_entry_is_not_found() {
|
||||
let mut conn = db();
|
||||
let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在");
|
||||
assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_)));
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod auth;
|
||||
mod catalog;
|
||||
pub mod entries;
|
||||
mod schema;
|
||||
pub mod settings;
|
||||
|
||||
@@ -27,6 +28,8 @@ pub use schema::SeedReport;
|
||||
pub use auth::SessionState;
|
||||
/// 站点设置:页头滚动信息 + 侧栏顺序/显隐。
|
||||
pub use settings::{Settings, SettingsDocument};
|
||||
/// 条目编辑(P2)。
|
||||
pub use entries::{EntryPatch, EntryRecord};
|
||||
|
||||
/// 一次查询得到的完整内容目录。
|
||||
///
|
||||
@@ -169,7 +172,26 @@ impl Db {
|
||||
expected_version: i64,
|
||||
) -> Result<SettingsDocument, StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
settings::save(&mut conn, next, expected_version)
|
||||
let document = settings::save(&mut conn, next, expected_version)?;
|
||||
entries::audit(&conn, "settings.update", "site", "站点设置")?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
// ---------- 条目编辑 ----------
|
||||
|
||||
pub fn load_entry(&self, key: &str) -> Result<Option<EntryRecord>, StashError> {
|
||||
let conn = self.lock()?;
|
||||
entries::load(&conn, key)
|
||||
}
|
||||
|
||||
pub fn update_entry(
|
||||
&self,
|
||||
key: &str,
|
||||
patch: EntryPatch,
|
||||
expected_updated_at: i64,
|
||||
) -> Result<EntryRecord, StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
entries::update(&mut conn, key, patch, expected_updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +540,7 @@ mod tests {
|
||||
fresh
|
||||
.execute_batch(
|
||||
"CREATE TABLE node (key TEXT PRIMARY KEY);
|
||||
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
|
||||
PRAGMA user_version = 15;",
|
||||
)
|
||||
.expect("应能造出 v15 老库");
|
||||
|
||||
+32
-6
@@ -75,7 +75,8 @@ CREATE TABLE IF NOT EXISTS entry (
|
||||
hot INTEGER NOT NULL DEFAULT 0 CHECK (hot IN (0, 1)),-- 是否热门:列表里显示 HOT 徽标
|
||||
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
|
||||
likes INTEGER NOT NULL DEFAULT 0, -- 点赞数,详情页操作条「赞」后的数字
|
||||
sort INTEGER NOT NULL DEFAULT 0 -- 条目顺序(组内或分区内),越小越靠前
|
||||
sort INTEGER NOT NULL DEFAULT 0, -- 条目顺序(组内或分区内),越小越靠前
|
||||
updated_at INTEGER NOT NULL DEFAULT 0 -- 最近一次编辑时间(unix 秒),0 = 从未编辑
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
|
||||
@@ -128,6 +129,29 @@ CREATE TABLE IF NOT EXISTS setting (
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- ========== v17:审计 ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
"#;
|
||||
|
||||
/// v16 → v17:条目加 `updated_at`(乐观锁),新增审计表。
|
||||
const V17_SQL: &str = r#"
|
||||
ALTER TABLE entry ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
"#;
|
||||
|
||||
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
|
||||
@@ -171,7 +195,7 @@ pub struct SeedReport {
|
||||
///
|
||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||
pub const SCHEMA_VERSION: i64 = 16;
|
||||
pub const SCHEMA_VERSION: i64 = 17;
|
||||
|
||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
@@ -180,10 +204,7 @@ fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
|
||||
/// 把库升到 `SCHEMA_VERSION`,返回升级后的版本。
|
||||
///
|
||||
/// - 0:全新库,建全量结构;
|
||||
/// - 15:补 v16 的登录/设置表;
|
||||
/// - 16:已是最新;
|
||||
/// - 其它:拒绝启动(不猜结构)。
|
||||
/// 逐级迁移:0 建全量;15 → 16 → 17;已最新直接返回;更高版本拒绝。
|
||||
pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||||
let mut version = current_version(conn)?;
|
||||
|
||||
@@ -213,6 +234,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||||
version = 16;
|
||||
}
|
||||
|
||||
if version == 16 {
|
||||
conn.execute_batch(V17_SQL).map_err(map_error)?;
|
||||
version = 17;
|
||||
}
|
||||
|
||||
conn.pragma_update(None, "user_version", version)
|
||||
.map_err(map_error)?;
|
||||
|
||||
|
||||
+108
@@ -581,6 +581,112 @@ async fn patch_settings(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 条目编辑 / 备份 ----------------
|
||||
|
||||
/// GET /api/items/{key} —— 编辑表单的初始值(公开,便于未登录时预览)
|
||||
async fn get_item(State(state): State<AppState>, Path(key): Path<String>) -> Response {
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.load_entry(&key)).await {
|
||||
Ok(Ok(Some(entry))) => Json(entry).into_response(),
|
||||
Ok(Ok(None)) => api_error(StatusCode::NOT_FOUND, "条目不存在"),
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ItemPatchBody {
|
||||
#[serde(rename = "updatedAt")]
|
||||
updated_at: i64,
|
||||
item: crate::db::EntryPatch,
|
||||
}
|
||||
|
||||
/// PATCH /api/items/{key} —— 需要登录,`updatedAt` 乐观锁
|
||||
async fn patch_item(
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<ItemPatchBody>,
|
||||
) -> Response {
|
||||
if !same_origin(&headers) {
|
||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
||||
}
|
||||
|
||||
match read_session(&state, &headers).await {
|
||||
Ok(session) if session.authenticated => {}
|
||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
||||
Err(response) => return response,
|
||||
}
|
||||
|
||||
let db = Arc::clone(&state.db);
|
||||
let (patch, updated_at) = (body.item, body.updated_at);
|
||||
match tokio::task::spawn_blocking(move || db.update_entry(&key, patch, updated_at)).await {
|
||||
Ok(Ok(entry)) => Json(entry).into_response(),
|
||||
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
|
||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
||||
api_error(StatusCode::BAD_REQUEST, &message)
|
||||
}
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/backup —— 下载整库快照(需要登录)。
|
||||
///
|
||||
/// 用 SQLite 的 `VACUUM INTO` 生成一致性副本,避免直接复制正在写的文件。
|
||||
async fn backup_db(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
match read_session(&state, &headers).await {
|
||||
Ok(session) if session.authenticated => {}
|
||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
||||
Err(response) => return response,
|
||||
}
|
||||
|
||||
let db_path = state.config.db_path.clone();
|
||||
let created = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let target = db_path.with_file_name(format!(
|
||||
"stash-backup-{}.db",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
|
||||
let conn = rusqlite::Connection::open(&db_path).map_err(|error| error.to_string())?;
|
||||
conn.execute("VACUUM INTO ?1", [target.to_string_lossy().as_ref()])
|
||||
.map_err(|error| error.to_string())?;
|
||||
let bytes = std::fs::read(&target).map_err(|error| error.to_string())?;
|
||||
let _ = std::fs::remove_file(&target);
|
||||
Ok(bytes)
|
||||
})
|
||||
.await;
|
||||
|
||||
match created {
|
||||
Ok(Ok(bytes)) => {
|
||||
let filename = format!(
|
||||
"stash-backup-{}.db",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/octet-stream".to_string()),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装路由:API + 内嵌前端 + 托管目录。
|
||||
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
||||
/// 起监听任务,没法在单元测试里用。
|
||||
@@ -607,6 +713,8 @@ fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/logout", post(auth_logout))
|
||||
.route("/api/auth/password", patch(auth_password))
|
||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||
.route("/api/items/{key}", get(get_item).patch(patch_item))
|
||||
.route("/api/backup", get(backup_db))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
.route("/favicon.svg", get(web_root_file))
|
||||
|
||||
Reference in New Issue
Block a user