use axum::{ Json, Router, extract::{OriginalUri, Path, Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode, header}, middleware::{self, Next}, response::sse::{Event, KeepAlive, Sse}, response::{Html, IntoResponse, Response}, routing::{get, patch, post, put}, }; use include_dir::{Dir, include_dir}; use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration}; use tokio::sync::{broadcast, oneshot}; use tower_http::{ cors::{Any, CorsLayer}, services::ServeDir, trace::TraceLayer, }; use tracing::{info, warn}; use crate::config::Config; use crate::db::Db; use crate::error::StashError; use crate::listing::{AppProject, scan_projects}; use crate::watcher; /// build.rs 会把 `stash-web/dist` 同步到 `$OUT_DIR/web`, /// 这里把整棵目录树在编译期内嵌,最终产出带前端的单文件二进制。 static WEB: Dir<'static> = include_dir!("$OUT_DIR/web"); /// Vite 产物文件名带内容 hash,可以长期强缓存;HTML 每次都要回源校验。 const HASHED_ASSET_CACHE: &str = "public, max-age=31536000, immutable"; const HTML_CACHE: &str = "no-cache"; #[derive(Clone)] pub struct AppState { pub config: Arc, pub project_updates: broadcast::Sender>, /// 内容库:分类 / 分组 / 条目的唯一数据源 pub db: Arc, /// 内容库写入后的广播:SSE 用它对所有打开的页面推「该重拉」。 /// 不能只靠 `PRAGMA data_version`——服务自己的连接写完不会让它变化。 pub catalog_updates: broadcast::Sender<()>, /// 登录失败计数(单用户本地场景,一个进程一份就够) pub login_guard: Arc>, } /// 连续登录失败 5 次 → 锁 60 秒。 #[derive(Debug, Default)] pub struct LoginGuard { failures: u32, locked_until: Option, } impl LoginGuard { fn locked(&mut self) -> bool { if let Some(until) = self.locked_until { if std::time::Instant::now() < until { return true; } self.failures = 0; self.locked_until = None; } false } fn record_failure(&mut self) { self.failures += 1; if self.failures >= 5 { self.locked_until = Some(std::time::Instant::now() + Duration::from_secs(60)); self.failures = 0; } } fn reset(&mut self) { self.failures = 0; self.locked_until = None; } } pub struct ServerHandle { shutdown_tx: oneshot::Sender<()>, pub local_addr: SocketAddr, } impl ServerHandle { pub fn stop(self) { let _ = self.shutdown_tx.send(()); } } /// 根路径:返回内嵌前端的入口页。 async fn web_index() -> Response { embedded_file("index.html").unwrap_or_else(|| not_found_page("/")) } /// `/assets/**`:Vite 产出的 CSS / JS / 图片。 async fn web_asset(OriginalUri(uri): OriginalUri, Path(path): Path) -> Response { let file = format!("assets/{path}"); embedded_file(&file).unwrap_or_else(|| not_found_page(uri.path())) } /// 前端放在 dist 根目录的其他文件(favicon 等)。 async fn web_root_file(OriginalUri(uri): OriginalUri) -> Response { let path = uri.path().trim_start_matches('/'); embedded_file(path).unwrap_or_else(|| not_found_page(uri.path())) } /// 从内嵌目录取文件并组装响应;找不到返回 `None`。 fn embedded_file(path: &str) -> Option { let file = WEB.get_file(path)?; let mime = mime_guess::from_path(path).first_or_octet_stream(); let content_type = HeaderValue::from_str(mime.as_ref()) .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); let cache = if path.starts_with("assets/") { HeaderValue::from_static(HASHED_ASSET_CACHE) } else { HeaderValue::from_static(HTML_CACHE) }; Some( ( StatusCode::OK, [ (header::CONTENT_TYPE, content_type), (header::CACHE_CONTROL, cache), ], file.contents(), ) .into_response(), ) } /// 统一 404:内嵌前端没有这个文件,托管目录里也没有这个路径。 fn not_found_page(path: &str) -> Response { let body = format!( r#" 404 · 页面不存在

404

资源或页面不存在

{}

