feat(web): per-user photo endpoints (list/upload/get/delete) behind JWT

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cc 2026-06-24 21:12:14 +08:00
parent 29cbf524e9
commit e8f3f1674d
2 changed files with 87 additions and 2 deletions

View File

@ -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<PhotoItem>,
max: usize,
}
#[derive(Serialize)]
struct PhotoItem {
id: String,
}
async fn list_photos(AuthUser(user): AuthUser, State(state): State<AppState>) -> Json<PhotoList> {
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<AppState>,
Json(req): Json<UploadRequest>,
) -> Result<(StatusCode, Json<UploadResponse>), 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<AppState>,
AxPath(id): AxPath<String>,
) -> 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<AppState>,
AxPath(id): AxPath<String>,
) -> StatusCode {
if state.photos.delete(&user, &id) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}

View File

@ -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<DevGate>,
/// 天气/定位服务(自带每日限流 + 缓存)。
pub weather: Arc<WeatherService>,
/// 照片墙图片仓库(按用户隔离的磁盘目录)。
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(),
}
}
}