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:
parent
c2016a8ea5
commit
9d113b72f8
@ -8,6 +8,7 @@ mod auth;
|
|||||||
mod folders;
|
mod folders;
|
||||||
mod monitor;
|
mod monitor;
|
||||||
mod photos;
|
mod photos;
|
||||||
|
mod posts;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod state;
|
mod state;
|
||||||
mod weather;
|
mod weather;
|
||||||
|
|||||||
320
Server/src/posts.rs
Normal file
320
Server/src/posts.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user