diff --git a/Server/Cargo.lock b/Server/Cargo.lock index 1179b3d..057ba00 100644 --- a/Server/Cargo.lock +++ b/Server/Cargo.lock @@ -1571,6 +1571,7 @@ name = "server" version = "0.1.0" dependencies = [ "axum", + "base64", "chrono", "jsonwebtoken", "reqwest", diff --git a/Server/Cargo.toml b/Server/Cargo.toml index df0bca5..c63fe13 100644 --- a/Server/Cargo.toml +++ b/Server/Cargo.toml @@ -9,6 +9,7 @@ path = "src/main.rs" [dependencies] axum = "0.8.9" +base64 = "0.22" chrono = "0.4" jsonwebtoken = "9" reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "rustls-tls"] } diff --git a/Server/src/main.rs b/Server/src/main.rs index 6bb94d9..af77245 100644 --- a/Server/src/main.rs +++ b/Server/src/main.rs @@ -6,6 +6,7 @@ mod auth; mod monitor; +mod photos; mod routes; mod state; mod weather; diff --git a/Server/src/photos.rs b/Server/src/photos.rs new file mode 100644 index 0000000..3b9383b --- /dev/null +++ b/Server/src/photos.rs @@ -0,0 +1,261 @@ +//! 照片墙图片存储:按用户隔离到 PHOTO_DIR//,每图随机十六进制文件名。 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use sha2::{Digest, Sha256}; + +pub const MAX_PHOTOS: usize = 10; +pub const MAX_BYTES: usize = 8 * 1024 * 1024; + +/// 保存失败原因(映射到 HTTP 状态码见 routes/web.rs)。 +#[derive(Debug, PartialEq, Eq)] +pub enum SaveError { + Full, + TooLarge, + BadType, + BadUser, + Io, +} + +/// 按用户隔离的图片仓库。内部只是一个根目录路径,`Clone` 廉价。 +#[derive(Clone)] +pub struct PhotoStore { + base: PathBuf, +} + +impl PhotoStore { + /// 根目录取 `PHOTO_DIR`,默认 `photo-wall`(相对进程 cwd)。 + pub fn from_env() -> Self { + let base = std::env::var("PHOTO_DIR").unwrap_or_else(|_| "photo-wall".to_string()); + Self { base: base.into() } + } + + pub fn new(base: impl Into) -> Self { + Self { base: base.into() } + } + + fn user_dir(&self, user: &str) -> Option { + Some(self.base.join(sanitize_user(user)?)) + } + + pub fn list(&self, user: &str) -> Vec { + self.user_dir(user).map(|d| list_in(&d)).unwrap_or_default() + } + + pub fn save(&self, user: &str, data_url: &str) -> Result { + let dir = self.user_dir(user).ok_or(SaveError::BadUser)?; + let (ext, bytes) = parse_data_url(data_url)?; + std::fs::create_dir_all(&dir).map_err(|_| SaveError::Io)?; + if list_in(&dir).len() >= MAX_PHOTOS { + return Err(SaveError::Full); + } + let id = format!("{}.{}", new_stem(), ext); + std::fs::write(dir.join(&id), &bytes).map_err(|_| SaveError::Io)?; + Ok(id) + } + + pub fn read(&self, user: &str, id: &str) -> Option<(&'static str, Vec)> { + if !valid_id(id) { + return None; + } + let dir = self.user_dir(user)?; + let bytes = std::fs::read(dir.join(id)).ok()?; + let ext = id.rsplit_once('.').map(|(_, e)| e)?; + Some((mime_for_ext(ext), bytes)) + } + + 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(id)).is_ok(), + None => false, + } + } +} + +/// 用户名 → 安全目录名:仅允许 `[A-Za-z0-9_-]`、长度 1–64。 +pub fn sanitize_user(user: &str) -> Option { + if user.is_empty() || user.len() > 64 { + return None; + } + if user + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + Some(user.to_string()) + } else { + None + } +} + +/// 图片 id 合法性:`.`,ext 在白名单内;杜绝路径穿越。 +pub fn valid_id(id: &str) -> bool { + let Some((stem, ext)) = id.rsplit_once('.') else { + return false; + }; + matches!(ext, "jpg" | "png" | "gif" | "webp") + && !stem.is_empty() + && stem.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +/// 解析 data URL(`data:image/png;base64,xxxx`)→ (ext, bytes),校验类型与大小。 +pub fn parse_data_url(data_url: &str) -> Result<(&'static str, Vec), SaveError> { + let rest = data_url.strip_prefix("data:").ok_or(SaveError::BadType)?; + let (meta, b64) = rest.split_once(',').ok_or(SaveError::BadType)?; + let mime = meta.split(';').next().unwrap_or(""); + let ext = ext_for_mime(mime).ok_or(SaveError::BadType)?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .map_err(|_| SaveError::BadType)?; + if bytes.len() > MAX_BYTES { + return Err(SaveError::TooLarge); + } + Ok((ext, bytes)) +} + +fn ext_for_mime(mime: &str) -> Option<&'static str> { + match mime { + "image/jpeg" => Some("jpg"), + "image/png" => Some("png"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + _ => None, + } +} + +fn mime_for_ext(ext: &str) -> &'static str { + match ext { + "jpg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + } +} + +fn list_in(dir: &Path) -> Vec { + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for entry in rd.flatten() { + if let Some(name) = entry.file_name().to_str() { + if valid_id(name) { + out.push(name.to_string()); + } + } + } + } + out.sort(); + out +} + +/// 生成 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() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + // 每个测试一个独立临时目录,结束清理(不引入 tempfile 依赖)。 + 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!("pw-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); + } + } + + // 1x1 PNG 的 data URL(合法图片)。 + const PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + + #[test] + fn sanitize_user_accepts_safe_rejects_traversal() { + assert_eq!(sanitize_user("cc"), Some("cc".to_string())); + assert_eq!(sanitize_user("a_b-1"), Some("a_b-1".to_string())); + assert_eq!(sanitize_user("../etc"), None); + assert_eq!(sanitize_user("a/b"), None); + assert_eq!(sanitize_user(""), None); + } + + #[test] + fn valid_id_guards_extension_and_path() { + assert!(valid_id("abc123.jpg")); + assert!(valid_id("ff.webp")); + assert!(!valid_id("abc.exe")); + assert!(!valid_id("../x.jpg")); + assert!(!valid_id("a/b.png")); + assert!(!valid_id("nodot")); + assert!(!valid_id("XYZ.png")); // 非 hex + } + + #[test] + fn parse_data_url_extracts_ext_and_rejects_nonimage() { + let (ext, bytes) = parse_data_url(PNG_DATA_URL).unwrap(); + assert_eq!(ext, "png"); + assert!(!bytes.is_empty()); + assert_eq!(parse_data_url("data:text/plain;base64,aGk="), Err(SaveError::BadType)); + assert_eq!(parse_data_url("not-a-data-url"), Err(SaveError::BadType)); + } + + #[test] + fn save_list_read_delete_round_trip() { + let tmp = TmpDir::new(); + let store = PhotoStore::new(&tmp.0); + assert_eq!(store.list("cc").len(), 0); + + let id = store.save("cc", PNG_DATA_URL).unwrap(); + assert!(valid_id(&id)); + assert_eq!(store.list("cc"), vec![id.clone()]); + + let (mime, bytes) = store.read("cc", &id).unwrap(); + assert_eq!(mime, "image/png"); + assert!(!bytes.is_empty()); + + assert!(store.delete("cc", &id)); + assert_eq!(store.list("cc").len(), 0); + } + + #[test] + fn save_enforces_max_photos() { + let tmp = TmpDir::new(); + let store = PhotoStore::new(&tmp.0); + for _ in 0..MAX_PHOTOS { + store.save("cc", PNG_DATA_URL).unwrap(); + } + assert_eq!(store.save("cc", PNG_DATA_URL), Err(SaveError::Full)); + } + + #[test] + fn read_rejects_bad_id_and_other_users() { + let tmp = TmpDir::new(); + let store = PhotoStore::new(&tmp.0); + let id = store.save("cc", PNG_DATA_URL).unwrap(); + assert!(store.read("cc", "../secret.jpg").is_none()); + assert!(store.read("dave", &id).is_none()); // 不是 dave 的目录 + assert!(!store.delete("cc", "../secret.jpg")); + } +}