# Stash Stash 是一个用 Rust 编写的本地静态页面托管工具。编译后的 React 控制台会被内嵌进二进制, 所以发布物只有一个 `stash` 可执行文件,不需要额外部署前端静态资源。 它能做两件事:托管目录下的多个静态前端项目、给控制台提供同源 JSON / SSE 接口。 ## 快速开始 ```bash # 1. 先构建前端(dist 会被内嵌,缺了它 cargo build 会直接失败) cd stash-web npm install npm run build cd .. # 2. 编译并运行 cargo run # 托管 ./public,监听 127.0.0.1:8080 # 自定义目录 / 端口 / 对外暴露 cargo run -- --root ./sites --port 3000 cargo run -- --root ./sites --host 0.0.0.0 --port 3000 ``` 启动后打开终端里打印的地址即可,例如 `http://127.0.0.1:8080/`。 默认托管目录 `./public` 不存在时会自动创建;首次启动还会与二进制同级创建 `data/`, 并在其中生成内容库 `stash.db`(已内置示例内容,可以直接用 SQLite 工具修改)。 环境要求: - Rust 工具链(2024 Edition) - Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本 ## 工作方式 请求按下面的顺序命中: | 路径 | 处理方式 | | --- | --- | | `/api/*` | JSON / SSE 接口 | | `/`、`/index.html` | 内嵌前端的 `index.html`(`no-cache`) | | `/c/**` | 前端路由地址(分类明细 / 条目详情),没有实体文件时同样返回 `index.html` | | `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) | | `/favicon.svg` | 内嵌前端根目录文件 | | 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` | | 仍未命中 | 内置 404 页面(显示请求路径) | 托管目录下的每个一级子目录,只要包含 `index.html` 或 `index.htm`,就会被识别为一个项目, 通过 `/<目录名>/` 访问。目录名会做 URL 路径段编码。 ## 前端路由 控制台是单页应用,但「当前在哪一页」写在地址栏里,所以刷新、收藏、前进后退都不会丢上下文: | 地址 | 页面 | | --- | --- | | `/` | 首页分区卡片 | | `/c/<分区>` | 分类明细 | | `/c/<分区>/<分组>` | 只展示该分组 | | `/c/<分区>/i/<条目>` | 条目详情,返回 → 分类明细 | | `/c/<分区>/<分组>/i/<条目>` | 条目详情,返回 → 该分组 | 地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`, 本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。 几条约定: - 条目地址带上它自己所在的分组,所以**一个条目只有一个地址**;刷新之后点「返回」 仍然落回条目所属的分组,而不是去猜一个返回目标; - 「返回」按钮在应用内有上一页时交给浏览器(`history.back()`), 直接打开地址(刷新 / 收藏,应用内没有上一页)时退到上面的推导结果; - 服务端只把 `/c/**` 回落到入口页(`src/server.rs` 的 `SPA_PREFIXES`), 托管目录里写错的地址仍然是 404 页面——不做「所有未命中都返回 200」的万能回落, 否则分不清是路由拼错还是文件真的不存在; - 地址转换规则在 [`stash-web/src/data/view.ts`](stash-web/src/data/view.ts), 与 history 打交道的部分在 [`stash-web/src/useViewRoute.ts`](stash-web/src/useViewRoute.ts)。 新增一类页面时两处都要改:前端加前缀,服务端把它加进 `SPA_PREFIXES`。 ## 目录结构 ```text . ├── Cargo.toml # 单 crate(lib + bin),依赖清单 ├── Cargo.lock ├── build.rs # 把 stash-web/dist 同步到 $OUT_DIR/web ├── src/ # Rust 后端 │ ├── main.rs # CLI 参数、启动横幅、停机信号 │ ├── lib.rs # 库导出 │ ├── config.rs # 服务配置、数据目录与路径校验 │ ├── error.rs # 错误类型与 JSON 错误响应 │ ├── server.rs # 路由、内嵌静态资源、SSE、生命周期 │ ├── listing.rs # 托管目录扫描 │ ├── watcher.rs # 文件监听、防抖、快照广播 │ └── db/ # SQLite 内容库 │ ├── mod.rs # 打开、PRAGMA、种子导入时机 │ ├── schema.rs # 建表语句与种子导入 │ ├── catalog.rs # 查询结果组装成前端 Category[] │ └── seed.json # 内嵌种子数据(编译进二进制) ├── stash-web/ # React + Vite + TypeScript 控制台 │ ├── package.json # 含 volta 版本固定 │ ├── vite.config.ts # 开发期 /api 代理到 127.0.0.1:8080 │ └── src/ ├── data/ # 内容库目录(与二进制同级,运行时生成) │ └── stash.db # SQLite 内容库 └── public/ # 默认托管目录(可放多个前端项目) ``` `templates/` 与 `assets/` 是上一版 Askama 控制台留下的文件,当前已无任何代码引用, 可以直接删除。 ## 前端怎么被内嵌 1. `npm run build` 产出 `stash-web/dist`; 2. `build.rs` 在编译 Rust 之前把 `dist` 复制到 `$OUT_DIR/web`(先清空,避免旧 hash 文件残留); 3. `src/server.rs` 用 `include_dir!("$OUT_DIR/web")` 在编译期把整棵目录树嵌进二进制; 4. 二进制运行时不再读取任何前端文件。 构建顺序不能颠倒:`dist` 不存在时 `build.rs` 会带着提示信息直接失败,而不是产出一个没有界面的二进制。 `build.rs` 声明了 `rerun-if-changed`,改动 `dist` 后重新 `cargo build` 会自动重新嵌入。 ## 内容库(data/stash.db) 控制台的分类 / 分组 / 条目不写死在前端代码里,而是存在一个 SQLite 文件中, 默认位置是**二进制同级**的 `data/stash.db`。首次启动时若文件不存在, 会用内嵌的 `src/db/seed.json` 建库并灌入示例内容;之后一直沿用该文件(不覆盖已有数据)。 用 `--data ` 可以把数据目录指到别处。 **内容库不能放在 `--root` 托管目录内部**——那里的文件会被 `ServeDir` 当静态资源对外提供, 启动时会直接报错拒绝。 ### 表结构 三张表 + 一张展示配置表: - **分区与分组是同一张自引用树**——它们都是「装条目的容器」,只是层级不同; - **侧栏直接由这棵树派生**(分区下挂分组),不再有独立的导航表; - **首页分区展示什么由 `section_block` 决定**,卡片不再混排所有分组。 文章正文是一整段 Markdown,直接存在 `entry.body` 里(一篇文章一行),不再拆成正文块表。 主键统一是 `id INTEGER PRIMARY KEY`(SQLite rowid,自动分配,不用 `AUTOINCREMENT`), 另有一列 `key` 作为**唯一业务编码**;外键一律写成 `<目标>_key`,指向对方的 `key`。 #### node —— 分区与分组(一棵容器树) | 字段 | 类型 | 说明 | | --- | --- | --- | | `id` | INTEGER | 主键,库内唯一标识(自动分配) | | `key` | TEXT | 业务编码,全局唯一(如 `recommend` / `ui`) | | `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL | | `title` | TEXT | 展示名:分区标题 / 分组小标题 | | `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 | | `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 | | `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) | | `icon` | TEXT? | 仅分区:明细页 hero 左侧的字符图标 | | `preview_limit` | INTEGER? | 仅分区:首页预览条数;为空时按类型取默认 | | `empty_text` | TEXT? | 仅分区:分区为空时的提示文案 | | `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 | | `sort` | INTEGER | 同级顺序,越小越靠前 | 两条约束写在表上: - `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`; - `key` 唯一(UNIQUE),全局不重复。 #### entry —— 内容条目(网站 / 电影 / 文章共用) | 字段 | 类型 | 说明 / 前端用途 | | --- | --- | --- | | `id` | INTEGER | 主键,库内唯一标识(自动分配) | | `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 | | `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 | | `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 | | `description` | TEXT? | 一句话简介,列表卡片的副标题 | | `icon` | TEXT? | 字符图标,封面缺失时的占位 | | `href` | TEXT? | 点击后打开的页面;空或 `#` 表示暂无可跳转地址 | | `cover` | TEXT? | 封面图:应用头像 / 电影海报 / 文章配图 | | `banner` | TEXT? | 详情页横幅图 | | `badge` | TEXT? | 详情页标签行上的一句话标记(如「推荐好站」) | | `comment` | TEXT? | 详情页的一句话评价(引用块) | | `summary` | TEXT? | 详情页正文段落(电影简介这类长文本) | | `body` | TEXT? | 文章正文,一整段 Markdown;一篇一行,不拆块 | | `hot` | INTEGER | 是否热门,`0` / `1`;为 1 时列表显示 HOT 徽标 | | `views` | INTEGER | 浏览量,详情页操作条「眼睛」后的数字 | | `likes` | INTEGER | 点赞数,详情页操作条「赞」后的数字 | | `sort` | INTEGER | 条目顺序(组内或分区内),越小越靠前 | 条目只存 `node_key`,**所属分区由它推导**:父节点是分组就取该分组的 `parent_key`, 父节点是分区就取它自己。查询「整个分区」时用: ```sql SELECT e.* FROM entry e JOIN node p ON p.key = e.node_key WHERE COALESCE(p.parent_key, p.key) = 'recommend'; ``` 好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况; `node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。 三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定: | 类型 | 常用字段 | | --- | --- | | 应用 | `name` `description` `icon` `href` `cover` `badge` `banner` `hot` | | 电影 | `name`(前端叫 `title`)`cover` `summary` `comment` `href` + `entry_tag` | | 文章 | `name`(前端叫 `title`)`description` `cover` `body`(Markdown)+ `entry_tag` | #### 正文格式 `entry.body` 存的是 Markdown,渲染层只支持正文实际用到的子集: | 写法 | 效果 | | --- | --- | | 空行分段 | 一个段落 | | `## 小标题` | 正文小标题(`#`~`######` 都渲染成同级) | | 单独一行 `![说明](url)` | 配图 | | `**粗体**`、`` `代码` ``、`[文字](链接)` | 行内格式 | 列表、表格、引用块暂不支持;需要完整语法时把 [`ArticleBody.tsx`](stash-web/src/components/content/ArticleBody.tsx) 换成 react-markdown 即可,渲染层是隔离的。 #### entry_tag —— 行内文本列表(标签 / 元信息) | 字段 | 类型 | 说明 / 前端用途 | | --- | --- | --- | | `entry_key` | TEXT | 所属条目编码,外键 → `entry.key` | | `sort` | INTEGER | 展示顺序 | | `label` | TEXT | 标签文字(如「推荐电影」「2 年前」) | 主键 `(entry_key, sort)`。海报的 `tags` 与文章的 `meta` 都存这里——它们都是「有序字符串列表」, 前端只是在不同版式里渲染。 #### section_block —— 首页卡片的展示配置 | 字段 | 类型 | 说明 / 前端用途 | | --- | --- | --- | | `id` | INTEGER | 主键,库内唯一标识(自动分配) | | `node_key` | TEXT | 要展示的节点编码,外键 → `node.key`;通常是分组,电影/文章填分区自己 | | `title` | TEXT? | 块标题;为空时用节点 `title` | | `preview_limit` | INTEGER? | 卡片显示几条;为空时用分类默认值 | | `sort` | INTEGER | 卡片顺序 | **不存所属分区**:一张卡片属于哪个分区,由 `node_key` 推导——节点是分组就取 `parent_key`, 节点是分区就取它自己。所以这一列没必要重复存。 一张卡片展示「你选定的那个分组」,而不是把分区下所有分组混在一起; 点卡片标题或「···」都打开这个块指向的节点。 ### 表关系 ```text node (kind=section) 1 ──── n node (kind=group) │ │ └────────── n entry ───────┘ │ ├──── n entry_tag (标签 / 元信息) └──── n section_block (首页卡片,指向节点) section_block ──── 1 node(分区由 node 自己推导,不单独存) ``` | 关系 | 外键 | 级联 | | --- | --- | --- | | 分区 → 分组 | `node.parent_key → node.key` | 删分区连带删分组 | | 节点 → 条目 | `entry.node_key → node.key` | 删分区或分组连带删条目;改编码时条目自动跟随 | | 条目 → 标签 | `entry_tag.entry_key → entry.key` | 删条目删标签 | | 展示块 → 节点 | `section_block.node_key → node.key` | 删被展示的节点连带删该卡片 | 几条约束值得记住: - **API 只暴露业务编码**:响应里的 `id` 都是 `key`,数字主键只在库内使用; - `entry.node_key`:应用指向分组,海报与图文直接指向分区; - 条目 `key` 是主键,全局唯一; - **侧栏就是内容树**:分区展开是它的分组,名称取自 `node.title`,不再有别名或跨区编排; - 首页卡片展示哪个分组由 `section_block.node_key` 决定,改一行即可换内容。 ### 前端怎么用这些表 | 界面 | 用到的表 | 关键字段 | | --- | --- | --- | | 首页分区卡片 | `node`(section) + `section_block` + `entry` | 卡片标题用展示块标题(默认被展示节点的 `title`),色条用分区 `bar_color`;预览条目取该节点下的前 N 条 | | 分类明细页 | `node`(section/group) + `entry` | `icon`(hero 图标)、分区 `title` 或分组 `title`、条目卡片字段 | | 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` | | 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` | | 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` | | 侧栏导航 | `node` | 走 `parent_key` 的分区 + 分组,`show_in_sidebar = 0` 的节点跳过;「本地服务」由前端排除 | | 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 | ### 侧栏导航 侧栏不再是单独一层数据,**直接由内容树派生**: ```text 常用推荐(section,可折叠) ├── 常用站点(group → 打开该分组) ├── UI 设计资源 ├── 矢量图标 ├── 免费壁纸 └── 开发工具 推荐电影(section,无分组 → 直接打开) CES 现场报道(section,无分组 → 直接打开) ``` 规则很简单: - 分区有分组时渲染成可折叠节点,子项就是分组; - 分区没有分组时(电影 / 文章)整行是链接; - 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层; - `show_in_sidebar = 0` 的节点不出现(种子把「常用站点」设成了 0,因为它已经是首页卡片); - 「本地服务」是前端拼出来的分类,侧栏按 id 直接排除。 ### 首页展示块 首页卡片展示什么由 `section_block` 决定,不再把分区下所有分组混排: ```sql -- 常用推荐卡片改成展示「UI 设计资源」分组,最多 6 条 UPDATE section_block SET node_key = 'ui', preview_limit = 6 WHERE node_key = 'common'; ``` 每张卡片一行,`node_key` 指向要展示的节点;它属于哪个分区由节点自己决定。 点卡片标题或「···」都会打开这个节点;电影 / 文章这类没有分组的,`node_key` 填分区自己的编码。 **卡片标题跟着展示内容走**:配了展示块时用块标题(没配则用节点 `title`), 所以「常用推荐」这张卡片会显示成「常用站点」——它实际展示的就是这个分组。 需要单独起名时,写 `section_block.title` 即可。 ### 自己改内容 1. 用任何 SQLite 工具(DB Browser for SQLite、`sqlite3` CLI、DBeaver…)打开 `data/stash.db`; 2. 内容改 `node`(分区与分组)与 `entry`(文章正文就是 `entry.body` 里的 Markdown),标签改 `entry_tag`,首页展示改 `section_block`; 3. 提交修改后,页面会在 **5 秒内**自动刷新——服务端轮询 `PRAGMA data_version` 检测外部写入; 4. 也可以点侧栏的「重新扫描」或直接刷新浏览器。 新增条目时 `id` 不用填,`key` 必须写(全局唯一);`node_key` 填直接所在的分组编码 (没有分组就填分区编码),所属分区由它推导。 先 `SELECT key, kind, parent_key FROM node` 看一眼现有编码即可。 ### 重置 开发阶段表结构还会调整,**没有实现迁移**。要重置内容:关掉服务后删掉 `data/stash.db`, 下次启动会重新建表并灌入内嵌种子。 ```bash rm data/stash.db # Linux / macOS Remove-Item data\stash.db # Windows PowerShell ``` 库结构版本记在 `PRAGMA user_version` 里(当前为 `15`)。新版本二进制遇到旧库时会**直接拒绝启动** 并提示删库,而不是拿旧表执行新查询: ```text [FATAL] 启动失败: 数据库错误: 内容库结构版本为 14,当前程序需要 15。 开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。 ``` `SCHEMA_VERSION` 定义在 [`src/db/schema.rs`](src/db/schema.rs),改动表结构时把它 +1。 ## 前端开发 ```bash cd stash-web npm install npm run dev # http://127.0.0.1:5173,/api 自动代理到后端 8080 npm run build # 产物输出到 stash-web/dist npm run lint # oxlint ``` 开发时前后端分开跑:一个终端 `cargo run`,另一个终端 `npm run dev`。 控制台的用户资料目前是占位数据(见 `stash-web/src/profile.ts`),后端尚未提供鉴权接口。 ## 命令行参数 | 参数 | 默认值 | 说明 | | --- | --- | --- | | `-r, --root ` | `./public` | 静态页面托管根目录 | | `--data ` | 二进制同级 `data/` | 内容库所在目录(存放 `stash.db`) | | `-p, --port ` | `8080` | 监听端口 | | `--host ` | `127.0.0.1` | 监听地址,`0.0.0.0` 供局域网访问 | | `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 | | `-h, --help` | - | 帮助 | | `-V, --version` | - | 版本 | `--no-listing` 参数目前仍能被解析,但由于控制台已改为内嵌前端,它不再影响任何行为。 ## HTTP API ### 健康检查 ```http GET /api/health ``` ```json { "status": "ok", "service": "stash" } ``` ### 项目列表 ```http GET /api/projects ``` ```json { "projects": [ { "name": "admin-console", "route_path": "/admin-console/" }, { "name": "docs", "route_path": "/docs/" } ] } ``` ### 订阅项目变化 ```http GET /api/projects/events Accept: text/event-stream ``` 连接建立后立即推送一次当前快照,之后在项目列表变化时推送: ```text event: projects data: {"projects":[{"name":"docs","route_path":"/docs/"}]} ``` 服务端递归监听托管目录,把短时间内的连续事件合并成一次扫描,只在项目名称或访问路径真正变化时广播; 每 15 秒发送一次保活消息,浏览器断开后自动重连。 ### 内容目录 ```http GET /api/catalog ``` 返回控制台需要的全部分类(结构见 [`types.ts`](stash-web/src/data/types.ts)), 数据来自 `data/stash.db`。一次全量下发,前端搜索在客户端完成: ```json { "categories": [ { "id": "recommend", "kind": "apps", "title": "常用推荐", "barColor": "#3858f6", "icon": "🔖", "block": { "nodeKey": "common", "previewLimit": 4 }, "groups": [ { "id": "common", "name": "常用站点", "items": [ { "id": "github", "name": "GitHub", "desc": "代码托管与开源社区", "views": 429 } ] } ] } ] } ``` 「本地服务」分类**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。 每个分类上的 `block` 就是首页卡片的展示配置:`nodeKey` 指向要展示的节点(通常是分组), `previewLimit` 是卡片里显示几条。侧栏不再单独下发,前端直接用 `categories` 里的 分区与分组渲染。 ### 订阅内容库变化 ```http GET /api/catalog/events Accept: text/event-stream ``` 当内容库被**其它连接**(例如用户用 DB Browser 改库)提交后推送一次,事件本身不带数据: ```text event: catalog data: {"changed":true} ``` 客户端收到后重新 `GET /api/catalog`。检测基于 SQLite 的 `PRAGMA data_version`,每 5 秒轮询一次。 ## 测试 ```bash cargo test # 目录扫描、隐藏目录过滤、排序、路径编码、文件监听广播、内容库、SPA 回落 cd stash-web && npm run build # tsc -b + vite build,同时做类型检查 ``` 内容库相关测试全部使用**内存库**(`:memory:`),不写磁盘、不污染 `data/`。 如果运行环境限制了系统临时目录(部分沙箱会这样),前几个测试会报 `PermissionDenied`, 把 `TEMP` / `TMP` 指到工作区内再跑即可: ```powershell $env:TEMP = "$PWD\target\test-tmp"; $env:TMP = $env:TEMP; cargo test ``` ## 已知限制 - 前端路由只有 `/c/**` 一个前缀;新增一类页面时,`stash-web/src/data/view.ts` 与 `src/server.rs` 的 `SPA_PREFIXES` 必须一起改,否则刷新那条地址会拿到 404 页面。 - 前进 / 后退能回到对应页面,但不恢复滚动位置:切换视图统一滚回内容区顶部。 - 内嵌资源占用了 `/assets/` 前缀,托管目录下如果存在同名目录会被遮蔽。 - `/` 固定由控制台占用,上一版「根目录有 `index.html` 就直接当单站点展示」的行为已取消; 托管目录里的项目统一走 `/<目录名>/`。 - 项目扫描只检查一级子目录,不递归查找更深层项目。 - 文件事件经过约 150ms 防抖,新项目出现在控制台可能有短暂延迟。 - 内容库没有迁移机制:表结构变化时需要删掉 `data/stash.db` 重建(当前属于开发阶段)。 - 内容库被外部工具修改后,页面最长有 5 秒延迟才自动刷新。 - 内容库按单实例使用设计:多个进程同时写同一个库会在 `busy_timeout`(5 秒)后报错。 - 没有身份认证,默认只建议绑定 `127.0.0.1`;使用 `0.0.0.0` 暴露到局域网前请确认目录内容适合被其他设备访问。