Files
stash/src/server.rs
T
CNWei 5982e0db5a feat(admin): 后台新增条目与分组 CRUD
- POST /api/items + DELETE /api/items/{key}:新增应用/海报/图文,按分区/分组挂载;PATCH 支持 nodeKey 移动
- 分组 CRUD:POST /api/categories/{id}/groups、PATCH/DELETE /api/groups/{id}(删除级联组内条目)
- PATCH /api/categories/{id} 增加 blockNodeKey,首页卡片可选择预览分组
- /admin 新增「内容」Tab(新增/删除条目),「分区」Tab 内联分组管理与卡片预览
- 测试 59 passed(条目创建/移动/删除、分组增删改)
2026-10-30 12:53:49 +08:00

1186 lines
43 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.
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::{get, patch, post},
};
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>,
/// 登录失败计数(单用户本地场景,一个进程一份就够)
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/"];
/// 精确匹配的前端页面(没有实体文件,返回入口页)
const SPA_PAGES: &[&str] = &["/login", "/admin"];
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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
}))
}
/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块)
///
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
/// 侧栏由这份数据派生,不再单独下发导航。
async fn catalog_api(State(state): State<AppState>) -> Response {
let db = state.db.clone();
// rusqlite 是同步的,查询扔进阻塞线程池,别占住 async 执行器
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
Ok(Ok(catalog)) => Json(serde_json::json!({
"categories": catalog.categories,
}))
.into_response(),
Ok(Err(error)) => {
// 典型原因是用户手改坏了 JSON,错误信息里已带条目 id
warn!("读取内容库失败: {error}");
error.into_response()
}
Err(error) => {
warn!("内容库查询任务崩溃: {error}");
StashError::Database(format!("内容库查询任务崩溃: {error}")).into_response()
}
}
}
/// SSE:内容库被外部修改时通知前端重新拉取。
///
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
/// SQLite 在 journal 模式下会产生临时文件的频繁读写,文件监听会误报,
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.clone();
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 {
ticker.tick().await;
let current = match db.data_version() {
Ok(version) => version,
Err(error) => {
warn!("读取内容库版本失败: {error}");
continue;
}
};
// 只在其它连接提交之后才推送。
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
// 因为 data_version 不会因本连接自己的提交而变化。
tracing::debug!(last, current, "内容库版本检查");
if current == last {
continue;
}
last = current;
// 只告诉前端「变了」,具体内容由它重新 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"),
)
}
/// 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(&current, &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()),
}
}
// ---------------- 条目编辑 / 备份 ----------------
/// 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()),
}
}
/// POST /api/categories —— 新建分区(需要登录)
async fn create_category(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::NewCategory>,
) -> 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_category(body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).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()),
}
}
/// PATCH /api/categories/{id} —— 修改分区(需要登录)
async fn update_category(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::UpdateCategory>,
) -> 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.update_category(&id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
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()),
}
}
/// POST /api/categories/{id}/groups —— 新建分组(需要登录)
async fn create_group(
State(state): State<AppState>,
Path(section_id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::NewGroup>,
) -> 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_group(&section_id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).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()),
}
}
/// PATCH /api/groups/{id} —— 分组改名 / 显隐(需要登录)
async fn update_group(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::UpdateGroup>,
) -> 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.update_group(&id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
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/groups/{id} —— 删除分组(需要登录,组内条目级联删除)
async fn delete_group(
State(state): State<AppState>,
Path(id): 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_group(&id)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
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/categories/{id} —— 删除分区(需要登录,级联内容)
async fn delete_category(
State(state): State<AppState>,
Path(id): 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_category(&id)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
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()),
}
}
/// 组装路由: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/catalog", get(catalog_api))
.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/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/categories", post(create_category))
.route(
"/api/categories/{id}",
patch(update_category).delete(delete_category),
)
.route("/api/categories/{id}/groups", post(create_group))
.route("/api/groups/{id}", patch(update_group).delete(delete_group))
.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)
.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 db = Arc::new(Db::open(&config)?);
let shared_state = AppState {
config: Arc::new(config.clone()),
project_updates,
db,
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);
build_router(AppState {
config: Arc::new(config),
project_updates,
db: Arc::new(Db::in_memory()),
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
})
}
/// 发一个 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"));
}
}