557 lines
19 KiB
Rust
557 lines
19 KiB
Rust
//! 前端(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<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> {
|
||
// 先查开发者表 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<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
|
||
}
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
特殊文件夹接口
|
||
════════════════════════════════════════════════════════════════ */
|
||
|
||
/// 校验文件夹访问权限:未注册 → 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<AppState>,
|
||
Json(req): Json<FolderCheckRequest>,
|
||
) -> Json<FolderCheckResponse> {
|
||
Json(FolderCheckResponse {
|
||
ok: state.folders.exists(&req.name),
|
||
})
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct FolderPhotoList {
|
||
items: Vec<FolderPhotoItem>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
max: Option<usize>,
|
||
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<AppState>,
|
||
AxPath(name): AxPath<String>,
|
||
) -> Result<Json<FolderPhotoList>, 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<AppState>,
|
||
AxPath(name): AxPath<String>,
|
||
Json(req): Json<FolderUploadRequest>,
|
||
) -> Result<(StatusCode, Json<FolderUploadResponse>), 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<AppState>,
|
||
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<AppState>,
|
||
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<AppState>,
|
||
AxPath(name): AxPath<String>,
|
||
) -> Result<Json<WallState>, 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_json::Value>,
|
||
#[serde(rename = "photoLayout")]
|
||
photo_layout: std::collections::HashMap<String, serde_json::Value>,
|
||
}
|
||
|
||
/// PUT /api/web/folder/{name}/state
|
||
async fn save_folder_state(
|
||
AuthUser(user): AuthUser,
|
||
State(state): State<AppState>,
|
||
AxPath(name): AxPath<String>,
|
||
Json(body): Json<StateSaveRequest>,
|
||
) -> Result<StatusCode, StatusCode> {
|
||
check_access(&state.folders, &name, &user)?;
|
||
// 从 JSON Value 转换装饰列表
|
||
let decorations: Vec<crate::folders::Decoration> = body.decorations
|
||
.into_iter()
|
||
.filter_map(|v| serde_json::from_value(v).ok())
|
||
.collect();
|
||
let photo_layout: std::collections::HashMap<String, crate::folders::PhotoLayout> = 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<Post>,
|
||
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<AppState>) -> Json<PostListResponse> {
|
||
Json(PostListResponse {
|
||
items: state.posts.list(&user),
|
||
max: MAX_POSTS,
|
||
})
|
||
}
|
||
|
||
async fn create_post(
|
||
AuthUser(user): AuthUser,
|
||
State(state): State<AppState>,
|
||
Json(req): Json<PostInput>,
|
||
) -> Result<(StatusCode, Json<Post>), 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<AppState>,
|
||
AxPath(id): AxPath<String>,
|
||
) -> Result<Json<Post>, StatusCode> {
|
||
state.posts.read(&user, &id).map(Json).ok_or(StatusCode::NOT_FOUND)
|
||
}
|
||
|
||
async fn update_post(
|
||
AuthUser(user): AuthUser,
|
||
State(state): State<AppState>,
|
||
AxPath(id): AxPath<String>,
|
||
Json(req): Json<PostInput>,
|
||
) -> Result<StatusCode, StatusCode> {
|
||
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<AppState>,
|
||
AxPath(id): AxPath<String>,
|
||
) -> StatusCode {
|
||
if state.posts.delete(&user, &id) {
|
||
StatusCode::NO_CONTENT
|
||
} else {
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
}
|