feat(blog): PostStore 文件存储 + 单测(按用户隔离 CRUD)

Post 加 derive(PartialEq, Eq) 以支持测试里对 Result<Post,_> 的 assert_eq。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-27 16:46:40 +08:00
parent c2016a8ea5
commit 9d113b72f8
2 changed files with 321 additions and 0 deletions

View File

@ -8,6 +8,7 @@ mod auth;
mod folders;
mod monitor;
mod photos;
mod posts;
mod routes;
mod state;
mod weather;

320
Server/src/posts.rs Normal file
View File

@ -0,0 +1,320 @@
//! 个人博客文章存储:按用户隔离到 BLOG_DIR/<user>/,每篇一个 <hex>.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<std::sync::Mutex<()>>,
}
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<PathBuf>) -> Self {
Self {
base: base.into(),
lock: std::sync::Arc::new(std::sync::Mutex::new(())),
}
}
fn user_dir(&self, user: &str) -> Option<PathBuf> {
Some(self.base.join(sanitize_user(user)?))
}
pub fn list(&self, user: &str) -> Vec<Post> {
let Some(dir) = self.user_dir(user) else {
return Vec::new();
};
let mut out: Vec<Post> = 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<Post> {
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<Post, PostError> {
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<Post, PostError> {
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<PostDraft, PostError> {
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<PathBuf> {
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<Post> {
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"));
}
}