feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动
This commit is contained in:
+202
-1
@@ -1,9 +1,20 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::error::StashError;
|
||||
|
||||
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
|
||||
pub const DATA_DIR_NAME: &str = "data";
|
||||
/// 内容库文件名:固定为这个名字,一个实例一个库。
|
||||
pub const DB_FILE_NAME: &str = "stash.db";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// 静态页面托管根目录(存放一个或多个前端项目)
|
||||
pub root: PathBuf,
|
||||
/// 数据目录:存放内容库与将来的备份 / 导出文件
|
||||
pub data_dir: PathBuf,
|
||||
/// 内容库路径,固定为 `data/stash.db`
|
||||
pub db_path: PathBuf,
|
||||
/// 监听端口
|
||||
pub port: u16,
|
||||
/// 监听地址,默认 127.0.0.1
|
||||
@@ -16,8 +27,13 @@ pub struct Config {
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
// 默认值只在测试与兜底路径上使用,真实启动路径由 main.rs 解析
|
||||
let data_dir = PathBuf::from(DATA_DIR_NAME);
|
||||
|
||||
Self {
|
||||
root: PathBuf::from("../public"),
|
||||
db_path: resolve_db_path(&data_dir),
|
||||
data_dir,
|
||||
port: 8080,
|
||||
host: "127.0.0.1".to_string(),
|
||||
directory_listing: true,
|
||||
@@ -25,3 +41,188 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据目录的默认位置:**二进制所在目录**下的 `data/`。
|
||||
///
|
||||
/// 之所以不用当前工作目录,是因为双击启动、快捷方式启动时 CWD 并不可控
|
||||
/// (可能落在 System32 这种没写权限的位置),而数据库属于程序自身状态,
|
||||
/// 跟着 exe 走最稳。托管目录 `--root` 仍然保持 CWD 相对,行为不变。
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
default_data_dir_from(std::env::current_exe().ok().as_deref())
|
||||
}
|
||||
|
||||
/// `default_data_dir` 的纯函数版本,便于在测试里用假路径验证。
|
||||
fn default_data_dir_from(exe: Option<&Path>) -> PathBuf {
|
||||
match exe.and_then(Path::parent) {
|
||||
// 拿不到 exe 路径时退化成 CWD 相对目录,至少不会 panic
|
||||
Some(dir) => dir.join(DATA_DIR_NAME),
|
||||
None => PathBuf::from(DATA_DIR_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
/// 由数据目录推导出内容库路径。
|
||||
pub fn resolve_db_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(DB_FILE_NAME)
|
||||
}
|
||||
|
||||
/// 校验内容库没有落在静态托管目录里。
|
||||
///
|
||||
/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
|
||||
/// 一旦库文件在其中,任何人都能直接下载整个数据库,属于必须拦截的配置错误。
|
||||
pub fn validate_db_location(root: &Path, db_path: &Path) -> Result<(), StashError> {
|
||||
let root = normalize(root);
|
||||
let db = normalize(db_path);
|
||||
|
||||
if db.starts_with(&root) {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"内容库不能放在托管目录内部:库={},托管目录={}。请用 --data 指定其它位置。",
|
||||
db.display(),
|
||||
root.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 去掉 Windows `canonicalize` 加上的 `\\?\` 前缀。
|
||||
///
|
||||
/// 该前缀本身合法,但会让 `Path::starts_with` 把它们当成不同的路径前缀:
|
||||
/// `\\?\D:\sites` 与 `D:\sites` 的 `Prefix` 组件不相等,
|
||||
/// 于是「库是否在托管目录内」的校验会被绕过。比较之前必须统一。
|
||||
pub fn strip_verbatim(path: &Path) -> PathBuf {
|
||||
match path.as_os_str().to_string_lossy().strip_prefix(r"\\?\") {
|
||||
Some(stripped) => PathBuf::from(stripped),
|
||||
None => path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示用路径:去掉 `\\?\` 前缀后再转成字符串。
|
||||
///
|
||||
/// 前缀对程序无害,但打印在启动横幅和日志里很碍眼。
|
||||
pub fn display_path(path: &Path) -> String {
|
||||
strip_verbatim(path).display().to_string()
|
||||
}
|
||||
|
||||
/// 把路径转成绝对路径,并消掉 `.` / `..` 与重复分隔符。///
|
||||
/// 这里刻意不做 `canonicalize`:校验发生在目录创建之前,
|
||||
/// 目标文件往往还不存在,而 `canonicalize` 要求路径真实存在。
|
||||
/// 纯字符串层面的归一化已经足够判断「谁在谁内部」。
|
||||
fn normalize(path: &Path) -> PathBuf {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(path)
|
||||
};
|
||||
|
||||
// `canonicalize` 产出的 `\\?\` 前缀会让后续 starts_with 比较失效,先统一掉
|
||||
let absolute = strip_verbatim(&absolute);
|
||||
|
||||
let mut normalized = PathBuf::new();
|
||||
|
||||
for component in absolute.components() {
|
||||
match component {
|
||||
// 当前目录不产生任何变化
|
||||
Component::CurDir => {}
|
||||
// 父目录回退一级;已经到根时 pop 是空操作,正好不会越界
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
other => normalized.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn data_dir_follows_the_binary_directory() {
|
||||
let dir = default_data_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe")));
|
||||
assert_eq!(dir, PathBuf::from(r"D:\apps\stash\data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_falls_back_to_relative_when_exe_path_is_unknown() {
|
||||
assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_path_is_fixed_inside_the_data_dir() {
|
||||
assert_eq!(
|
||||
resolve_db_path(Path::new(r"D:\apps\stash\data")),
|
||||
PathBuf::from(r"D:\apps\stash\data\stash.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_database_nested_inside_the_hosted_root() {
|
||||
let err = validate_db_location(
|
||||
Path::new(r"D:\sites"),
|
||||
Path::new(r"D:\sites\data\stash.db"),
|
||||
);
|
||||
assert!(err.is_err(), "库在托管目录内时必须拒绝启动");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_database_beside_the_hosted_root() {
|
||||
assert!(
|
||||
validate_db_location(
|
||||
Path::new(r"D:\stash\public"),
|
||||
Path::new(r"D:\stash\data\stash.db"),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn location_check_survives_dot_segments() {
|
||||
// `..` 归一化之后应认出这是同一个目录,而不是被绕过
|
||||
assert!(
|
||||
validate_db_location(
|
||||
Path::new(r"D:\stash\sites"),
|
||||
Path::new(r"D:\stash\sites\..\sites\stash.db"),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// 回归测试:`canonicalize` 会给路径加 `\\?\` 前缀,
|
||||
/// 若比较前不统一,库落在托管目录内这种配置错误会被漏过。
|
||||
#[test]
|
||||
fn location_check_ignores_verbatim_prefix() {
|
||||
assert!(
|
||||
validate_db_location(
|
||||
Path::new(r"\\?\D:\stash\sites"),
|
||||
Path::new(r"D:\stash\sites\data\stash.db"),
|
||||
)
|
||||
.is_err(),
|
||||
"带 \\\\?\\ 前缀的托管目录也必须能拦住库"
|
||||
);
|
||||
|
||||
assert!(
|
||||
validate_db_location(
|
||||
Path::new(r"D:\stash\sites"),
|
||||
Path::new(r"\\?\D:\stash\sites\data\stash.db"),
|
||||
)
|
||||
.is_err(),
|
||||
"带 \\\\?\\ 前缀的库路径也必须能被识别"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbatim_prefix_is_stripped() {
|
||||
assert_eq!(
|
||||
strip_verbatim(Path::new(r"\\?\D:\apps\stash")),
|
||||
PathBuf::from(r"D:\apps\stash")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_verbatim(Path::new(r"D:\apps\stash")),
|
||||
PathBuf::from(r"D:\apps\stash")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user