PersonalWebApplication/Server/src/routes/web.rs
cc edb975d818 feat(blog): 接入 posts 接口(CRUD + CORS PUT)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:48:26 +08:00

557 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 前端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
}
}