返回本地应用
"#, escape_html(path) ); (StatusCode::NOT_FOUND, Html(body)).into_response() } /// 前端客户端路由占用的路径前缀(见 `stash-web/src/data/view.ts`)。 /// /// 这些地址在服务端没有对应文件:`/c/apps` 刷新时 `ServeDir` 找不到东西, /// 会落到下面的兜底处理器,由这里回落到内嵌的 `index.html`, /// 前端再按 `location.pathname` 还原出同一页。 /// /// 只接管明确列出的前缀,托管目录里写错的路径仍然返回 404 页面: /// 把所有未命中都冒充成 200 的「万能 SPA 回落」,会让人分不清 /// 到底是路由拼错了还是文件真的不存在。 const SPA_PREFIXES: &[&str] = &["/c/", "/g/"]; /// 精确匹配的前端页面(没有实体文件,返回入口页) const SPA_PAGES: &[&str] = &["/login", "/admin", "/interface"]; /// 是否为前端路由路径(没有实体文件,应该返回入口页)。 fn is_spa_path(path: &str) -> bool { SPA_PAGES.contains(&path) || SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) } /// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。 async fn fallback_handler(OriginalUri(uri): OriginalUri, method: Method) -> Response { let is_page = method == Method::GET || method == Method::HEAD; if is_page && is_spa_path(uri.path()) { return web_index().await; } not_found_page(uri.path()) } fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } /// API: 健康检查 async fn health_check() -> impl IntoResponse { Json(serde_json::json!({ "status": "ok", "service": "stash" })) } /// API: 项目列表 async fn list_projects_api(State(state): State) -> impl IntoResponse { let projects = scan_projects(&state.config.root); Json(serde_json::json!({ "projects": projects })) } /// SSE:内容库被外部修改时通知前端重新拉取。 /// /// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件: /// SQLite 在 journal 模式下会产生临时文件的频繁读写,文件监听会误报, /// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。 async fn catalog_events(State(state): State) -> impl IntoResponse { let db = state.db.clone(); let mut updates = state.catalog_updates.subscribe(); let stream = async_stream::stream! { let mut last = db.data_version().unwrap_or(0); let mut ticker = tokio::time::interval(Duration::from_secs(5)); loop { // 两条来源:本进程的写接口主动广播(快、准), // 以及 data_version 轮询兜底(外部工具直接改库时用)。 let changed = tokio::select! { _ = ticker.tick() => match db.data_version() { Ok(current) if current != last => { last = current; true } _ => false, }, received = updates.recv() => received.is_ok(), }; if !changed { continue; } // 只告诉前端「变了」,具体内容由它重新 GET /api/catalog yield Ok::( Event::default().event("catalog").data(r#"{"changed":true}"#), ); } }; Sse::new(stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(15)) .text("keep-alive"), ) } /// 任何写请求成功后,通知所有打开的页面「内容库变了」。 /// /// 放在中间件里而不是逐个 handler 调:以后新增写接口不用再记得挂号。 /// 登录相关接口不涉及内容库,跳过以免无谓刷新。 async fn notify_catalog_writes( State(state): State, request: Request, next: Next, ) -> Response { let method = request.method().clone(); let path = request.uri().path().to_owned(); let response = next.run(request).await; let mutating = matches!( method, Method::POST | Method::PATCH | Method::PUT | Method::DELETE ); if mutating && path.starts_with("/api/") && !path.starts_with("/api/auth/") && response.status().is_success() { // 没人订阅(没有开着的页面)时 send 会失败,忽略即可 let _ = state.catalog_updates.send(()); } response } /// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。 async fn project_events(State(state): State) -> impl IntoResponse { let root = state.config.root.clone(); let initial_projects = scan_projects(&root); let mut updates = state.project_updates.subscribe(); let stream = async_stream::stream! { yield Ok::(project_event(initial_projects)); loop { match updates.recv().await { Ok(projects) => yield Ok(project_event(projects)), Err(broadcast::error::RecvError::Lagged(skipped)) => { warn!("项目事件订阅落后 {skipped} 条,正在重新扫描"); yield Ok(project_event(scan_projects(&root))); } Err(broadcast::error::RecvError::Closed) => break, } } }; Sse::new(stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(15)) .text("keep-alive"), ) } fn project_event(projects: Vec) -> Event { Event::default() .event("projects") .json_data(serde_json::json!({ "projects": projects })) .unwrap_or_else(|_| { warn!("序列化项目事件失败,将发送空项目列表"); Event::default() .event("projects") .data(r#"{"projects":[]}"#) }) } // ---------------- 登录 / 会话 ---------------- const SESSION_COOKIE: &str = "stash_session"; #[derive(serde::Deserialize)] struct PasswordBody { password: String, } #[derive(serde::Deserialize)] struct SetupBody { password: String, username: Option, } #[derive(serde::Deserialize)] struct ChangePasswordBody { current: String, next: String, } fn cookie_token(headers: &HeaderMap) -> Option { let raw = headers.get(header::COOKIE)?.to_str().ok()?; raw.split(';') .filter_map(|part| part.trim().split_once('=')) .find(|(key, _)| *key == SESSION_COOKIE) .map(|(_, value)| value.to_string()) } fn session_cookie(token: &str) -> String { format!( "{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", crate::db::auth::SESSION_TTL_SECS ) } fn clear_cookie() -> String { format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0") } fn api_error(status: StatusCode, message: &str) -> Response { (status, Json(serde_json::json!({ "error": message }))).into_response() } fn with_cookie(mut response: Response, cookie: String) -> Response { if let Ok(value) = HeaderValue::from_str(&cookie) { response.headers_mut().append(header::SET_COOKIE, value); } response } /// 同源校验:跨站请求带 Origin 且 host 不匹配时拒绝。 /// 和 Cookie 的 `SameSite=Strict` 互为补充,不依赖浏览器实现。 fn same_origin(headers: &HeaderMap) -> bool { let Some(origin) = headers .get(header::ORIGIN) .and_then(|value| value.to_str().ok()) else { return true; }; let Some(host) = headers.get(header::HOST).and_then(|value| value.to_str().ok()) else { return false; }; origin.ends_with(host) } fn login_locked(state: &AppState) -> bool { state .login_guard .lock() .map(|mut guard| guard.locked()) .unwrap_or(true) } fn login_failed(state: &AppState) { if let Ok(mut guard) = state.login_guard.lock() { guard.record_failure(); } } fn login_ok(state: &AppState) { if let Ok(mut guard) = state.login_guard.lock() { guard.reset(); } } async fn issue_token(state: &AppState) -> Result { let db = Arc::clone(&state.db); tokio::task::spawn_blocking(move || db.issue_session()) .await .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))? .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string())) } async fn read_session(state: &AppState, headers: &HeaderMap) -> Result { let token = cookie_token(headers); let db = Arc::clone(&state.db); tokio::task::spawn_blocking(move || db.auth_state(token.as_deref())) .await .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))? .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string())) } /// GET /api/auth/session async fn auth_session(State(state): State, headers: HeaderMap) -> Response { match read_session(&state, &headers).await { Ok(session) => Json(session).into_response(), Err(response) => response, } } /// POST /api/auth/setup —— 只在还没有管理员时可用 async fn auth_setup( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Response { if !same_origin(&headers) { return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); } let username = body.username.unwrap_or_else(|| "admin".into()); let password = body.password; let db = Arc::clone(&state.db); let created = tokio::task::spawn_blocking(move || db.setup_admin(&username, &password)).await; match created { Ok(Ok(())) => match issue_token(&state).await { Ok(token) => with_cookie( Json(serde_json::json!({ "ok": true })).into_response(), session_cookie(&token), ), Err(response) => response, }, Ok(Err(error)) => api_error(StatusCode::BAD_REQUEST, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// POST /api/auth/login async fn auth_login( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Response { if !same_origin(&headers) { return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); } if login_locked(&state) { return api_error(StatusCode::TOO_MANY_REQUESTS, "尝试过多,请一分钟后再试"); } let password = body.password; let db = Arc::clone(&state.db); let verified = tokio::task::spawn_blocking(move || db.login(&password)).await; match verified { Ok(Ok(Some(_username))) => { login_ok(&state); match issue_token(&state).await { Ok(token) => with_cookie( Json(serde_json::json!({ "ok": true })).into_response(), session_cookie(&token), ), Err(response) => response, } } Ok(Ok(None)) => { login_failed(&state); api_error(StatusCode::UNAUTHORIZED, "密码错误") } Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// POST /api/auth/logout async fn auth_logout(State(state): State, headers: HeaderMap) -> Response { if let Some(token) = cookie_token(&headers) { let db = Arc::clone(&state.db); let _ = tokio::task::spawn_blocking(move || db.logout(&token)).await; } with_cookie( Json(serde_json::json!({ "ok": true })).into_response(), clear_cookie(), ) } /// PATCH /api/auth/password —— 必须已登录,成功后所有会话失效 async fn auth_password( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Response { if !same_origin(&headers) { return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); } match read_session(&state, &headers).await { Ok(session) if session.authenticated => {} Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), Err(response) => return response, } let db = Arc::clone(&state.db); let (current, next) = (body.current, body.next); let changed = tokio::task::spawn_blocking(move || db.change_password(¤t, &next)).await; match changed { Ok(Ok(true)) => with_cookie( Json(serde_json::json!({ "ok": true })).into_response(), clear_cookie(), ), Ok(Ok(false)) => api_error(StatusCode::UNAUTHORIZED, "当前密码不正确"), Ok(Err(error)) => api_error(StatusCode::BAD_REQUEST, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } // ---------------- 站点设置 ---------------- #[derive(serde::Deserialize)] struct SettingsPatch { version: i64, settings: crate::db::Settings, } /// GET /api/settings —— 读接口公开,前端首屏直接取 async fn get_settings(State(state): State) -> Response { let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.load_settings()).await { Ok(Ok(document)) => Json(document).into_response(), Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// PATCH /api/settings —— 需要登录;`version` 做乐观锁 async fn patch_settings( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Response { if !same_origin(&headers) { return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); } match read_session(&state, &headers).await { Ok(session) if session.authenticated => {} Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), Err(response) => return response, } let db = Arc::clone(&state.db); let (settings, version) = (body.settings, body.version); match tokio::task::spawn_blocking(move || db.save_settings(settings, version)).await { Ok(Ok(document)) => Json(document).into_response(), Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message), Ok(Err(StashError::InvalidDirectory(message))) => { api_error(StatusCode::BAD_REQUEST, &message) } Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } // ---------------- 字体 ---------------- /// 字体文件后缀:后台字体下拉只列这几种。 const FONT_EXTENSIONS: [&str; 4] = ["woff2", "woff", "ttf", "otf"]; /// GET /api/fonts —— `font/` 目录里的字体文件(后台字体下拉用,公开读) async fn list_fonts(State(state): State) -> Response { let dir = state.config.font_dir.clone(); match tokio::task::spawn_blocking(move || scan_fonts(&dir)).await { Ok(Ok(fonts)) => Json(serde_json::json!({ "fonts": fonts })).into_response(), Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// 列目录:目录不存在按「没有字体」处理,不让后台报错。 fn scan_fonts(dir: &std::path::Path) -> std::io::Result> { let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(error) => return Err(error), }; let mut fonts: Vec<(String, serde_json::Value)> = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if !path.is_file() { continue; } let Some(extension) = path.extension().and_then(|value| value.to_str()) else { continue; }; if !FONT_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str()) { continue; } let Some(name) = path.file_name().and_then(|value| value.to_str()) else { continue; }; fonts.push(( name.to_string(), serde_json::json!({ "name": name, "url": format!("/fonts/{name}") }), )); } fonts.sort_by(|a, b| a.0.cmp(&b.0)); Ok(fonts.into_iter().map(|(_, value)| value).collect()) } /// GET /fonts/{path} —— `font/` 目录里的字体文件。 /// 只认一级文件名:`..`、子目录一律 404,避免越界读文件。 async fn font_asset(State(state): State, Path(path): Path) -> Response { let reject = || not_found_page(&format!("/fonts/{path}")); if path.is_empty() || path.contains('/') || path.contains('\\') || path.contains("..") { return reject(); } let file = state.config.font_dir.join(&path); let bytes = match tokio::fs::read(&file).await { Ok(bytes) => bytes, Err(_) => return reject(), }; let mime = mime_guess::from_path(&path).first_or_octet_stream(); let content_type = HeaderValue::from_str(mime.as_ref()) .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); ( [ (header::CONTENT_TYPE, content_type), (header::CACHE_CONTROL, HeaderValue::from_static(HTML_CACHE)), ], bytes, ) .into_response() } // ---------------- 条目编辑 / 备份 ---------------- /// GET /api/items/{key} —— 编辑表单的初始值(公开,便于未登录时预览) async fn get_item(State(state): State, Path(key): Path) -> Response { let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.load_entry(&key)).await { Ok(Ok(Some(entry))) => Json(entry).into_response(), Ok(Ok(None)) => api_error(StatusCode::NOT_FOUND, "条目不存在"), Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } #[derive(serde::Deserialize)] struct ItemPatchBody { #[serde(rename = "updatedAt")] updated_at: i64, item: crate::db::EntryPatch, } /// PATCH /api/items/{key} —— 需要登录,`updatedAt` 乐观锁 async fn patch_item( State(state): State, Path(key): Path, headers: HeaderMap, Json(body): Json, ) -> Response { if !same_origin(&headers) { return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); } match read_session(&state, &headers).await { Ok(session) if session.authenticated => {} Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), Err(response) => return response, } let db = Arc::clone(&state.db); let (patch, updated_at) = (body.item, body.updated_at); match tokio::task::spawn_blocking(move || db.update_entry(&key, patch, updated_at)).await { Ok(Ok(entry)) => Json(entry).into_response(), Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message), Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), Ok(Err(StashError::InvalidDirectory(message))) => { api_error(StatusCode::BAD_REQUEST, &message) } Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// GET /api/backup —— 下载整库快照(需要登录)。 /// /// 用 SQLite 的 `VACUUM INTO` 生成一致性副本,避免直接复制正在写的文件。 async fn backup_db(State(state): State, headers: HeaderMap) -> Response { match read_session(&state, &headers).await { Ok(session) if session.authenticated => {} Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), Err(response) => return response, } let db_path = state.config.db_path.clone(); let created = tokio::task::spawn_blocking(move || -> Result, String> { let target = db_path.with_file_name(format!( "stash-backup-{}.db", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0) )); let conn = rusqlite::Connection::open(&db_path).map_err(|error| error.to_string())?; conn.execute("VACUUM INTO ?1", [target.to_string_lossy().as_ref()]) .map_err(|error| error.to_string())?; let bytes = std::fs::read(&target).map_err(|error| error.to_string())?; let _ = std::fs::remove_file(&target); Ok(bytes) }) .await; match created { Ok(Ok(bytes)) => { let filename = format!( "stash-backup-{}.db", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0) ); ( [ (header::CONTENT_TYPE, "application/octet-stream".to_string()), ( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""), ), ], bytes, ) .into_response() } Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// POST /api/items/{key}/view —— 记一次浏览(公开) async fn post_view(State(state): State, Path(key): Path) -> Response { let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.bump_view(&key)).await { Ok(Ok((views, likes))) => Json(serde_json::json!({ "views": views, "likes": likes })).into_response(), Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } #[derive(serde::Deserialize)] struct LikeBody { liked: bool, } /// POST /api/items/{key}/like —— 点赞 / 取消(公开,前端用 localStorage 记是否已赞) async fn post_like( State(state): State, Path(key): Path, Json(body): Json, ) -> Response { let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.set_like(&key, body.liked)).await { Ok(Ok((views, likes))) => Json(serde_json::json!({ "views": views, "likes": likes })).into_response(), Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// POST /api/items —— 新建条目(需要登录) async fn create_item( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Response { if !same_origin(&headers) { return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); } match read_session(&state, &headers).await { Ok(session) if session.authenticated => {} Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), Err(response) => return response, } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.create_entry(body)).await { Ok(Ok(entry)) => Json(entry).into_response(), Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message), Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), Ok(Err(StashError::InvalidDirectory(message))) => { api_error(StatusCode::BAD_REQUEST, &message) } Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// DELETE /api/items/{key} —— 删除条目(需要登录) async fn delete_item( State(state): State, Path(key): Path, headers: HeaderMap, ) -> Response { if !same_origin(&headers) { return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); } match read_session(&state, &headers).await { Ok(session) if session.authenticated => {} Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), Err(response) => return response, } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.remove_entry(&key)).await { Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } // ---------- 原子模型:分组树 / 卡片 ---------- /// GET /api/catalog2 —— 分组树 + 卡片(公开读,新首页/侧栏用) async fn catalog2_api(State(state): State) -> Response { let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || -> Result { Ok(serde_json::json!({ "groups": db.load_tree()?, "cards": db.load_cards()?, })) }) .await { Ok(Ok(payload)) => Json(payload).into_response(), Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } async fn require_login(state: &AppState, headers: &HeaderMap) -> Result<(), Response> { if !same_origin(headers) { return Err(api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝")); } match read_session(state, headers).await { Ok(session) if session.authenticated => Ok(()), Ok(_) => Err(api_error(StatusCode::UNAUTHORIZED, "请先登录")), Err(response) => Err(response), } } fn node_error(error: StashError) -> Response { match error { StashError::Conflict(message) => api_error(StatusCode::CONFLICT, &message), StashError::NotFound(message) => api_error(StatusCode::NOT_FOUND, &message), StashError::InvalidDirectory(message) => api_error(StatusCode::BAD_REQUEST, &message), other => api_error(StatusCode::INTERNAL_SERVER_ERROR, &other.to_string()), } } /// POST /api/groups —— 新建根分组 / 子分组 async fn create_node( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Response { if let Err(response) = require_login(&state, &headers).await { return response; } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.create_node(body)).await { Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), Ok(Err(error)) => node_error(error), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// PATCH /api/groups/{key} —— 改名 / 移动 / 排序 / 根分组格式 async fn update_node( State(state): State, Path(key): Path, headers: HeaderMap, Json(body): Json, ) -> Response { if let Err(response) = require_login(&state, &headers).await { return response; } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.update_node(&key, body)).await { Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), Ok(Err(error)) => node_error(error), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// DELETE /api/groups/{key} —— 删除分组(级联子分组与条目) async fn delete_node( State(state): State, Path(key): Path, headers: HeaderMap, ) -> Response { if let Err(response) = require_login(&state, &headers).await { return response; } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.remove_node(&key)).await { Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), Ok(Err(error)) => node_error(error), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// POST /api/cards —— 新建卡片 async fn create_card( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Response { if let Err(response) = require_login(&state, &headers).await { return response; } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.create_card(body)).await { Ok(Ok(id)) => Json(serde_json::json!({ "id": id })).into_response(), Ok(Err(error)) => node_error(error), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// PATCH /api/cards/{id} —— 卡片标题 / 顺序 async fn update_card( State(state): State, Path(id): Path, headers: HeaderMap, Json(body): Json, ) -> Response { if let Err(response) = require_login(&state, &headers).await { return response; } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.update_card(id, body)).await { Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), Ok(Err(error)) => node_error(error), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// DELETE /api/cards/{id} —— 删除卡片(不影响内容) async fn delete_card( State(state): State, Path(id): Path, headers: HeaderMap, ) -> Response { if let Err(response) = require_login(&state, &headers).await { return response; } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.remove_card(id)).await { Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), Ok(Err(error)) => node_error(error), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// PUT /api/cards/{id}/groups —— 整组替换卡片勾选的分组 async fn set_card_groups( State(state): State, Path(id): Path, headers: HeaderMap, Json(body): Json>, ) -> Response { if let Err(response) = require_login(&state, &headers).await { return response; } let db = Arc::clone(&state.db); match tokio::task::spawn_blocking(move || db.set_card_groups(id, body)).await { Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), Ok(Err(error)) => node_error(error), Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } /// 组装路由:API + 内嵌前端 + 托管目录。 /// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、 /// 起监听任务,没法在单元测试里用。 fn build_router(state: AppState) -> Router { let root = state.config.root.clone(); let enable_cors = state.config.enable_cors; // 顺序就是优先级:显式路由(API / 内嵌前端)→ 托管目录 → 前端路由回落 → 404 let serve_dir = ServeDir::new(&root) .append_index_html_on_directories(true) .fallback(axum::handler::HandlerWithoutStateExt::into_service( fallback_handler, )); let mut app = Router::new() .route("/api/health", get(health_check)) .route("/api/projects", get(list_projects_api)) .route("/api/projects/events", get(project_events)) .route("/api/catalog2", get(catalog2_api)) .route("/api/nodes", post(create_node)) .route("/api/nodes/{key}", patch(update_node).delete(delete_node)) .route("/api/cards", post(create_card)) .route("/api/cards/{id}", patch(update_card).delete(delete_card)) .route("/api/cards/{id}/groups", put(set_card_groups)) .route("/api/catalog/events", get(catalog_events)) .route("/api/auth/session", get(auth_session)) .route("/api/auth/setup", post(auth_setup)) .route("/api/auth/login", post(auth_login)) .route("/api/auth/logout", post(auth_logout)) .route("/api/auth/password", patch(auth_password)) .route("/api/settings", get(get_settings).patch(patch_settings)) .route("/api/fonts", get(list_fonts)) .route("/fonts/{*path}", get(font_asset)) .route("/api/items/{key}", get(get_item).patch(patch_item).delete(delete_item)) .route("/api/items", post(create_item)) .route("/api/items/{key}/view", post(post_view)) .route("/api/items/{key}/like", post(post_like)) .route("/api/backup", get(backup_db)) .route("/", get(web_index)) .route("/index.html", get(web_index)) .route("/favicon.svg", get(web_root_file)) .route("/assets/{*path}", get(web_asset)) .fallback_service(serve_dir) .with_state(state.clone()) // 写接口成功后广播「内容库变了」,让所有打开的页面自己重拉 .layer(middleware::from_fn_with_state( state, notify_catalog_writes, )) .layer(TraceLayer::new_for_http()); if enable_cors { app = app.layer( CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any), ); } app } pub async fn start(config: Config) -> Result { if !config.root.exists() { return Err(StashError::InvalidDirectory( config.root.display().to_string(), )); } let (project_updates, _) = broadcast::channel(32); watcher::start(config.root.clone(), project_updates.clone())?; let (catalog_updates, _) = broadcast::channel(16); // 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成 let db = Arc::new(Db::open(&config)?); let shared_state = AppState { config: Arc::new(config.clone()), project_updates, db, catalog_updates, login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())), }; let app = build_router(shared_state); let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; let listener = tokio::net::TcpListener::bind(addr) .await .map_err(|e| StashError::ServerBind(format!("端口 {} 绑定失败: {}", config.port, e)))?; let local_addr = listener.local_addr()?; info!("Stash 服务运行于: http://{}", local_addr); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); tokio::spawn(async move { let server = axum::serve(listener, app).with_graceful_shutdown(async move { let _ = shutdown_rx.await; info!("Stash 优雅停机..."); }); if let Err(err) = server.await { warn!("服务异常退出: {}", err); } }); Ok(ServerHandle { shutdown_tx, local_addr, }) } #[cfg(test)] mod tests { use super::*; use axum::{body::Body, http::Request}; use std::path::PathBuf; use tower::ServiceExt; /// 测试用的托管目录:放在 `target/` 下,不依赖系统临时目录的权限。 fn hosted_root() -> PathBuf { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/spa-root"); std::fs::create_dir_all(root.join("demo")).expect("应能创建测试项目"); std::fs::write( root.join("demo/index.html"), "demo", ) .expect("应能写入项目入口页"); std::fs::write(root.join("note.txt"), "plain file").expect("应能写入普通文件"); root } fn router() -> Router { let mut config = Config::default(); config.root = hosted_root(); // 测试不关心 CORS 标头 config.enable_cors = false; let (project_updates, _) = broadcast::channel(4); let (catalog_updates, _) = broadcast::channel(4); build_router(AppState { config: Arc::new(config), project_updates, db: Arc::new(Db::in_memory()), catalog_updates, login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())), }) } #[test] fn scan_fonts_lists_only_font_files() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/font-scan"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("应能建目录"); std::fs::write(dir.join("b.ttf"), b"x").expect("应能写入"); std::fs::write(dir.join("a.WOFF2"), b"x").expect("应能写入"); std::fs::write(dir.join("note.txt"), b"x").expect("应能写入"); let fonts = scan_fonts(&dir).expect("应能列目录"); let names: Vec<_> = fonts .iter() .map(|font| font["name"].as_str().unwrap_or_default()) .collect(); assert_eq!(names, vec!["a.WOFF2", "b.ttf"]); assert_eq!(fonts[0]["url"], "/fonts/a.WOFF2"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn scan_fonts_treats_missing_dir_as_empty() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/no-such-font-dir"); let _ = std::fs::remove_dir_all(&dir); assert!(scan_fonts(&dir).expect("目录不存在不应报错").is_empty()); } /// 发一个 GET,返回(状态码,Content-Type,响应体)。 async fn get(path: &str) -> (StatusCode, String, String) { let response = router() .oneshot( Request::builder() .uri(path) .body(Body::empty()) .expect("请求应能构造"), ) .await .expect("路由应能处理请求"); let status = response.status(); let content_type = response .headers() .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_string(); let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("应能读出响应体"); ( status, content_type, String::from_utf8_lossy(&bytes).into_owned(), ) } /// 刷新分类明细页:服务端没有 `/c/apps` 这个文件,必须回落到前端入口页。 #[tokio::test] async fn spa_routes_fall_back_to_the_embedded_index() { let (status, content_type, body) = get("/c/apps").await; assert_eq!(status, StatusCode::OK); assert!( content_type.starts_with("text/html"), "应返回 HTML: {content_type}" ); assert!(body.contains(r#"
"#), "应返回前端入口页"); } /// 条目详情地址(含分组)同样回落:`/c/` 前缀下的任意层级都是前端路由。 #[tokio::test] async fn item_routes_fall_back_to_the_embedded_index() { let (status, _, body) = get("/c/apps/common/i/github").await; assert_eq!(status, StatusCode::OK); assert!(body.contains(r#"
"#)); // 本地项目:条目 id 是项目名,可能带空格 / 中文,照样要能落到入口页 let (status, _, _) = get("/c/local/local-all/i/demo").await; assert_eq!(status, StatusCode::OK); } /// 托管目录里的真实文件优先于回落,不能被前端路由吞掉。 #[tokio::test] async fn hosted_files_still_win_over_the_spa_fallback() { let (status, _, body) = get("/demo/").await; assert_eq!(status, StatusCode::OK); assert!( body.contains("demo"), "应直接返回项目入口页: {body}" ); let (status, _, body) = get("/note.txt").await; assert_eq!(status, StatusCode::OK); assert_eq!(body, "plain file"); } /// 不是前端路由的路径仍然返回 404 页面(带请求路径,便于排错)。 #[tokio::test] async fn unknown_paths_still_return_the_404_page() { let (status, content_type, body) = get("/missing.txt").await; assert_eq!(status, StatusCode::NOT_FOUND); assert!(content_type.starts_with("text/html")); assert!(body.contains("404")); assert!(body.contains("/missing.txt"), "404 页面应显示请求路径"); } /// API 不能被回落影响。 #[tokio::test] async fn api_routes_are_not_swallowed_by_the_fallback() { let (status, content_type, body) = get("/api/health").await; assert_eq!(status, StatusCode::OK); assert!(content_type.starts_with("application/json")); assert!(body.contains("\"status\":\"ok\"")); } /// 只接管登记过的前缀:`/c` 本身与 `/css/...` 都还是普通路径。 #[test] fn only_registered_prefixes_fall_back() { assert!(is_spa_path("/c/apps")); assert!(is_spa_path("/c/")); assert!(!is_spa_path("/c")); assert!(!is_spa_path("/css/app.css")); } }