- 新增 GET /api/fonts 列出二进制同级 font/ 目录里的字体文件,/fonts/<文件名> 对外提供(只认一级文件名,拒绝穿越) - 站点设置新增 font.url / font.family,校验只接受 http(s):// 或 /fonts/ 开头的链接 - 前台按设置注入 @font-face 或 CSS 链接并覆盖 --su-font-sans;留空回到内置文楷 - 后台「系统管理」新增字体选择 + 链接 + 族名;撤销/保存按钮覆盖该页 - 撤掉上一轮的霞鹜新晰黑改动
1299 lines
47 KiB
Rust
1299 lines
47 KiB
Rust
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<Config>,
|
||
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
||
/// 内容库:分类 / 分组 / 条目的唯一数据源
|
||
pub db: Arc<Db>,
|
||
/// 内容库写入后的广播:SSE 用它对所有打开的页面推「该重拉」。
|
||
/// 不能只靠 `PRAGMA data_version`——服务自己的连接写完不会让它变化。
|
||
pub catalog_updates: broadcast::Sender<()>,
|
||
/// 登录失败计数(单用户本地场景,一个进程一份就够)
|
||
pub login_guard: Arc<std::sync::Mutex<LoginGuard>>,
|
||
}
|
||
|
||
/// 连续登录失败 5 次 → 锁 60 秒。
|
||
#[derive(Debug, Default)]
|
||
pub struct LoginGuard {
|
||
failures: u32,
|
||
locked_until: Option<std::time::Instant>,
|
||
}
|
||
|
||
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<String>) -> 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<Response> {
|
||
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#"<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="utf-8"><title>404 · 页面不存在</title></head>
|
||
<body style="margin:0;display:grid;place-items:center;min-height:100vh;background:#f7f9fb;color:#1e293b;font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif">
|
||
<main style="text-align:center;padding:24px">
|
||
<p style="margin:0;font-size:64px;font-weight:800;line-height:1;color:#cbd5e1">404</p>
|
||
<h1 style="margin:12px 0 8px;font-size:18px;font-weight:600">资源或页面不存在</h1>
|
||
<p style="margin:0 0 20px;color:#64748b"><code style="font-family:ui-monospace,Consolas,monospace;font-size:13px">{}</code></p>
|
||
<a href="/" style="display:inline-block;padding:8px 16px;border-radius:8px;background:#3b82f6;color:#fff;text-decoration:none;font-size:14px">返回本地应用</a>
|
||
</main>
|
||
</body>
|
||
</html>"#,
|
||
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<AppState>) -> 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<AppState>) -> 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, Infallible>(
|
||
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<AppState>,
|
||
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<AppState>) -> 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::<Event, Infallible>(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<AppProject>) -> 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<String>,
|
||
}
|
||
|
||
#[derive(serde::Deserialize)]
|
||
struct ChangePasswordBody {
|
||
current: String,
|
||
next: String,
|
||
}
|
||
|
||
fn cookie_token(headers: &HeaderMap) -> Option<String> {
|
||
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<String, Response> {
|
||
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<crate::db::SessionState, Response> {
|
||
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<AppState>, 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<AppState>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<SetupBody>,
|
||
) -> 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<AppState>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<PasswordBody>,
|
||
) -> 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<AppState>, 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<AppState>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<ChangePasswordBody>,
|
||
) -> 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<AppState>) -> 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<AppState>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<SettingsPatch>,
|
||
) -> 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<AppState>) -> 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<Vec<serde_json::Value>> {
|
||
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<AppState>, Path(path): Path<String>) -> 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<AppState>, Path(key): Path<String>) -> 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<AppState>,
|
||
Path(key): Path<String>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<ItemPatchBody>,
|
||
) -> 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<AppState>, 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<Vec<u8>, 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<AppState>, Path(key): Path<String>) -> 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<AppState>,
|
||
Path(key): Path<String>,
|
||
Json(body): Json<LikeBody>,
|
||
) -> 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<AppState>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<crate::db::entries::NewEntry>,
|
||
) -> 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<AppState>,
|
||
Path(key): Path<String>,
|
||
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<AppState>) -> Response {
|
||
let db = Arc::clone(&state.db);
|
||
match tokio::task::spawn_blocking(move || -> Result<serde_json::Value, StashError> {
|
||
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<AppState>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<crate::db::groups::NewNode>,
|
||
) -> 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<AppState>,
|
||
Path(key): Path<String>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<crate::db::groups::UpdateNode>,
|
||
) -> 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<AppState>,
|
||
Path(key): Path<String>,
|
||
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<AppState>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<crate::db::cards::NewCard>,
|
||
) -> 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<AppState>,
|
||
Path(id): Path<i64>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<crate::db::cards::UpdateCard>,
|
||
) -> 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<AppState>,
|
||
Path(id): Path<i64>,
|
||
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<AppState>,
|
||
Path(id): Path<i64>,
|
||
headers: HeaderMap,
|
||
Json(body): Json<Vec<crate::db::cards::CardGroupInput>>,
|
||
) -> 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<ServerHandle, StashError> {
|
||
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"),
|
||
"<!doctype html><title>demo</title>",
|
||
)
|
||
.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#"<div id="root">"#), "应返回前端入口页");
|
||
}
|
||
|
||
/// 条目详情地址(含分组)同样回落:`/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#"<div id="root">"#));
|
||
|
||
// 本地项目:条目 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("<title>demo</title>"),
|
||
"应直接返回项目入口页: {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"));
|
||
}
|
||
}
|