diff --git a/Server/src/main.rs b/Server/src/main.rs index d4df461..28c7808 100644 --- a/Server/src/main.rs +++ b/Server/src/main.rs @@ -8,6 +8,7 @@ mod auth; mod folders; mod monitor; mod photos; +mod posts; mod routes; mod state; mod weather; diff --git a/Server/src/posts.rs b/Server/src/posts.rs new file mode 100644 index 0000000..44abb59 --- /dev/null +++ b/Server/src/posts.rs @@ -0,0 +1,320 @@ +//! 个人博客文章存储:按用户隔离到 BLOG_DIR//,每篇一个 .json。 +//! 仿 photos.rs(廉价 Clone、串行化 计数→写 临界区)。 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::photos::sanitize_user; + +pub const MAX_POSTS: usize = 200; +pub const MAX_BODY: usize = 64 * 1024; +pub const MAX_TITLE: usize = 200; +pub const MAX_TAG: usize = 40; + +#[derive(Debug, PartialEq, Eq)] +pub enum PostError { + Full, + TooLarge, + BadInput, + BadUser, + NotFound, + Io, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Post { + pub id: String, + pub title: String, + pub tag: String, + pub body: String, + #[serde(rename = "createdAt")] + pub created_at: u64, + #[serde(rename = "updatedAt")] + pub updated_at: u64, +} + +pub struct PostDraft { + pub title: String, + pub tag: String, + pub body: String, +} + +#[derive(Clone)] +pub struct PostStore { + base: PathBuf, + lock: std::sync::Arc>, +} + +impl PostStore { + pub fn from_env() -> Self { + let base = std::env::var("BLOG_DIR").unwrap_or_else(|_| "blog".to_string()); + Self::new(base) + } + + pub fn new(base: impl Into) -> Self { + Self { + base: base.into(), + lock: std::sync::Arc::new(std::sync::Mutex::new(())), + } + } + + fn user_dir(&self, user: &str) -> Option { + Some(self.base.join(sanitize_user(user)?)) + } + + pub fn list(&self, user: &str) -> Vec { + let Some(dir) = self.user_dir(user) else { + return Vec::new(); + }; + let mut out: Vec = json_files(&dir) + .iter() + .filter_map(|p| read_one(p)) + .collect(); + out.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + out + } + + pub fn read(&self, user: &str, id: &str) -> Option { + if !valid_id(id) { + return None; + } + let dir = self.user_dir(user)?; + read_one(&dir.join(format!("{id}.json"))) + } + + pub fn create(&self, user: &str, draft: PostDraft) -> Result { + let draft = validate(draft)?; + let dir = self.user_dir(user).ok_or(PostError::BadUser)?; + // 串行化 计数→写:防止并发创建越过 MAX_POSTS。 + let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner()); + if json_files(&dir).len() >= MAX_POSTS { + return Err(PostError::Full); + } + std::fs::create_dir_all(&dir).map_err(|_| PostError::Io)?; + let now = now_secs(); + let id = new_stem(); + let post = Post { + id, + title: draft.title, + tag: draft.tag, + body: draft.body, + created_at: now, + updated_at: now, + }; + write_one(&dir.join(format!("{}.json", post.id)), &post)?; + Ok(post) + } + + pub fn update(&self, user: &str, id: &str, draft: PostDraft) -> Result { + if !valid_id(id) { + return Err(PostError::NotFound); + } + let draft = validate(draft)?; + let dir = self.user_dir(user).ok_or(PostError::BadUser)?; + let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner()); + let path = dir.join(format!("{id}.json")); + let mut post = read_one(&path).ok_or(PostError::NotFound)?; + post.title = draft.title; + post.tag = draft.tag; + post.body = draft.body; + post.updated_at = now_secs(); + write_one(&path, &post)?; + Ok(post) + } + + pub fn delete(&self, user: &str, id: &str) -> bool { + if !valid_id(id) { + return false; + } + match self.user_dir(user) { + Some(dir) => std::fs::remove_file(dir.join(format!("{id}.json"))).is_ok(), + None => false, + } + } +} + +/// 校验并归一化草稿(trim 标题)。 +fn validate(draft: PostDraft) -> Result { + let title = draft.title.trim().to_string(); + if title.is_empty() || title.chars().count() > MAX_TITLE { + return Err(PostError::BadInput); + } + if draft.tag.chars().count() > MAX_TAG { + return Err(PostError::BadInput); + } + if draft.body.len() > MAX_BODY { + return Err(PostError::TooLarge); + } + Ok(PostDraft { title, tag: draft.tag, body: draft.body }) +} + +/// 目录下所有 `*.json` 文件路径(目录不存在 → 空)。 +fn json_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for entry in rd.flatten() { + let p = entry.path(); + if p.extension().and_then(|x| x.to_str()) == Some("json") { + out.push(p); + } + } + } + out +} + +fn read_one(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn write_one(path: &Path, post: &Post) -> Result<(), PostError> { + let bytes = serde_json::to_vec(post).map_err(|_| PostError::Io)?; + std::fs::write(path, bytes).map_err(|_| PostError::Io) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// 16 位 hex 文件名干(纳秒 + 进程内计数器哈希;读取已鉴权,无需密码学随机)。 +fn new_stem() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let digest = Sha256::digest(format!("{nanos}-{n}").as_bytes()); + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} + +/// 文章 id 合法性:非空、全小写 hex、长度 ≤ 32;杜绝路径穿越。 +pub fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 32 + && id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TmpDir(PathBuf); + impl TmpDir { + fn new() -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("blog-test-{nanos}")); + std::fs::create_dir_all(&p).unwrap(); + TmpDir(p) + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn draft(title: &str, body: &str) -> PostDraft { + PostDraft { title: title.into(), tag: "Note".into(), body: body.into() } + } + + #[test] + fn valid_id_guards_hex_and_path() { + assert!(valid_id("abc123")); + assert!(!valid_id("ABC123")); // 非小写 + assert!(!valid_id("../x")); + assert!(!valid_id("a.b")); + assert!(!valid_id("")); + } + + #[test] + fn create_list_read_update_delete_round_trip() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + assert_eq!(store.list("cc").len(), 0); + + let p = store.create("cc", draft("Hello", "world body")).unwrap(); + assert!(valid_id(&p.id)); + assert_eq!(p.title, "Hello"); + assert_eq!(p.created_at, p.updated_at); + + let listed = store.list("cc"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, p.id); + + let got = store.read("cc", &p.id).unwrap(); + assert_eq!(got.body, "world body"); + + let updated = store + .update("cc", &p.id, draft("Hello2", "new body")) + .unwrap(); + assert_eq!(updated.title, "Hello2"); + assert_eq!(updated.created_at, p.created_at); // 创建时间不变 + assert!(updated.updated_at >= p.created_at); + + assert!(store.delete("cc", &p.id)); + assert_eq!(store.list("cc").len(), 0); + } + + #[test] + fn list_is_newest_first() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + let a = store.create("cc", draft("A", "a")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + let b = store.create("cc", draft("B", "b")).unwrap(); + let listed = store.list("cc"); + assert_eq!(listed[0].id, b.id); + assert_eq!(listed[1].id, a.id); + } + + #[test] + fn rejects_bad_input() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + assert_eq!(store.create("cc", draft(" ", "body")), Err(PostError::BadInput)); + let long_title: String = "x".repeat(MAX_TITLE + 1); + assert_eq!(store.create("cc", draft(&long_title, "b")), Err(PostError::BadInput)); + let big_body: String = "x".repeat(MAX_BODY + 1); + assert_eq!(store.create("cc", draft("ok", &big_body)), Err(PostError::TooLarge)); + } + + #[test] + fn enforces_max_posts() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + for i in 0..MAX_POSTS { + store.create("cc", draft(&format!("t{i}"), "b")).unwrap(); + } + assert_eq!(store.create("cc", draft("over", "b")), Err(PostError::Full)); + } + + #[test] + fn isolates_users() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + let p = store.create("cc", draft("mine", "b")).unwrap(); + assert!(store.read("dave", &p.id).is_none()); + assert_eq!(store.list("dave").len(), 0); + assert!(!store.delete("dave", &p.id)); + } + + #[test] + fn read_and_update_reject_bad_id() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + assert!(store.read("cc", "../secret").is_none()); + assert_eq!(store.update("cc", "../secret", draft("x", "y")), Err(PostError::NotFound)); + assert!(!store.delete("cc", "../secret")); + } +}