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:
+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