//! 前端(React)专用接口。挂 CORS,仅放行前端开发源。 use std::net::SocketAddr; use axum::{ Json, Router, extract::{ConnectInfo, Query, State}, http::{HeaderMap, Method, StatusCode, header}, routing::{get, post, put}, }; 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::folders::{FolderRegistry, WallState, QUOTA_BYTES, MAX_ITEMS}; use crate::photos::{SaveError, MAX_BYTES, MAX_PHOTOS}; use crate::posts::{Post, PostDraft, PostError, MAX_POSTS}; use crate::routes::extract::AuthUser; use crate::{ monitor::Stats, state::AppState, weather::{Located, WeatherOutcome}, }; pub fn router(state: AppState) -> Router { // CORS:只有浏览器会校验。放行前端开发源(Vite 默认 5173,被占用时会用 5174)。 // 登录是 POST + JSON,所以要额外放行 POST 方法与 Content-Type 请求头, // 否则浏览器的预检(OPTIONS)会被拦下。 let cors = CorsLayer::new() .allow_origin(AllowOrigin::list([ "http://localhost:5173".parse().unwrap(), "http://127.0.0.1:5173".parse().unwrap(), "http://localhost:5174".parse().unwrap(), "http://127.0.0.1:5174".parse().unwrap(), ])) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]); // 照片接口接收 base64 上传,需放宽 body 上限到 16MB;其余接口保持 // axum 默认 2MB,避免全站放大攻击面。故把照片路由单独成子路由再 merge。 let photo_routes = Router::new() .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)); // 特殊文件夹路由(无张数上限,body 上限同照片接口) let folder_routes = Router::new() .route("/api/web/folder", post(check_exists)) .route("/api/web/folder/{name}/photos", get(list_folder_photos).post(upload_folder_photo)) .route("/api/web/folder/{name}/photos/{id}", get(get_folder_photo).delete(delete_folder_photo)) .layer(DefaultBodyLimit::max(16 * 1024 * 1024)); // 照片墙状态路由(body 较小,用默认 2MB 即可) let state_routes = Router::new() .route("/api/web/folder/{name}/state", get(get_folder_state).put(save_folder_state)); // 博客文章路由(正文是文本,默认 2MB body 上限足够)。 let post_routes = Router::new() .route("/api/web/posts", get(list_posts).post(create_post)) .route("/api/web/posts/{id}", get(get_post).put(update_post).delete(delete_post)); 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)) .merge(photo_routes) .merge(folder_routes) .merge(state_routes) .merge(post_routes) .layer(cors) .with_state(state) } /// 返回最新系统状态快照。 async fn stats(State(state): State) -> Json { let snapshot = state.snapshot.read().await.clone(); Json(snapshot) } /// 入口门请求体:前端把 URL `#` 后那串 hash 发来校验。 #[derive(Deserialize)] struct GateRequest { hash: String, } /// 入口门响应。 #[derive(Serialize)] struct GateResponse { ok: bool, } /// 校验入口 hash 是否属于某个合法开发者。 /// /// 命中 200 + `{ok:true}`(前端据此放行到登录页);未命中返回 401。 /// 注意:这只决定“是否显示登录页”,**真正的身份校验仍在 `login`**。 async fn gate( State(state): State, Json(req): Json, ) -> Result, StatusCode> { if state.gate.allows(&req.hash).await { Ok(Json(GateResponse { ok: true })) } else { Err(StatusCode::UNAUTHORIZED) } } /// 登录请求体:用户名 + 明文密码(开发期走 HTTP;生产应套 HTTPS)。 #[derive(Deserialize)] struct LoginRequest { username: String, password: String, } /// 登录成功响应。`token` 目前只是给前端持久化「登录态」用的标识, /// 暂无受保护接口去校验它;将来加鉴权中间件时再赋予真实含义。 #[derive(Serialize)] struct LoginResponse { ok: bool, username: String, token: String, } /// 校验用户名/密码。成功 200 + token,失败 401。 async fn login( State(state): State, Json(req): Json, ) -> Result, StatusCode> { // 先查开发者表 ccuser,再查照片墙用户表 wall_users if state.users.verify(&req.username, &req.password).await || state.wall_users.verify(&req.username, &req.password).await { Ok(Json(LoginResponse { ok: true, username: req.username.clone(), token: crate::auth::issue_token(&req.username), })) } else { Err(StatusCode::UNAUTHORIZED) } } /// 前端可选带上的定位参数:经纬度 + 城市名(来自 AMap.Geolocation 插件)。 /// 三者都缺省 → 后端退回高德 IP 定位兜底。 #[derive(Deserialize)] struct WeatherQuery { lon: Option, lat: Option, city: Option, } /// 天气 + 定位: /// - 前端带 `lon/lat`(AMap.Geolocation 定位结果)→ 直接用,只调和风。 /// - 未带 → 高德按访问者 IP 定位取城市作兜底。 /// /// 内部自带每日限流(高德 300 / 和风 900,北京时间 0 点归零)与短期缓存。 async fn weather( State(state): State, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Query(params): Query, ) -> Json { let ip = client_ip(&headers, addr); // 经纬度齐全才算前端定位成功;否则交给后端 IP 兜底。 let front = match (params.lon, params.lat) { (Some(lon), Some(lat)) => Some(Located::from_front(lon, lat, params.city)), _ => None, }; let outcome = state.weather.get(&ip, front).await; let q = state.weather.quota_snapshot(); let quota = json!({ "amap": { "remaining": q.amap_remaining, "limit": q.amap_limit }, "qweather": { "remaining": q.qweather_remaining, "limit": q.qweather_limit }, }); let body = match outcome { WeatherOutcome::Fresh(d) => json!({ "ok": true, "cached": false, "city": d.city, "text": d.text, "temp": d.temp, "icon": d.icon, "obsTime": d.obs_time, "quota": quota, }), WeatherOutcome::Cached(d) => json!({ "ok": true, "cached": true, "city": d.city, "text": d.text, "temp": d.temp, "icon": d.icon, "obsTime": d.obs_time, "quota": quota, }), WeatherOutcome::QuotaExceeded { provider } => json!({ "ok": false, "reason": "quota_exceeded", "provider": provider, "quota": quota, }), WeatherOutcome::NotConfigured => json!({ "ok": false, "reason": "not_configured", }), WeatherOutcome::Failed(message) => json!({ "ok": false, "reason": "failed", "message": message, }), }; Json(body) } /// 取访问者真实 IP:优先反代透传的 `X-Forwarded-For` 首段,否则用对端 socket 地址。 fn client_ip(headers: &HeaderMap, addr: SocketAddr) -> String { if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { if let Some(first) = xff.split(',').next() { let ip = first.trim(); if !ip.is_empty() { return ip.to_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 } } /* ════════════════════════════════════════════════════════════════ 特殊文件夹接口 ════════════════════════════════════════════════════════════════ */ /// 校验文件夹访问权限:未注册 → 404,无权 → 403 fn check_access(registry: &FolderRegistry, folder: &str, user: &str) -> Result<(), StatusCode> { if !registry.is_registered(folder) { return Err(StatusCode::NOT_FOUND); } if !registry.allows(folder, user) { return Err(StatusCode::FORBIDDEN); } Ok(()) } #[derive(Deserialize)] struct FolderCheckRequest { name: String, } #[derive(Serialize)] struct FolderCheckResponse { ok: bool, } /// POST /api/web/folder — 检查文件夹名是否已注册(登录前,无鉴权) async fn check_exists( State(state): State, Json(req): Json, ) -> Json { Json(FolderCheckResponse { ok: state.folders.exists(&req.name), }) } #[derive(Serialize)] struct FolderPhotoList { items: Vec, #[serde(skip_serializing_if = "Option::is_none")] max: Option, total_bytes: u64, over_quota: bool, } #[derive(Serialize)] struct FolderPhotoItem { id: String, } /// GET /api/web/folder/{name}/photos async fn list_folder_photos( AuthUser(user): AuthUser, State(state): State, AxPath(name): AxPath, ) -> Result, StatusCode> { check_access(&state.folders, &name, &user)?; let (items, total_bytes) = state.folder_photos.list(&name); Ok(Json(FolderPhotoList { items: items.into_iter().map(|id| FolderPhotoItem { id }).collect(), max: Some(MAX_ITEMS), total_bytes, over_quota: total_bytes > QUOTA_BYTES, })) } #[derive(Deserialize)] struct FolderUploadRequest { #[serde(rename = "dataUrl")] data_url: String, } #[derive(Serialize)] struct FolderUploadResponse { id: String, } /// POST /api/web/folder/{name}/photos async fn upload_folder_photo( AuthUser(user): AuthUser, State(state): State, AxPath(name): AxPath, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { check_access(&state.folders, &name, &user)?; if req.data_url.len() > MAX_BYTES * 2 { return Err(StatusCode::PAYLOAD_TOO_LARGE); } // 100 条总数上限检查 if state.folder_photos.count_items(&name) >= MAX_ITEMS { return Err(StatusCode::CONFLICT); } match state.folder_photos.save(&name, &req.data_url) { Ok(id) => Ok((StatusCode::CREATED, Json(FolderUploadResponse { id }))), 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), Err(SaveError::Full) => Err(StatusCode::INSUFFICIENT_STORAGE), } } /// GET /api/web/folder/{name}/photos/{id} async fn get_folder_photo( AuthUser(user): AuthUser, State(state): State, AxPath((name, id)): AxPath<(String, String)>, ) -> Response { if check_access(&state.folders, &name, &user).is_err() { return StatusCode::NOT_FOUND.into_response(); } match state.folder_photos.read(&name, &id) { Some((mime, bytes)) => ([(header::CONTENT_TYPE, mime)], bytes).into_response(), None => StatusCode::NOT_FOUND.into_response(), } } /// DELETE /api/web/folder/{name}/photos/{id} async fn delete_folder_photo( AuthUser(user): AuthUser, State(state): State, AxPath((name, id)): AxPath<(String, String)>, ) -> StatusCode { if check_access(&state.folders, &name, &user).is_err() { return StatusCode::NOT_FOUND; } if state.folder_photos.delete(&name, &id) { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } /* ════════════════════════════════════════════════════════════════ 照片墙状态接口(装饰 + 排版,服务器共享) ════════════════════════════════════════════════════════════════ */ /// GET /api/web/folder/{name}/state async fn get_folder_state( AuthUser(user): AuthUser, State(state): State, AxPath(name): AxPath, ) -> Result, StatusCode> { check_access(&state.folders, &name, &user)?; state.folder_photos .load_state(&name) .map(Json) .ok_or(StatusCode::NOT_FOUND) } #[derive(Deserialize)] struct StateSaveRequest { #[serde(rename = "bgIndex")] bg_index: usize, decorations: Vec, #[serde(rename = "photoLayout")] photo_layout: std::collections::HashMap, } /// PUT /api/web/folder/{name}/state async fn save_folder_state( AuthUser(user): AuthUser, State(state): State, AxPath(name): AxPath, Json(body): Json, ) -> Result { check_access(&state.folders, &name, &user)?; // 从 JSON Value 转换装饰列表 let decorations: Vec = body.decorations .into_iter() .filter_map(|v| serde_json::from_value(v).ok()) .collect(); let photo_layout: std::collections::HashMap = body.photo_layout .into_iter() .filter_map(|(k, v)| serde_json::from_value(v).ok().map(|pl| (k, pl))) .collect(); let wall_state = WallState { bg_index: body.bg_index, decorations, photo_layout, }; match state.folder_photos.save_state(&name, &wall_state) { Ok(()) => Ok(StatusCode::NO_CONTENT), Err("items limit exceeded") => Err(StatusCode::CONFLICT), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } /* ════════════════════════════════════════════════════════════════ 博客文章接口(按用户隔离,全部 AuthUser 鉴权) ════════════════════════════════════════════════════════════════ */ #[derive(Serialize)] struct PostListResponse { items: Vec, max: usize, } #[derive(Deserialize)] struct PostInput { title: String, tag: String, body: String, } /// PostError → HTTP 状态码(与 photos 同构)。 fn post_status(e: PostError) -> StatusCode { match e { PostError::Full => StatusCode::CONFLICT, PostError::TooLarge => StatusCode::PAYLOAD_TOO_LARGE, PostError::BadInput | PostError::BadUser => StatusCode::BAD_REQUEST, PostError::NotFound => StatusCode::NOT_FOUND, PostError::Io => StatusCode::INTERNAL_SERVER_ERROR, } } async fn list_posts(AuthUser(user): AuthUser, State(state): State) -> Json { Json(PostListResponse { items: state.posts.list(&user), max: MAX_POSTS, }) } async fn create_post( AuthUser(user): AuthUser, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { let draft = PostDraft { title: req.title, tag: req.tag, body: req.body }; state .posts .create(&user, draft) .map(|p| (StatusCode::CREATED, Json(p))) .map_err(post_status) } async fn get_post( AuthUser(user): AuthUser, State(state): State, AxPath(id): AxPath, ) -> Result, StatusCode> { state.posts.read(&user, &id).map(Json).ok_or(StatusCode::NOT_FOUND) } async fn update_post( AuthUser(user): AuthUser, State(state): State, AxPath(id): AxPath, Json(req): Json, ) -> Result { let draft = PostDraft { title: req.title, tag: req.tag, body: req.body }; state .posts .update(&user, &id, draft) .map(|_| StatusCode::NO_CONTENT) .map_err(post_status) } async fn delete_post( AuthUser(user): AuthUser, State(state): State, AxPath(id): AxPath, ) -> StatusCode { if state.posts.delete(&user, &id) { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } }