From e8f3f1674d445ed20eed7c45990c9200bbbbe4de Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 24 Jun 2026 21:12:14 +0800 Subject: [PATCH] feat(web): per-user photo endpoints (list/upload/get/delete) behind JWT Co-Authored-By: Claude Opus 4.8 --- Server/src/routes/web.rs | 85 +++++++++++++++++++++++++++++++++++++++- Server/src/state.rs | 4 ++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/Server/src/routes/web.rs b/Server/src/routes/web.rs index 0d89db2..65b8ee4 100644 --- a/Server/src/routes/web.rs +++ b/Server/src/routes/web.rs @@ -8,10 +8,15 @@ use axum::{ http::{HeaderMap, Method, StatusCode, header}, routing::{get, post}, }; +use axum::extract::{DefaultBodyLimit, Path as AxPath}; +use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tower_http::cors::{AllowOrigin, CorsLayer}; +use crate::photos::{SaveError, MAX_PHOTOS}; +use crate::routes::extract::AuthUser; + use crate::{ monitor::Stats, state::AppState, @@ -29,14 +34,17 @@ pub fn router(state: AppState) -> Router { "http://localhost:5174".parse().unwrap(), "http://127.0.0.1:5174".parse().unwrap(), ])) - .allow_methods([Method::GET, Method::POST]) - .allow_headers([header::CONTENT_TYPE]); + .allow_methods([Method::GET, Method::POST, Method::DELETE]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]); Router::new() .route("/api/web/stats", get(stats)) .route("/api/web/gate", post(gate)) .route("/api/web/login", post(login)) .route("/api/web/weather", get(weather)) + .route("/api/web/photos", get(list_photos).post(upload_photo)) + .route("/api/web/photos/{id}", get(get_photo).delete(delete_photo)) + .layer(DefaultBodyLimit::max(16 * 1024 * 1024)) .layer(cors) .with_state(state) } @@ -178,3 +186,76 @@ fn client_ip(headers: &HeaderMap, addr: SocketAddr) -> String { } addr.ip().to_string() } + +/// 当前用户的图片 id 列表 + 数量上限。 +#[derive(Serialize)] +struct PhotoList { + items: Vec, + max: usize, +} +#[derive(Serialize)] +struct PhotoItem { + id: String, +} + +async fn list_photos(AuthUser(user): AuthUser, State(state): State) -> Json { + let items = state + .photos + .list(&user) + .into_iter() + .map(|id| PhotoItem { id }) + .collect(); + Json(PhotoList { + items, + max: MAX_PHOTOS, + }) +} + +/// 上传请求体:base64 data URL(前端 FileReader 读出的)。 +#[derive(Deserialize)] +struct UploadRequest { + #[serde(rename = "dataUrl")] + data_url: String, +} +#[derive(Serialize)] +struct UploadResponse { + id: String, +} + +async fn upload_photo( + AuthUser(user): AuthUser, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + match state.photos.save(&user, &req.data_url) { + Ok(id) => Ok((StatusCode::CREATED, Json(UploadResponse { id }))), + Err(SaveError::Full) => Err(StatusCode::CONFLICT), + Err(SaveError::TooLarge) => Err(StatusCode::PAYLOAD_TOO_LARGE), + Err(SaveError::BadType) => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE), + Err(SaveError::BadUser) => Err(StatusCode::BAD_REQUEST), + Err(SaveError::Io) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn get_photo( + AuthUser(user): AuthUser, + State(state): State, + AxPath(id): AxPath, +) -> Response { + match state.photos.read(&user, &id) { + Some((mime, bytes)) => ([(header::CONTENT_TYPE, mime)], bytes).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + +async fn delete_photo( + AuthUser(user): AuthUser, + State(state): State, + AxPath(id): AxPath, +) -> StatusCode { + if state.photos.delete(&user, &id) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} diff --git a/Server/src/state.rs b/Server/src/state.rs index 9ab3ee0..728aff1 100644 --- a/Server/src/state.rs +++ b/Server/src/state.rs @@ -9,6 +9,7 @@ use tokio::sync::RwLock; use crate::auth::{self, DevGate, UserStore}; use crate::monitor::Stats; +use crate::photos::PhotoStore; use crate::weather::WeatherService; #[derive(Clone)] @@ -21,6 +22,8 @@ pub struct AppState { pub gate: Arc, /// 天气/定位服务(自带每日限流 + 缓存)。 pub weather: Arc, + /// 照片墙图片仓库(按用户隔离的磁盘目录)。 + pub photos: PhotoStore, } impl AppState { @@ -32,6 +35,7 @@ impl AppState { users: Arc::new(UserStore::new(pool.clone())), gate: Arc::new(DevGate::new(pool)), weather: Arc::new(WeatherService::from_env()), + photos: PhotoStore::from_env(), } } }