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:
parent
1186d80475
commit
3af4893a81
1
Server/Cargo.lock
generated
1
Server/Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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"] }
|
||||||
|
|||||||
@ -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
261
Server/src/photos.rs
Normal 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_-]`、长度 1–64。
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user