diff --git a/README.md b/README.md index dde5f0e..5ac7d28 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 - 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位); - 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐; +- 详情页的 `[编辑]` 打开右侧 Drawer,可改标题 / 简介 / 标签 / 正文等(保存后局部刷新,`updated_at` 乐观锁); +- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照); - 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天); - 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录; - 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」; diff --git a/docs/ADMIN-PLAN.md b/docs/ADMIN-PLAN.md index 0d52f22..192285b 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -167,4 +167,22 @@ DELETE /api/items/:id - 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染; - 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。 +### P2(已完成,2026-10-29) + +- 库新增 `Textarea`(stash-ui v0.2.0):`rows` / `resize` / `invalid`; +- 条目编辑:`GET /api/items/{key}`(公开读)、`PATCH /api/items/{key}`(需登录); +- 乐观锁:`entry.updated_at`(迁移 v17),冲突返回 409; +- 可编辑字段:标题、简介、标签、一句话评价、正文 Markdown、封面、跳转地址; +- 前端:详情页 `[编辑]` → `Drawer` 表单;保存后 `catalog.reload()` 局部刷新,不整页刷新; +- 审计:每次条目更新写入 `audit` 表; +- 测试:46 passed(新增 entries 4 例:更新/标签替换、冲突、空标题、不存在)。 + +### P3(部分完成,2026-10-29) + +- 审计表 `audit`:记录 `item.update`、`settings.update`; +- 备份:`GET /api/backup`(需登录)用 `VACUUM INTO` 生成一致性副本并以附件下载; +- 系统设置 Tab:修改密码(成功后所有会话失效)、下载备份; +- **未做**:恢复/导入(需要停机替换文件或在线校验导入流程)、回收站/软删除、图片上传、拖拽排序(当前用上移/下移按钮替代)。 + + diff --git a/src/db/entries.rs b/src/db/entries.rs new file mode 100644 index 0000000..9ace88b --- /dev/null +++ b/src/db/entries.rs @@ -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, + 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, +} + +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, 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::, _>>() + .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 { + validate(&patch)?; + + let tags: Vec = 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(_))); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index c136cec..4e5df82 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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 { 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, StashError> { + let conn = self.lock()?; + entries::load(&conn, key) + } + + pub fn update_entry( + &self, + key: &str, + patch: EntryPatch, + expected_updated_at: i64, + ) -> Result { + 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 老库"); diff --git a/src/db/schema.rs b/src/db/schema.rs index ef4fc1a..8a4d3cb 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -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 { conn.pragma_query_value(None, "user_version", |row| row.get(0)) @@ -180,10 +204,7 @@ fn current_version(conn: &Connection) -> Result { /// 把库升到 `SCHEMA_VERSION`,返回升级后的版本。 /// -/// - 0:全新库,建全量结构; -/// - 15:补 v16 的登录/设置表; -/// - 16:已是最新; -/// - 其它:拒绝启动(不猜结构)。 +/// 逐级迁移:0 建全量;15 → 16 → 17;已最新直接返回;更高版本拒绝。 pub fn migrate(conn: &Connection) -> Result { let mut version = current_version(conn)?; @@ -213,6 +234,11 @@ pub fn migrate(conn: &Connection) -> Result { 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)?; diff --git a/src/server.rs b/src/server.rs index 318b3cb..24ef2b2 100644 --- a/src/server.rs +++ b/src/server.rs @@ -581,6 +581,112 @@ async fn patch_settings( } } +// ---------------- 条目编辑 / 备份 ---------------- + +/// GET /api/items/{key} —— 编辑表单的初始值(公开,便于未登录时预览) +async fn get_item(State(state): State, Path(key): Path) -> 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, + Path(key): Path, + headers: HeaderMap, + Json(body): Json, +) -> 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, 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, 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)) diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 7b80ef7..1f159b1 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -36,6 +36,7 @@ import { type SettingsDocument, } from './settings' import { LoginDialog } from './components/auth/LoginDialog' +import { ItemEditor } from './components/content/ItemEditor' import { parseViewPath, viewPath, type View } from './data/view' import './App.css' @@ -52,6 +53,7 @@ export default function App() { settings: defaultSettings(), }) const [loginOpen, setLoginOpen] = useState(false) + const [editingKey, setEditingKey] = useState(null) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) @@ -253,6 +255,7 @@ export default function App() { categoryId={itemCategory.id} categoryTitle={itemCategory.title} onNavigate={navigateHref} + onEdit={() => setEditingKey(itemView.itemId)} /> ) } @@ -272,6 +275,7 @@ export default function App() { related={relatedItems} onNavigate={navigateHref} onOpenItem={(itemId) => openItem(itemCategory.id, itemId)} + onEdit={() => setEditingKey(itemView.itemId)} /> ) } @@ -295,6 +299,13 @@ export default function App() { }} /> + setEditingKey(null)} + onSaved={() => catalog.reload()} + /> + {/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}