PersonalWebApplication/Server/src/routes/web.rs

262 lines
8.3 KiB
Rust
Raw Normal View History

//! 前端React专用接口。挂 CORS仅放行前端开发源。
use std::net::SocketAddr;
use axum::{
Json, Router,
extract::{ConnectInfo, Query, State},
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,
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::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)
}
/// 返回最新系统状态快照。
async fn stats(State(state): State<AppState>) -> Json<Stats> {
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<AppState>,
Json(req): Json<GateRequest>,
) -> Result<Json<GateResponse>, 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<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
if state.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<f64>,
lat: Option<f64>,
city: Option<String>,
}
/// 天气 + 定位:
/// - 前端带 `lon/lat`AMap.Geolocation 定位结果)→ 直接用,只调和风。
/// - 未带 → 高德按访问者 IP 定位取城市作兜底。
///
/// 内部自带每日限流(高德 300 / 和风 900北京时间 0 点归零)与短期缓存。
async fn weather(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(params): Query<WeatherQuery>,
) -> Json<Value> {
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<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
}
}