feat(photos): per-user image store with size/count/path-safety guards

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-24 21:03:04 +08:00
parent 1186d80475
commit 3af4893a81
4 changed files with 264 additions and 0 deletions

1
Server/Cargo.lock generated
View File

@ -1571,6 +1571,7 @@ name = "server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64",
"chrono", "chrono",
"jsonwebtoken", "jsonwebtoken",
"reqwest", "reqwest",

View File

@ -9,6 +9,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
axum = "0.8.9" axum = "0.8.9"
base64 = "0.22"
chrono = "0.4" chrono = "0.4"
jsonwebtoken = "9" jsonwebtoken = "9"
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "rustls-tls"] }

View File

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

261
Server/src/photos.rs Normal file
View File

@ -0,0 +1,261 @@
//! 照片墙图片存储:按用户隔离到 PHOTO_DIR/<user>/,每图随机十六进制文件名。
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<PathBuf>) -> Self {
Self { base: base.into() }
}
fn user_dir(&self, user: &str) -> Option<PathBuf> {
Some(self.base.join(sanitize_user(user)?))
}
pub fn list(&self, user: &str) -> Vec<String> {
self.user_dir(user).map(|d| list_in(&d)).unwrap_or_default()
}
pub fn save(&self, user: &str, data_url: &str) -> Result<String, SaveError> {
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<u8>)> {
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_-]`、长度 164。
pub fn sanitize_user(user: &str) -> Option<String> {
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 合法性:`<hex>.<ext>`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<u8>), 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<String> {
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"));
}
}