feat: 实现导航页基础功能
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -4,3 +4,14 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Navius 后端
|
||||
|
||||
Rust + axum,数据用 JSON 文件存储,不依赖数据库。
|
||||
|
||||
网站分两种:**系统内置**(只读,随项目发布)和**提交/推送**(可改可删)。
|
||||
「不可修改」不是靠每处判断,而是靠**结构**保证:`system-*.json` 只在启动时读,
|
||||
代码里没有任何写这两个文件的路径。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cargo run # 默认监听 127.0.0.1:8080,数据目录 ./data
|
||||
cargo test # 34 个单元测试 + 接口测试
|
||||
cargo clippy --all-targets
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `NAVIUS_ADDR` | `127.0.0.1:8080` | 监听地址 |
|
||||
| `NAVIUS_DATA_DIR` | `data` | 数据目录 |
|
||||
| `NAVIUS_API_TOKEN` | 未设置 | 推送接口的令牌;不设置时 `/api/sites/push` 不可用(提交接口不受影响) |
|
||||
|
||||
## 数据文件
|
||||
|
||||
```
|
||||
data/
|
||||
├── site.json # 站点名与搜索框提示,只读
|
||||
├── system-categories.json # 内置分组,只读,自己手写维护
|
||||
├── system-sites.json # 内置网站,只读
|
||||
├── user-categories.json # 提交时创建的分组,自动写
|
||||
└── user-sites.json # 提交 / 推送的网站,自动写
|
||||
```
|
||||
|
||||
启动时会自检,任何一条不通过就直接退出(避免带着坏数据继续跑):
|
||||
|
||||
- `system-*.json` 里的 `source` 必须全是 `system`,`user-*.json` 里必须全不是;
|
||||
- 网站 id、分组 id 不能重复;
|
||||
- 每个网站的 `categoryId` 必须存在,且**内置网站只能挂在内置分组下、
|
||||
提交的网站只能挂在用户分组下**;
|
||||
- 分组的 `parentId` 必须存在、必须是一级分组(最多两级),也不能指向自己。
|
||||
|
||||
写盘用「先写临时文件再改名」,不会因为写到一半断电把文件写坏。
|
||||
|
||||
## 接口
|
||||
|
||||
### `GET /api/health`
|
||||
|
||||
```json
|
||||
{ "sites": 5, "systemSites": 5, "userSites": 0, "categories": 8 }
|
||||
```
|
||||
|
||||
### `GET /api/nav`
|
||||
|
||||
侧栏 + 内容区需要的那棵树。每个网站上都带一个推导出来的 `editable`
|
||||
(`source != "system"`),前端据此决定要不要显示编辑/删除入口。
|
||||
|
||||
```json
|
||||
{
|
||||
"site": { "name": "Suxing Nav", "searchHint": "输入关键字搜索全网资源..." },
|
||||
"groups": [
|
||||
{
|
||||
"id": "common", "title": "常用推荐", "source": "system", "editable": false,
|
||||
"sites": [
|
||||
{ "id": "github", "title": "GitHub", "url": "https://github.com",
|
||||
"description": "代码托管与开源社区", "categoryId": "common",
|
||||
"source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "", "editable": false }
|
||||
],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`site` 来自 `data/site.json`:站点名和搜索框提示都由后端提供,前端不再写死。
|
||||
|
||||
### `POST /api/sites` — 用户提交
|
||||
|
||||
**不接受 `categoryId`**,只接受分组名;后端只在「用户创建的分组」里查找或新建,
|
||||
所以提交的内容不可能落进内置分组。
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Hugging Face",
|
||||
"url": "https://huggingface.co",
|
||||
"description": "开源模型与数据集社区",
|
||||
"group": "AI 工具",
|
||||
"subGroup": "模型社区",
|
||||
"submittedBy": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
成功返回 201 和创建出来的条目。同一父级下同名的用户分组会**复用**,
|
||||
不会每提交一次就多一个同名分组。
|
||||
|
||||
### `POST /api/sites/push` — 接口推送
|
||||
|
||||
入参同上,但需要请求头 `X-Api-Token: <NAVIUS_API_TOKEN>`,
|
||||
创建出来的条目 `source` 记为 `api`(id 用 `a-` 前缀)。
|
||||
|
||||
### `PATCH /api/sites/{id}` — 修改
|
||||
|
||||
只改传进来的字段,可传 `title` / `url` / `description` / `group` / `subGroup`。
|
||||
系统内置的条目返回 403;传 `group` 时同样只能在用户分组里找或建。
|
||||
|
||||
### `DELETE /api/sites/{id}` — 删除
|
||||
|
||||
系统内置的条目返回 403,删掉了返回 204。
|
||||
|
||||
### 错误响应
|
||||
|
||||
统一是 `{"error": "中文说明"}`:
|
||||
|
||||
| 状态码 | 场景 |
|
||||
| --- | --- |
|
||||
| 400 | 入参不合法(标题为空/超长、url 非法或指向内网、缺 group 等) |
|
||||
| 403 | 改删系统内置条目、推送令牌不对 |
|
||||
| 404 | 找不到条目 |
|
||||
| 500 | 写盘失败、推送接口未配置令牌 |
|
||||
|
||||
## 字段长度限制
|
||||
|
||||
标题 60 字、描述 120 字、分组名 30 字、`submittedBy` 120 字,都按**字符**算
|
||||
(中文一个字算一个)。集中在 `src/validate.rs` 顶部,改规则只改那一处。
|
||||
|
||||
## 模块
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # 启动:读数据 → 自检 → 起 HTTP
|
||||
├── api.rs # 路由、令牌校验、把仓库错误翻译成状态码
|
||||
├── store.rs # 内存态仓库:增删改、构建导航树、原子写盘
|
||||
├── model.rs # 落盘结构、请求入参、对外返回结构
|
||||
├── validate.rs # 校验与归一化
|
||||
├── error.rs # ApiError(→ HTTP)与 StoreError(→ 启动失败)
|
||||
└── util.rs # 时间格式化与 id 生成
|
||||
```
|
||||
|
||||
## 已知取舍
|
||||
|
||||
- **跨域是全开的**(`CorsLayer::permissive`)。前端是 Flutter Web,开发时不同源;
|
||||
上线后如果只从自己的域名访问,应改成只允许那个来源。
|
||||
- **提交接口没有限流**。按需求「不需要审核、提交即上线」,公开写接口建议后面补
|
||||
一层限流或简单的验证码。
|
||||
- **`Cargo.lock` 目前在 `.gitignore` 里**。这是可执行程序,建议把它纳入版本管理,
|
||||
保证每台机器装到的依赖版本一致。
|
||||
- 前端目前还在用本地 mock 数据,接这个接口是下一步。
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Suxing Nav",
|
||||
"searchHint": "输入关键字搜索全网资源..."
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"id": "common",
|
||||
"title": "常用推荐",
|
||||
"parentId": "",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z"
|
||||
},
|
||||
{
|
||||
"id": "materials",
|
||||
"title": "素材资源",
|
||||
"parentId": "",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z"
|
||||
},
|
||||
{
|
||||
"id": "design",
|
||||
"title": "UI 设计",
|
||||
"parentId": "materials",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z"
|
||||
},
|
||||
{
|
||||
"id": "icon",
|
||||
"title": "图标库",
|
||||
"parentId": "materials",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z"
|
||||
},
|
||||
{
|
||||
"id": "stock",
|
||||
"title": "高清壁纸",
|
||||
"parentId": "materials",
|
||||
"source": "system",
|
||||
"order": 30,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z"
|
||||
},
|
||||
{
|
||||
"id": "dev-tools",
|
||||
"title": "开发工具",
|
||||
"parentId": "",
|
||||
"source": "system",
|
||||
"order": 30,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z"
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"title": "前端框架",
|
||||
"parentId": "dev-tools",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z"
|
||||
},
|
||||
{
|
||||
"id": "api",
|
||||
"title": "在线 API",
|
||||
"parentId": "dev-tools",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"id": "github",
|
||||
"title": "GitHub",
|
||||
"url": "https://github.com",
|
||||
"description": "代码托管与开源社区",
|
||||
"categoryId": "common",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
},
|
||||
{
|
||||
"id": "zhihu",
|
||||
"title": "知乎",
|
||||
"url": "https://www.zhihu.com",
|
||||
"description": "深度内容问答社区",
|
||||
"categoryId": "common",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
},
|
||||
{
|
||||
"id": "bilibili",
|
||||
"title": "哔哩哔哩",
|
||||
"url": "https://www.bilibili.com",
|
||||
"description": "弹幕视频分享网站",
|
||||
"categoryId": "common",
|
||||
"source": "system",
|
||||
"order": 30,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
},
|
||||
{
|
||||
"id": "figma",
|
||||
"title": "Figma",
|
||||
"url": "https://www.figma.com",
|
||||
"description": "协同界面设计工具",
|
||||
"categoryId": "design",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
},
|
||||
{
|
||||
"id": "dribbble",
|
||||
"title": "Dribbble",
|
||||
"url": "https://dribbble.com",
|
||||
"description": "全球设计作品分享社区",
|
||||
"categoryId": "design",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": "g-20260919-c8460f",
|
||||
"title": "嗯嗯",
|
||||
"parentId": "",
|
||||
"source": "submission",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-19T13:30:25Z",
|
||||
"updatedAt": "2026-09-19T13:30:25Z"
|
||||
},
|
||||
{
|
||||
"id": "g-20260919-e78b21",
|
||||
"title": "收拾收拾",
|
||||
"parentId": "g-20260919-c8460f",
|
||||
"source": "submission",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-19T13:32:03Z",
|
||||
"updatedAt": "2026-09-19T13:32:03Z"
|
||||
},
|
||||
{
|
||||
"id": "g-20260919-460478",
|
||||
"title": "AI 工具",
|
||||
"parentId": "",
|
||||
"source": "submission",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-19T13:38:10Z",
|
||||
"updatedAt": "2026-09-19T13:38:10Z"
|
||||
},
|
||||
{
|
||||
"id": "g-20260919-6b3fa7",
|
||||
"title": "模型社区",
|
||||
"parentId": "g-20260919-460478",
|
||||
"source": "submission",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-19T13:38:10Z",
|
||||
"updatedAt": "2026-09-19T13:38:10Z"
|
||||
},
|
||||
{
|
||||
"id": "g-20260919-91da17",
|
||||
"title": "反反复复",
|
||||
"parentId": "g-20260919-c8460f",
|
||||
"source": "submission",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-19T13:39:53Z",
|
||||
"updatedAt": "2026-09-19T13:39:53Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"id": "s-20260919-f103b8",
|
||||
"title": "Hugging Face",
|
||||
"url": "https://huggingface.co",
|
||||
"description": "开源模型与数据集社区",
|
||||
"categoryId": "g-20260919-6b3fa7",
|
||||
"source": "submission",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-19T13:38:10Z",
|
||||
"updatedAt": "2026-09-19T13:38:10Z",
|
||||
"submittedBy": "[email protected]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,411 @@
|
||||
//! HTTP 接口层:路由、鉴权、把仓库错误翻译成状态码。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::{get, patch};
|
||||
use axum::{Json, Router};
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::model::{
|
||||
CreateSiteRequest, NavResponse, Site, SiteView, Source, UpdateSiteRequest,
|
||||
};
|
||||
use crate::store::{Store, Summary};
|
||||
|
||||
/// 全局状态。仓库放在互斥锁里:接口可能并发到达,而写文件必须串行。
|
||||
pub struct AppState {
|
||||
pub store: Mutex<Store>,
|
||||
/// 推送接口的令牌。没配置时推送接口不可用(返回 500 并说明原因)。
|
||||
pub api_token: Option<String>,
|
||||
}
|
||||
|
||||
/// 组装路由。
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/nav", get(nav))
|
||||
// 用户提交:公开。
|
||||
.route("/api/sites", axum::routing::post(create_submission))
|
||||
// 接口推送:需要令牌,记成 api 来源。
|
||||
.route("/api/sites/push", axum::routing::post(create_push))
|
||||
.route("/api/sites/{id}", patch(update_site).delete(delete_site))
|
||||
// 前端是 Flutter Web,开发时和后端不同源,这里放开跨域。
|
||||
// 生产环境如果只从自己的域名访问,应改成只允许那个来源。
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn health(State(state): State<Arc<AppState>>) -> Result<Json<Summary>, ApiError> {
|
||||
let store = lock(&state)?;
|
||||
Ok(Json(store.summary()))
|
||||
}
|
||||
|
||||
async fn nav(State(state): State<Arc<AppState>>) -> Result<Json<NavResponse>, ApiError> {
|
||||
let store = lock(&state)?;
|
||||
Ok(Json(store.nav()))
|
||||
}
|
||||
|
||||
async fn create_submission(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateSiteRequest>,
|
||||
) -> Result<(StatusCode, Json<SiteView>), ApiError> {
|
||||
create(&state, request, Source::Submission)
|
||||
}
|
||||
|
||||
async fn create_push(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<CreateSiteRequest>,
|
||||
) -> Result<(StatusCode, Json<SiteView>), ApiError> {
|
||||
let Some(expected) = state.api_token.as_deref() else {
|
||||
return Err(ApiError::internal(
|
||||
"服务端没有配置 NAVIUS_API_TOKEN,推送接口未启用",
|
||||
));
|
||||
};
|
||||
let provided = headers
|
||||
.get("x-api-token")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if !constant_time_eq(provided, expected) {
|
||||
return Err(ApiError::forbidden("接口令牌不正确"));
|
||||
}
|
||||
create(&state, request, Source::Api)
|
||||
}
|
||||
|
||||
async fn update_site(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<UpdateSiteRequest>,
|
||||
) -> Result<Json<SiteView>, ApiError> {
|
||||
let mut store = lock(&state)?;
|
||||
let site = store.update(&id, &request)?;
|
||||
Ok(Json(SiteView::from(site)))
|
||||
}
|
||||
|
||||
async fn delete_site(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let mut store = lock(&state)?;
|
||||
store.delete(&id)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn create(
|
||||
state: &AppState,
|
||||
request: CreateSiteRequest,
|
||||
source: Source,
|
||||
) -> Result<(StatusCode, Json<SiteView>), ApiError> {
|
||||
let mut store = lock(state)?;
|
||||
let site: Site = store.create(&request, source)?;
|
||||
Ok((StatusCode::CREATED, Json(SiteView::from(site))))
|
||||
}
|
||||
|
||||
/// 取锁并把「锁被毒化」翻译成 500。
|
||||
///
|
||||
/// 所有仓库操作都是同步的,锁不会跨 `await`,所以这里是普通的 `std::sync::Mutex`。
|
||||
fn lock(state: &AppState) -> Result<std::sync::MutexGuard<'_, Store>, ApiError> {
|
||||
state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| ApiError::internal("内部状态锁已失效,请重启服务"))
|
||||
}
|
||||
|
||||
/// 定长时间比较,避免用令牌的响应时间反推正确值。
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request};
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::store::seed_for_test;
|
||||
|
||||
#[test]
|
||||
fn compares_tokens_exactly() {
|
||||
assert!(constant_time_eq("secret", "secret"));
|
||||
assert!(!constant_time_eq("secret", "secreu"));
|
||||
assert!(!constant_time_eq("secret", "secret-longer"));
|
||||
assert!(!constant_time_eq("", "secret"));
|
||||
assert!(constant_time_eq("", ""));
|
||||
}
|
||||
|
||||
/// 起一个带种子数据的路由,直接打请求,不用真的监听端口。
|
||||
fn app(temp: &TempDir, token: Option<&str>) -> Router {
|
||||
seed_for_test(temp.path());
|
||||
let store = Store::load(temp.path()).expect("加载失败");
|
||||
store.verify().expect("自检失败");
|
||||
let state = Arc::new(AppState {
|
||||
store: Mutex::new(store),
|
||||
api_token: token.map(|value| value.to_string()),
|
||||
});
|
||||
router(state)
|
||||
}
|
||||
|
||||
async fn call(
|
||||
app: &Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: Option<&str>,
|
||||
token: Option<&str>,
|
||||
) -> (StatusCode, String) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-api-token", token);
|
||||
}
|
||||
let request = match body {
|
||||
Some(json) => builder
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(json.to_string()))
|
||||
.expect("构造请求失败"),
|
||||
None => builder.body(Body::empty()).expect("构造请求失败"),
|
||||
};
|
||||
let response = app.clone().oneshot(request).await.expect("请求失败");
|
||||
let status = response.status();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
|
||||
.await
|
||||
.expect("读响应失败");
|
||||
(status, String::from_utf8_lossy(&bytes).to_string())
|
||||
}
|
||||
|
||||
fn json(text: &str) -> serde_json::Value {
|
||||
serde_json::from_str(text).expect("响应不是合法 JSON")
|
||||
}
|
||||
|
||||
const SUBMISSION: &str = r#"{
|
||||
"title": "Hugging Face",
|
||||
"url": "https://huggingface.co",
|
||||
"description": "开源模型与数据集社区",
|
||||
"group": "AI 工具",
|
||||
"subGroup": "模型社区",
|
||||
"submittedBy": "[email protected]"
|
||||
}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_and_nav_expose_seed_data() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, None);
|
||||
|
||||
let (status, body) = call(&app, Method::GET, "/api/health", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let health = json(&body);
|
||||
assert_eq!(health["sites"], 3);
|
||||
assert_eq!(health["systemSites"], 3);
|
||||
assert_eq!(health["userSites"], 0);
|
||||
assert_eq!(health["categories"], 3);
|
||||
|
||||
let (status, body) = call(&app, Method::GET, "/api/nav", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let nav = json(&body);
|
||||
assert_eq!(nav["site"]["name"], "Suxing Nav");
|
||||
assert_eq!(nav["site"]["searchHint"], "输入关键字搜索全网资源...");
|
||||
let groups = nav["groups"].as_array().expect("groups 应当是数组");
|
||||
assert_eq!(groups.len(), 2, "两个一级分组:{body}");
|
||||
assert_eq!(groups[0]["id"], "common");
|
||||
assert_eq!(groups[0]["sites"][0]["id"], "github");
|
||||
assert_eq!(groups[0]["sites"][0]["editable"], false, "内置条目不可编辑");
|
||||
assert_eq!(groups[1]["id"], "materials");
|
||||
assert_eq!(groups[1]["children"][0]["id"], "design");
|
||||
assert_eq!(groups[1]["children"][0]["sites"][0]["id"], "figma");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_creates_group_and_persists_to_disk() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, None);
|
||||
|
||||
let (status, body) = call(&app, Method::POST, "/api/sites", Some(SUBMISSION), None).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "{body}");
|
||||
let created = json(&body);
|
||||
assert!(created["id"].as_str().unwrap().starts_with("s-"), "{body}");
|
||||
assert!(
|
||||
created["categoryId"].as_str().unwrap().starts_with("g-"),
|
||||
"{body}"
|
||||
);
|
||||
assert_eq!(created["source"], "submission");
|
||||
assert_eq!(created["editable"], true);
|
||||
|
||||
// 换一个实例重新加载,数据应当还在磁盘上。
|
||||
let reloaded = Store::load(temp.path()).expect("重新加载失败");
|
||||
reloaded.verify().expect("重新加载后自检失败");
|
||||
assert_eq!(reloaded.summary().user_sites, 1);
|
||||
assert_eq!(reloaded.summary().categories, 5, "多了一级 + 子分组");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_group_name_is_reused_instead_of_duplicated() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, None);
|
||||
|
||||
let (_, first) = call(&app, Method::POST, "/api/sites", Some(SUBMISSION), None).await;
|
||||
let (_, second) = call(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/sites",
|
||||
Some(r#"{"title":"Ollama","url":"https://ollama.com","group":"AI 工具","subGroup":"模型社区"}"#),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
json(&first)["categoryId"],
|
||||
json(&second)["categoryId"],
|
||||
"同名分组应当复用"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_never_lands_in_a_system_group() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, None);
|
||||
|
||||
// 故意用内置分组的名字提交。
|
||||
let (status, body) = call(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/sites",
|
||||
Some(r#"{"title":"Fake","url":"https://fake.com","group":"素材资源","subGroup":"UI 设计"}"#),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED, "{body}");
|
||||
assert_ne!(json(&body)["categoryId"], "design", "不能落进内置子分组");
|
||||
|
||||
// 内置分组的内容没被动过。
|
||||
let (_, nav) = call(&app, Method::GET, "/api/nav", None, None).await;
|
||||
let nav = json(&nav);
|
||||
let materials = nav["groups"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|group| group["id"] == "materials")
|
||||
.expect("materials 分组应当存在");
|
||||
assert_eq!(materials["children"][0]["sites"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(materials["children"][0]["sites"][0]["id"], "figma");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_entries_are_read_only_over_http() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, None);
|
||||
|
||||
let (status, body) = call(
|
||||
&app,
|
||||
Method::PATCH,
|
||||
"/api/sites/github",
|
||||
Some(r#"{"title":"hacked"}"#),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
||||
|
||||
let (status, body) = call(&app, Method::DELETE, "/api/sites/github", None, None).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_sites_can_be_updated_and_deleted() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, None);
|
||||
|
||||
let (_, created) = call(&app, Method::POST, "/api/sites", Some(SUBMISSION), None).await;
|
||||
let id = json(&created)["id"].as_str().unwrap().to_string();
|
||||
|
||||
let (status, body) = call(
|
||||
&app,
|
||||
Method::PATCH,
|
||||
&format!("/api/sites/{id}"),
|
||||
Some(r#"{"title":"新名字","url":"https://huggingface.co/models"}"#),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(json(&body)["title"], "新名字");
|
||||
assert_eq!(json(&body)["url"], "https://huggingface.co/models");
|
||||
|
||||
let (status, _) = call(&app, Method::DELETE, &format!("/api/sites/{id}"), None, None).await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, body) = call(&app, Method::DELETE, &format!("/api/sites/{id}"), None, None).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_input_is_rejected_with_400() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, None);
|
||||
|
||||
for body in [
|
||||
// 标题为空
|
||||
r#"{"title":" ","url":"https://a.com","group":"G"}"#,
|
||||
// 没有分组
|
||||
r#"{"title":"A","url":"https://a.com"}"#,
|
||||
// 内网地址
|
||||
r#"{"title":"A","url":"http://192.168.1.1","group":"G"}"#,
|
||||
// 协议不对
|
||||
r#"{"title":"A","url":"ftp://a.com","group":"G"}"#,
|
||||
] {
|
||||
let (status, response) = call(&app, Method::POST, "/api/sites", Some(body), None).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{body} -> {response}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_requires_the_configured_token() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, Some("secret"));
|
||||
let payload = r#"{"title":"Pushed","url":"https://pushed.example.com","group":"API 推送"}"#;
|
||||
|
||||
let (status, body) = call(&app, Method::POST, "/api/sites/push", Some(payload), None).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
||||
|
||||
let (status, body) = call(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/sites/push",
|
||||
Some(payload),
|
||||
Some("wrong"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
||||
|
||||
let (status, body) = call(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/sites/push",
|
||||
Some(payload),
|
||||
Some("secret"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED, "{body}");
|
||||
let pushed = json(&body);
|
||||
assert_eq!(pushed["source"], "api");
|
||||
assert!(pushed["id"].as_str().unwrap().starts_with("a-"), "{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_is_unavailable_without_a_token_configured() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app = app(&temp, None);
|
||||
let payload = r#"{"title":"Pushed","url":"https://pushed.example.com","group":"API 推送"}"#;
|
||||
|
||||
let (status, body) =
|
||||
call(&app, Method::POST, "/api/sites/push", Some(payload), Some("any")).await;
|
||||
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}");
|
||||
assert!(body.contains("NAVIUS_API_TOKEN"), "{body}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 两类错误:接口错误(直接映射成 HTTP 状态码)与存储错误(启动阶段直接退出)。
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
/// 接口层错误。`message` 是直接返回给调用方的中文说明。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ApiError {
|
||||
/// 入参不合法,400。
|
||||
BadRequest(String),
|
||||
/// 想改系统内置的内容,403。
|
||||
Forbidden(String),
|
||||
/// 找不到条目,404。
|
||||
NotFound(String),
|
||||
/// 服务端自己的问题(写盘失败等),500。
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::BadRequest(message.into())
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::Forbidden(message.into())
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::NotFound(message.into())
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::Internal(message.into())
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::BadRequest(m)
|
||||
| Self::Forbidden(m)
|
||||
| Self::NotFound(m)
|
||||
| Self::Internal(m) => m,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
Self::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
Self::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status();
|
||||
(status, Json(json!({ "error": self.message() }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载与写盘阶段的错误。这些错误不会变成 HTTP 响应,而是让进程直接退出:
|
||||
/// 数据文件读不出来或者自检不通过,服务继续跑只会写出更乱的数据。
|
||||
#[derive(Debug)]
|
||||
pub enum StoreError {
|
||||
Io(String),
|
||||
Json(String),
|
||||
Integrity(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StoreError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(m) => write!(f, "读取/写入失败:{m}"),
|
||||
Self::Json(m) => write!(f, "JSON 解析失败:{m}"),
|
||||
Self::Integrity(m) => write!(f, "数据自检不通过:{m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StoreError {}
|
||||
|
||||
impl From<StoreError> for ApiError {
|
||||
fn from(value: StoreError) -> Self {
|
||||
ApiError::Internal(value.to_string())
|
||||
}
|
||||
}
|
||||
+57
-2
@@ -1,3 +1,58 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
//! Navius 后端:JSON 文件存储的导航站服务。
|
||||
//!
|
||||
//! 启动流程:读数据目录 → 自检 → 起 HTTP。
|
||||
//! 自检不过直接退出:数据文件坏了还继续跑,只会把更乱的数据写回去。
|
||||
|
||||
mod api;
|
||||
mod error;
|
||||
mod model;
|
||||
mod store;
|
||||
mod util;
|
||||
mod validate;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use store::Store;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let data_dir = std::env::var("NAVIUS_DATA_DIR").unwrap_or_else(|_| "data".to_string());
|
||||
let addr = std::env::var("NAVIUS_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
|
||||
let api_token = std::env::var("NAVIUS_API_TOKEN")
|
||||
.ok()
|
||||
.filter(|token| !token.is_empty());
|
||||
|
||||
if let Err(error) = run(PathBuf::from(data_dir), addr, api_token).await {
|
||||
eprintln!("启动失败:{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
data_dir: PathBuf,
|
||||
addr: String,
|
||||
api_token: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let store = Store::load(&data_dir)?;
|
||||
store.verify()?;
|
||||
|
||||
let summary = store.summary();
|
||||
println!("数据目录:{}", data_dir.display());
|
||||
println!(
|
||||
"网站 {} 条(内置 {} / 提交 {}),分组 {} 个",
|
||||
summary.sites, summary.system_sites, summary.user_sites, summary.categories
|
||||
);
|
||||
if api_token.is_none() {
|
||||
println!("未设置 NAVIUS_API_TOKEN,/api/sites/push 不可用(提交接口不受影响)");
|
||||
}
|
||||
|
||||
let state = Arc::new(api::AppState {
|
||||
store: Mutex::new(store),
|
||||
api_token,
|
||||
});
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
println!("监听 http://{addr}");
|
||||
axum::serve(listener, api::router(state)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 落盘数据结构与对外返回结构。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 数据来源。**唯一决定条目能不能被修改的字段。**
|
||||
///
|
||||
/// 不额外存 `editable`:存两份状态迟早会不一致,比如手改 JSON 把 `source`
|
||||
/// 改成 `system`、`editable` 却还是 `true`。对外返回时按这里推导。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Source {
|
||||
/// 系统内置:随项目发布,只读,只有项目维护者手改 JSON 能改。
|
||||
System,
|
||||
/// 用户提交。
|
||||
Submission,
|
||||
/// 第三方通过接口推送。
|
||||
Api,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// 系统内置的条目不可修改。
|
||||
pub fn is_system(self) -> bool {
|
||||
matches!(self, Source::System)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个网站条目。
|
||||
///
|
||||
/// 字段名在 JSON 里是 camelCase(和前端 Dart 的 `fromJson` 一致),
|
||||
/// 以后新增字段一律加 `#[serde(default)]`,老 JSON 文件仍然能读,不必写迁移。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Site {
|
||||
/// 唯一标识,也是接口路径里的 id。
|
||||
pub id: String,
|
||||
/// 显示名称。前端取它的首字当图标。
|
||||
pub title: String,
|
||||
/// 跳转地址。
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// 所属分组或子分组的 id。
|
||||
pub category_id: String,
|
||||
pub source: Source,
|
||||
/// 同一分组内的排序值,小的在前。
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
/// 提交来源标识;系统内置为空。
|
||||
#[serde(default)]
|
||||
pub submitted_by: String,
|
||||
}
|
||||
|
||||
impl Site {
|
||||
/// 能不能被接口修改。
|
||||
pub fn is_editable(&self) -> bool {
|
||||
!self.source.is_system()
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组。`parent_id` 为空表示一级分组,不为空表示子分组。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Category {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub parent_id: String,
|
||||
pub source: Source,
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
/// 是不是一级分组。
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.parent_id.is_empty()
|
||||
}
|
||||
|
||||
/// 能不能被接口改动(提交时新建的分组才能被后续提交复用,内置分组只看不改)。
|
||||
pub fn is_editable(&self) -> bool {
|
||||
!self.source.is_system()
|
||||
}
|
||||
}
|
||||
|
||||
/// 对外返回的网站:在落盘结构上补一个推导出来的 `editable`。
|
||||
///
|
||||
/// 这里存一份 `Site` 的拷贝而不是借用:接口层需要先把互斥锁放开、
|
||||
/// 再去序列化响应,借用会让生命周期绑死在锁上。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SiteView {
|
||||
#[serde(flatten)]
|
||||
pub site: Site,
|
||||
/// 由 `source` 推导,不落盘。前端据此决定要不要显示编辑/删除入口。
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
impl From<Site> for SiteView {
|
||||
fn from(site: Site) -> Self {
|
||||
Self {
|
||||
editable: site.is_editable(),
|
||||
site,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Site> for SiteView {
|
||||
fn from(site: &Site) -> Self {
|
||||
Self::from(site.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// 对外返回的分组节点:带上自己直接挂着的网站与下一级子分组。
|
||||
///
|
||||
/// 前端侧栏与内容区需要的正是这棵树,所以直接在接口层拼好,
|
||||
/// 前端不用再拿两份数组自己组装。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavGroupView {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub source: Source,
|
||||
pub editable: bool,
|
||||
pub sites: Vec<SiteView>,
|
||||
pub children: Vec<NavGroupView>,
|
||||
}
|
||||
|
||||
/// `GET /api/nav` 的响应体。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavResponse {
|
||||
/// 站点级配置,前端不再自己写死站点名和搜索框提示。
|
||||
pub site: SiteMeta,
|
||||
pub groups: Vec<NavGroupView>,
|
||||
}
|
||||
|
||||
/// 站点级配置,存在 `data/site.json`,由项目维护者手改。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SiteMeta {
|
||||
/// 站点名,显示在侧栏顶部与浏览器标题。
|
||||
pub name: String,
|
||||
/// 搜索框占位文案。
|
||||
#[serde(default)]
|
||||
pub search_hint: String,
|
||||
}
|
||||
|
||||
/// 提交 / 推送一个网站的入参。
|
||||
///
|
||||
/// **没有 `categoryId`**:提交方只能给分组名,由后端在「用户创建的分组」里
|
||||
/// 查找或新建,这样提交的内容不可能落进内置分组。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSiteRequest {
|
||||
// 下面三个字段也用 `default`:缺字段时不要让 axum 抛 422 和英文错误,
|
||||
// 交给 `validate` 给出统一的中文 400 说明。
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// 一级分组名。
|
||||
#[serde(default)]
|
||||
pub group: String,
|
||||
/// 子分组名,可选;给了就挂到两级下面。
|
||||
#[serde(default)]
|
||||
pub sub_group: String,
|
||||
#[serde(default)]
|
||||
pub submitted_by: String,
|
||||
}
|
||||
|
||||
/// 修改一个网站的入参,只改传进来的字段。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateSiteRequest {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// 给了就表示要把条目挪到(或挪进)这个用户分组。
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sub_group: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
//! 数据仓库:启动时把四个 JSON 文件读进内存,之后读内存、写文件。
|
||||
//!
|
||||
//! 「系统内置不可修改」是靠结构保证的:`system-*.json` 只在 `load` 里被读,
|
||||
//! 模块里根本没有写这两个文件的函数,任何接口都改不到它们。
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::{ApiError, StoreError};
|
||||
use crate::model::{
|
||||
Category, CreateSiteRequest, NavGroupView, NavResponse, Site, SiteMeta, SiteView, Source,
|
||||
UpdateSiteRequest,
|
||||
};
|
||||
use crate::util::{generate_id, now_iso8601};
|
||||
use crate::validate::{self, SiteDraft, SitePatch};
|
||||
|
||||
/// 同一分组里每新增一条,排序值加这么多,给以后手工插入留出空隙。
|
||||
const ORDER_STEP: i32 = 10;
|
||||
|
||||
/// 四个数据文件的位置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataPaths {
|
||||
pub site: PathBuf,
|
||||
pub system_categories: PathBuf,
|
||||
pub system_sites: PathBuf,
|
||||
pub user_categories: PathBuf,
|
||||
pub user_sites: PathBuf,
|
||||
}
|
||||
|
||||
impl DataPaths {
|
||||
pub fn in_dir(dir: &Path) -> Self {
|
||||
Self {
|
||||
site: dir.join("site.json"),
|
||||
system_categories: dir.join("system-categories.json"),
|
||||
system_sites: dir.join("system-sites.json"),
|
||||
user_categories: dir.join("user-categories.json"),
|
||||
user_sites: dir.join("user-sites.json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存态数据仓库。
|
||||
pub struct Store {
|
||||
paths: DataPaths,
|
||||
site: SiteMeta,
|
||||
system_categories: Vec<Category>,
|
||||
system_sites: Vec<Site>,
|
||||
user_categories: Vec<Category>,
|
||||
user_sites: Vec<Site>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// 从数据目录读取全部数据。系统文件必须存在;用户文件缺失时当空处理,
|
||||
/// 这样第一次跑起来只需要带上种子数据。
|
||||
pub fn load(dir: &Path) -> Result<Self, StoreError> {
|
||||
let paths = DataPaths::in_dir(dir);
|
||||
Ok(Self {
|
||||
site: read_json(&paths.site, true)?,
|
||||
system_categories: read_list(&paths.system_categories, true)?,
|
||||
system_sites: read_list(&paths.system_sites, true)?,
|
||||
user_categories: read_list(&paths.user_categories, false)?,
|
||||
user_sites: read_list(&paths.user_sites, false)?,
|
||||
paths,
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动自检。不通过就直接让进程退出——数据文件坏了还继续跑,
|
||||
/// 只会把更乱的数据写回去。
|
||||
pub fn verify(&self) -> Result<(), StoreError> {
|
||||
// 0. 站点配置是必需的:站点名不能空。
|
||||
if self.site.name.trim().is_empty() {
|
||||
return Err(StoreError::Integrity(
|
||||
"site.json 里的 name 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 1. source 必须和文件归属一致,防止手改 JSON 越权。
|
||||
for category in &self.system_categories {
|
||||
if !category.source.is_system() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"system-categories.json 里的分组「{}」source 不是 system",
|
||||
category.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
for category in &self.user_categories {
|
||||
if category.source.is_system() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"user-categories.json 里的分组「{}」source 不能是 system",
|
||||
category.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
for site in &self.system_sites {
|
||||
if !site.source.is_system() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"system-sites.json 里的网站「{}」source 不是 system",
|
||||
site.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
for site in &self.user_sites {
|
||||
if site.source.is_system() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"user-sites.json 里的网站「{}」source 不能是 system",
|
||||
site.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. id 不能重复。
|
||||
ensure_unique(self.categories().map(|c| c.id.as_str()), "分组")?;
|
||||
ensure_unique(self.sites().map(|s| s.id.as_str()), "网站")?;
|
||||
|
||||
// 3. 分组层级:parentId 必须存在、必须是一级分组、不能自指。
|
||||
for category in self.categories() {
|
||||
if category.parent_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if category.parent_id == category.id {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"分组「{}」的 parentId 指向了自己",
|
||||
category.id
|
||||
)));
|
||||
}
|
||||
let parent = self.find_category(&category.parent_id).ok_or_else(|| {
|
||||
StoreError::Integrity(format!(
|
||||
"分组「{}」的 parentId「{}」不存在",
|
||||
category.id, category.parent_id
|
||||
))
|
||||
})?;
|
||||
if !parent.is_root() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"分组「{}」挂在子分组「{}」下,层级最多两级",
|
||||
category.id, parent.id
|
||||
)));
|
||||
}
|
||||
if parent.source.is_system() != category.source.is_system() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"分组「{}」和它的父分组「{}」来源不一致",
|
||||
category.id, parent.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 每个网站都要挂在一个存在的分组上,而且来源要对得上:
|
||||
// 内置网站只能在内置分组下,提交的网站只能在用户分组下。
|
||||
for site in &self.system_sites {
|
||||
let category = self.require_category(&site.category_id)?;
|
||||
if !category.source.is_system() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"内置网站「{}」挂在了用户分组「{}」下",
|
||||
site.id, category.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
for site in &self.user_sites {
|
||||
let category = self.require_category(&site.category_id)?;
|
||||
if category.source.is_system() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"提交的网站「{}」挂在了内置分组「{}」下",
|
||||
site.id, category.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统计信息,给健康检查用。
|
||||
pub fn summary(&self) -> Summary {
|
||||
Summary {
|
||||
sites: self.system_sites.len() + self.user_sites.len(),
|
||||
system_sites: self.system_sites.len(),
|
||||
user_sites: self.user_sites.len(),
|
||||
categories: self.categories().count(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 侧栏 + 内容区需要的那棵树。
|
||||
pub fn nav(&self) -> NavResponse {
|
||||
let mut roots: Vec<&Category> =
|
||||
self.categories().filter(|c| c.is_root()).collect();
|
||||
sort_categories(&mut roots);
|
||||
NavResponse {
|
||||
site: self.site.clone(),
|
||||
groups: roots.into_iter().map(|c| self.group_view(c)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增一个提交 / 推送的网站。
|
||||
pub fn create(
|
||||
&mut self,
|
||||
request: &CreateSiteRequest,
|
||||
source: Source,
|
||||
) -> Result<Site, ApiError> {
|
||||
if source.is_system() {
|
||||
return Err(ApiError::internal("新增接口不接受 system 来源"));
|
||||
}
|
||||
let draft = validate::submission(request)?;
|
||||
|
||||
// 先改副本,落盘成功后再替换内存:写盘失败时不会留下「内存有、磁盘没有」的状态。
|
||||
let mut categories = self.user_categories.clone();
|
||||
let mut sites = self.user_sites.clone();
|
||||
|
||||
let category_id = resolve_user_group(&mut categories, &draft)?;
|
||||
let order = next_order(&sites, &category_id);
|
||||
let now = now_iso8601();
|
||||
let site = Site {
|
||||
id: new_id(site_prefix(source), &sites),
|
||||
title: draft.title,
|
||||
url: draft.url,
|
||||
description: draft.description,
|
||||
category_id,
|
||||
source,
|
||||
order,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
submitted_by: draft.submitted_by,
|
||||
};
|
||||
sites.push(site.clone());
|
||||
|
||||
write_list(&self.paths.user_categories, &categories)?;
|
||||
write_list(&self.paths.user_sites, &sites)?;
|
||||
self.user_categories = categories;
|
||||
self.user_sites = sites;
|
||||
Ok(site)
|
||||
}
|
||||
|
||||
/// 修改一个网站。系统内置的直接 403。
|
||||
pub fn update(&mut self, id: &str, request: &UpdateSiteRequest) -> Result<Site, ApiError> {
|
||||
let patch = validate::patch(request)?;
|
||||
let index = self.user_sites.iter().position(|s| s.id == id);
|
||||
let Some(index) = index else {
|
||||
if self.system_sites.iter().any(|s| s.id == id) {
|
||||
return Err(ApiError::forbidden(format!(
|
||||
"「{id}」是系统内置网站,不能修改"
|
||||
)));
|
||||
}
|
||||
return Err(ApiError::not_found(format!("找不到网站:{id}")));
|
||||
};
|
||||
|
||||
let mut categories = self.user_categories.clone();
|
||||
let mut sites = self.user_sites.clone();
|
||||
|
||||
let moved_to = if let Some(group) = &patch.group {
|
||||
let sub = patch.sub_group.clone().unwrap_or_default();
|
||||
Some(resolve_named_group(&mut categories, group, &sub)?)
|
||||
} else if let Some(sub) = &patch.sub_group {
|
||||
if sub.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// 只给子分组:沿用条目当前所属的一级分组。
|
||||
let root = root_title_of(&categories, &sites[index].category_id)
|
||||
.ok_or_else(|| ApiError::internal("当前条目的一级分组找不到"))?;
|
||||
Some(resolve_named_group(&mut categories, &root, sub)?)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 排序值要在拿到 `sites[index]` 的可变借用之前算好,否则借用冲突。
|
||||
let moved_order = moved_to.as_ref().map(|id| next_order(&sites, id));
|
||||
|
||||
let site = &mut sites[index];
|
||||
apply_patch(site, patch);
|
||||
if let Some(category_id) = moved_to {
|
||||
if let Some(order) = moved_order {
|
||||
site.order = order;
|
||||
}
|
||||
site.category_id = category_id;
|
||||
}
|
||||
site.updated_at = now_iso8601();
|
||||
let updated = site.clone();
|
||||
|
||||
write_list(&self.paths.user_categories, &categories)?;
|
||||
write_list(&self.paths.user_sites, &sites)?;
|
||||
self.user_categories = categories;
|
||||
self.user_sites = sites;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 删除一个网站。系统内置的直接 403。
|
||||
pub fn delete(&mut self, id: &str) -> Result<(), ApiError> {
|
||||
if self.system_sites.iter().any(|s| s.id == id) {
|
||||
return Err(ApiError::forbidden(format!(
|
||||
"「{id}」是系统内置网站,不能删除"
|
||||
)));
|
||||
}
|
||||
let mut sites = self.user_sites.clone();
|
||||
let before = sites.len();
|
||||
sites.retain(|s| s.id != id);
|
||||
if sites.len() == before {
|
||||
return Err(ApiError::not_found(format!("找不到网站:{id}")));
|
||||
}
|
||||
write_list(&self.paths.user_sites, &sites)?;
|
||||
self.user_sites = sites;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- 内部 ----
|
||||
|
||||
fn categories(&self) -> impl Iterator<Item = &Category> {
|
||||
self.system_categories.iter().chain(self.user_categories.iter())
|
||||
}
|
||||
|
||||
fn sites(&self) -> impl Iterator<Item = &Site> {
|
||||
self.system_sites.iter().chain(self.user_sites.iter())
|
||||
}
|
||||
|
||||
fn find_category(&self, id: &str) -> Option<&Category> {
|
||||
self.categories().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
fn require_category(&self, id: &str) -> Result<&Category, StoreError> {
|
||||
self.find_category(id).ok_or_else(|| {
|
||||
StoreError::Integrity(format!("分组「{id}」不存在,但有网站挂在它下面"))
|
||||
})
|
||||
}
|
||||
|
||||
fn group_view(&self, category: &Category) -> NavGroupView {
|
||||
let mut children: Vec<&Category> = self
|
||||
.categories()
|
||||
.filter(|c| c.parent_id == category.id)
|
||||
.collect();
|
||||
sort_categories(&mut children);
|
||||
|
||||
let mut sites: Vec<&Site> = self
|
||||
.sites()
|
||||
.filter(|s| s.category_id == category.id)
|
||||
.collect();
|
||||
sort_sites(&mut sites);
|
||||
|
||||
NavGroupView {
|
||||
id: category.id.clone(),
|
||||
title: category.title.clone(),
|
||||
source: category.source,
|
||||
editable: category.is_editable(),
|
||||
sites: sites.into_iter().map(SiteView::from).collect(),
|
||||
children: children.into_iter().map(|c| self.group_view(c)).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康检查 / 启动日志用的统计。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Summary {
|
||||
pub sites: usize,
|
||||
pub system_sites: usize,
|
||||
pub user_sites: usize,
|
||||
pub categories: usize,
|
||||
}
|
||||
|
||||
/// 解析(必要时新建)用户分组,返回网站该挂到的分组 id。
|
||||
///
|
||||
/// 只在传进来的 `user_categories` 里查找——内置分组不参与,
|
||||
/// 所以提交的内容不可能落进内置分组。
|
||||
fn resolve_user_group(
|
||||
categories: &mut Vec<Category>,
|
||||
draft: &SiteDraft,
|
||||
) -> Result<String, ApiError> {
|
||||
resolve_named_group(categories, &draft.group, &draft.sub_group)
|
||||
}
|
||||
|
||||
fn resolve_named_group(
|
||||
categories: &mut Vec<Category>,
|
||||
group: &str,
|
||||
sub_group: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let root_id = match categories.iter().find(|c| c.is_root() && c.title == group) {
|
||||
Some(found) => found.id.clone(),
|
||||
None => push_category(categories, group, "")?,
|
||||
};
|
||||
if sub_group.is_empty() {
|
||||
return Ok(root_id);
|
||||
}
|
||||
match categories
|
||||
.iter()
|
||||
.find(|c| c.parent_id == root_id && c.title == sub_group)
|
||||
{
|
||||
Some(found) => Ok(found.id.clone()),
|
||||
None => push_category(categories, sub_group, &root_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_category(
|
||||
categories: &mut Vec<Category>,
|
||||
title: &str,
|
||||
parent_id: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let id = new_id("g", categories);
|
||||
let order = categories
|
||||
.iter()
|
||||
.filter(|c| c.parent_id == parent_id)
|
||||
.map(|c| c.order)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ ORDER_STEP;
|
||||
let now = now_iso8601();
|
||||
categories.push(Category {
|
||||
id: id.clone(),
|
||||
title: title.to_string(),
|
||||
parent_id: parent_id.to_string(),
|
||||
source: Source::Submission,
|
||||
order,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn apply_patch(site: &mut Site, patch: SitePatch) {
|
||||
if let Some(value) = patch.title {
|
||||
site.title = value;
|
||||
}
|
||||
if let Some(value) = patch.url {
|
||||
site.url = value;
|
||||
}
|
||||
if let Some(value) = patch.description {
|
||||
site.description = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// 找出某个分组所属的一级分组标题。
|
||||
fn root_title_of(categories: &[Category], category_id: &str) -> Option<String> {
|
||||
let category = categories.iter().find(|c| c.id == category_id)?;
|
||||
if category.is_root() {
|
||||
return Some(category.title.clone());
|
||||
}
|
||||
categories
|
||||
.iter()
|
||||
.find(|c| c.id == category.parent_id)
|
||||
.map(|root| root.title.clone())
|
||||
}
|
||||
|
||||
fn next_order(sites: &[Site], category_id: &str) -> i32 {
|
||||
sites
|
||||
.iter()
|
||||
.filter(|s| s.category_id == category_id)
|
||||
.map(|s| s.order)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ ORDER_STEP
|
||||
}
|
||||
|
||||
fn site_prefix(source: Source) -> &'static str {
|
||||
match source {
|
||||
Source::Submission => "s",
|
||||
Source::Api => "a",
|
||||
// 系统内置的 id 是手写的,不会走到这里。
|
||||
Source::System => "sys",
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成一个当前集合里没用过的 id。
|
||||
fn new_id<T: HasId>(prefix: &str, existing: &[T]) -> String {
|
||||
for _ in 0..8 {
|
||||
let candidate = generate_id(prefix);
|
||||
if !existing.iter().any(|item| item.id() == candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// 理论上到不了这里;真撞上了就退化成带纳秒的 id,保证唯一。
|
||||
format!("{prefix}-{}", now_iso8601().replace([':', '-'], ""))
|
||||
}
|
||||
|
||||
trait HasId {
|
||||
fn id(&self) -> &str;
|
||||
}
|
||||
|
||||
impl HasId for Site {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl HasId for Category {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_categories(items: &mut [&Category]) {
|
||||
items.sort_by(|a, b| {
|
||||
a.order
|
||||
.cmp(&b.order)
|
||||
.then_with(|| a.created_at.cmp(&b.created_at))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn sort_sites(items: &mut [&Site]) {
|
||||
items.sort_by(|a, b| {
|
||||
a.order
|
||||
.cmp(&b.order)
|
||||
.then_with(|| a.created_at.cmp(&b.created_at))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_unique<'a>(ids: impl Iterator<Item = &'a str>, label: &str) -> Result<(), StoreError> {
|
||||
let mut seen = HashSet::new();
|
||||
for id in ids {
|
||||
if !seen.insert(id) {
|
||||
return Err(StoreError::Integrity(format!("{label} id 重复:{id}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读一个 JSON 数组。`required` 为 false 时,文件不存在按空数组处理。
|
||||
fn read_list<T: DeserializeOwned>(path: &Path, required: bool) -> Result<Vec<T>, StoreError> {
|
||||
if !path.exists() {
|
||||
if required {
|
||||
return Err(StoreError::Io(format!("找不到数据文件:{}", path.display())));
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let text = fs::read_to_string(path)
|
||||
.map_err(|e| StoreError::Io(format!("{}:{e}", path.display())))?;
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|e| StoreError::Json(format!("{}:{e}", path.display())))
|
||||
}
|
||||
|
||||
/// 读一个 JSON 对象。`required` 为 false 时,文件不存在会报错并要求补上——
|
||||
/// 站点配置是必需项,缺了就说明数据目录不完整。
|
||||
fn read_json<T: DeserializeOwned>(path: &Path, required: bool) -> Result<T, StoreError> {
|
||||
if !path.exists() {
|
||||
if required {
|
||||
return Err(StoreError::Io(format!("找不到数据文件:{}", path.display())));
|
||||
}
|
||||
}
|
||||
let text = fs::read_to_string(path)
|
||||
.map_err(|e| StoreError::Io(format!("{}:{e}", path.display())))?;
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|e| StoreError::Json(format!("{}:{e}", path.display())))
|
||||
}
|
||||
|
||||
/// 原子写:先写临时文件再改名,避免写到一半断电把数据文件写坏。
|
||||
fn write_list<T: Serialize>(path: &Path, value: &[T]) -> Result<(), StoreError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| StoreError::Io(format!("{}:{e}", parent.display())))?;
|
||||
}
|
||||
let mut text = serde_json::to_string_pretty(value)
|
||||
.map_err(|e| StoreError::Json(e.to_string()))?;
|
||||
text.push('\n');
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, text).map_err(|e| StoreError::Io(format!("{}:{e}", tmp.display())))?;
|
||||
fs::rename(&tmp, path).map_err(|e| StoreError::Io(format!("{}:{e}", path.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 造一份最小的种子数据,供单元测试与接口测试共用。
|
||||
pub fn seed_for_test(dir: &Path) {
|
||||
fs::write(
|
||||
dir.join("site.json"),
|
||||
r#"{ "name": "Suxing Nav", "searchHint": "输入关键字搜索全网资源..." }"#,
|
||||
)
|
||||
.expect("写测试数据失败");
|
||||
fs::write(
|
||||
dir.join("system-categories.json"),
|
||||
r#"[
|
||||
{ "id": "common", "title": "常用推荐", "parentId": "", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" },
|
||||
{ "id": "materials", "title": "素材资源", "parentId": "", "source": "system", "order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" },
|
||||
{ "id": "design", "title": "UI 设计", "parentId": "materials", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" }
|
||||
]"#,
|
||||
)
|
||||
.expect("写测试数据失败");
|
||||
fs::write(
|
||||
dir.join("system-sites.json"),
|
||||
r#"[
|
||||
{ "id": "github", "title": "GitHub", "url": "https://github.com", "description": "代码托管与开源社区",
|
||||
"categoryId": "common", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z", "submittedBy": "" },
|
||||
{ "id": "zhihu", "title": "知乎", "url": "https://www.zhihu.com", "description": "深度内容问答社区",
|
||||
"categoryId": "common", "source": "system", "order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z", "submittedBy": "" },
|
||||
{ "id": "figma", "title": "Figma", "url": "https://www.figma.com", "description": "协同界面设计工具",
|
||||
"categoryId": "design", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z", "submittedBy": "" }
|
||||
]"#,
|
||||
)
|
||||
.expect("写测试数据失败");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
fs::write(path, text).expect("写测试数据失败");
|
||||
}
|
||||
|
||||
fn store_in(temp: &TempDir) -> Store {
|
||||
seed_for_test(temp.path());
|
||||
let store = Store::load(temp.path()).expect("加载失败");
|
||||
store.verify().expect("自检失败");
|
||||
store
|
||||
}
|
||||
|
||||
fn submission(title: &str, url: &str, group: &str, sub_group: &str) -> CreateSiteRequest {
|
||||
CreateSiteRequest {
|
||||
title: title.to_string(),
|
||||
url: url.to_string(),
|
||||
description: "一句话描述".to_string(),
|
||||
group: group.to_string(),
|
||||
sub_group: sub_group.to_string(),
|
||||
submitted_by: "[email protected]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_seed_data_and_builds_nav_tree() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = store_in(&temp);
|
||||
|
||||
let nav = store.nav();
|
||||
assert_eq!(nav.groups.len(), 2, "两个一级分组");
|
||||
assert_eq!(nav.groups[0].id, "common");
|
||||
assert_eq!(nav.groups[0].sites.len(), 2, "common 下有两条");
|
||||
assert_eq!(nav.groups[1].id, "materials");
|
||||
assert_eq!(nav.groups[1].children.len(), 1);
|
||||
assert_eq!(nav.groups[1].children[0].id, "design");
|
||||
assert_eq!(nav.groups[1].children[0].sites[0].site.id, "figma");
|
||||
assert!(!nav.groups[1].children[0].sites[0].editable, "内置条目不可编辑");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_creates_its_own_group_and_lands_there() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
|
||||
let site = store
|
||||
.create(&submission("Hugging Face", "https://huggingface.co", "AI 工具", "模型社区"), Source::Submission)
|
||||
.expect("提交应当成功");
|
||||
|
||||
assert!(site.id.starts_with("s-"), "用户提交的 id 用 s- 前缀:{}", site.id);
|
||||
assert!(site.category_id.starts_with("g-"), "分组是新建的:{}", site.category_id);
|
||||
assert_eq!(site.source, Source::Submission);
|
||||
assert!(site.is_editable());
|
||||
|
||||
// 新分组挂在第一位,且是两级结构。
|
||||
let nav = store.nav();
|
||||
let created_root = nav.groups.iter().find(|g| g.id != "common" && g.id != "materials").unwrap();
|
||||
assert_eq!(created_root.title, "AI 工具");
|
||||
assert_eq!(created_root.children[0].title, "模型社区");
|
||||
assert_eq!(created_root.children[0].sites[0].site.id, site.id);
|
||||
|
||||
// 真的写到磁盘了:重新加载还能看到。
|
||||
let reloaded = Store::load(temp.path()).expect("重新加载失败");
|
||||
reloaded.verify().expect("重新加载后自检失败");
|
||||
assert_eq!(reloaded.summary().user_sites, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_group_name_is_reused() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
|
||||
let first = store
|
||||
.create(&submission("A", "https://a.com", "AI 工具", "模型社区"), Source::Submission)
|
||||
.unwrap();
|
||||
let second = store
|
||||
.create(&submission("B", "https://b.com", "AI 工具", "模型社区"), Source::Submission)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.category_id, second.category_id, "同名分组应当复用");
|
||||
assert_eq!(store.summary().categories, 5, "只多了一级 + 子分组两个");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_can_never_land_in_a_system_group() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
|
||||
// 故意用内置分组的名字提交。
|
||||
let site = store
|
||||
.create(&submission("Fake", "https://fake.com", "素材资源", "UI 设计"), Source::Submission)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(site.category_id, "design", "不能挂进内置子分组");
|
||||
assert!(site.category_id.starts_with("g-"), "应当在用户分组里");
|
||||
|
||||
// 内置分组下的内容没被动过。
|
||||
let nav = store.nav();
|
||||
let materials = nav.groups.iter().find(|g| g.id == "materials").unwrap();
|
||||
assert_eq!(materials.children[0].id, "design");
|
||||
assert_eq!(materials.children[0].sites.len(), 1, "design 下仍然只有 Figma");
|
||||
assert!(materials.children[0].sites[0].site.id == "figma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_sites_cannot_be_updated_or_deleted() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
|
||||
let update = UpdateSiteRequest {
|
||||
title: Some("改个名字".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let err = store.update("github", &update).unwrap_err();
|
||||
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
|
||||
let err = store.delete("github").unwrap_err();
|
||||
assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_sites_can_be_updated_and_deleted() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
let site = store
|
||||
.create(&submission("A", "https://a.com", "AI 工具", ""), Source::Submission)
|
||||
.unwrap();
|
||||
|
||||
let update = UpdateSiteRequest {
|
||||
title: Some("新名字".to_string()),
|
||||
url: Some("https://a2.com".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let updated = store.update(&site.id, &update).unwrap();
|
||||
assert_eq!(updated.title, "新名字");
|
||||
assert_eq!(updated.url, "https://a2.com");
|
||||
assert_eq!(updated.category_id, site.category_id, "没传 group 就不换分组");
|
||||
|
||||
store.delete(&site.id).unwrap();
|
||||
assert_eq!(store.summary().user_sites, 0);
|
||||
|
||||
let err = store.delete(&site.id).unwrap_err();
|
||||
assert_eq!(err.status(), axum::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_can_move_a_site_between_user_groups() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
let site = store
|
||||
.create(&submission("A", "https://a.com", "AI 工具", ""), Source::Submission)
|
||||
.unwrap();
|
||||
|
||||
let update = UpdateSiteRequest {
|
||||
group: Some("效率工具".to_string()),
|
||||
sub_group: Some("笔记".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let updated = store.update(&site.id, &update).unwrap();
|
||||
assert_ne!(updated.category_id, site.category_id);
|
||||
|
||||
let nav = store.nav();
|
||||
let moved = nav
|
||||
.groups
|
||||
.iter()
|
||||
.find(|g| g.title == "效率工具")
|
||||
.expect("新分组应当存在");
|
||||
assert_eq!(moved.children[0].title, "笔记");
|
||||
assert_eq!(moved.children[0].sites[0].site.id, site.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_a_tampered_file() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
seed_for_test(temp.path());
|
||||
// 把内置网站标成 api 来源,模拟手改 JSON 越权。
|
||||
write(
|
||||
&temp.path().join("system-sites.json"),
|
||||
r#"[{ "id": "github", "title": "GitHub", "url": "https://github.com",
|
||||
"categoryId": "common", "source": "api",
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" }]"#,
|
||||
);
|
||||
let store = Store::load(temp.path()).expect("加载应当成功");
|
||||
let err = store.verify().unwrap_err();
|
||||
assert!(err.to_string().contains("source"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_user_site_under_system_group() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
seed_for_test(temp.path());
|
||||
write(
|
||||
&temp.path().join("user-sites.json"),
|
||||
r#"[{ "id": "s-1", "title": "A", "url": "https://a.com",
|
||||
"categoryId": "common", "source": "submission",
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" }]"#,
|
||||
);
|
||||
let store = Store::load(temp.path()).expect("加载应当成功");
|
||||
let err = store.verify().unwrap_err();
|
||||
assert!(err.to_string().contains("内置分组"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_three_level_groups() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
seed_for_test(temp.path());
|
||||
write(
|
||||
&temp.path().join("system-categories.json"),
|
||||
r#"[{ "id": "materials", "title": "素材资源", "parentId": "", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" },
|
||||
{ "id": "design", "title": "UI 设计", "parentId": "materials", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" },
|
||||
{ "id": "deep", "title": "更深一层", "parentId": "design", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" }]"#,
|
||||
);
|
||||
write(&temp.path().join("system-sites.json"), "[]");
|
||||
let store = Store::load(temp.path()).expect("加载应当成功");
|
||||
let err = store.verify().unwrap_err();
|
||||
assert!(err.to_string().contains("两级"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_user_files_are_treated_as_empty() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
seed_for_test(temp.path());
|
||||
let store = Store::load(temp.path()).expect("加载失败");
|
||||
store.verify().expect("自检失败");
|
||||
assert_eq!(store.summary().user_sites, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 与业务无关的小工具:时间格式化与 id 生成。
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
/// 进程内自增计数,用来保证同一纳秒内生成的 id 也不重复。
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 当前时间的 ISO 8601 UTC 字符串,例如 `2026-09-19T06:02:11Z`。
|
||||
///
|
||||
/// 统一用 UTC:客户端拿到的时刻永远无歧义,展示成哪个时区是前端的事。
|
||||
/// 截掉秒以下的小数位,和手写的种子数据保持同一种格式。
|
||||
pub fn now_iso8601() -> String {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
// `replace_nanosecond` 返回 Result(纳秒可能越界),0 一定合法,取不到就用原值。
|
||||
let now = now.replace_nanosecond(0).unwrap_or(now);
|
||||
now.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
/// 今天的日期,格式 `20260919`,只用来拼可读的 id。
|
||||
pub fn today_compact() -> String {
|
||||
let today = now_iso8601();
|
||||
today.get(0..10).unwrap_or("1970-01-01").replace('-', "")
|
||||
}
|
||||
|
||||
/// 生成 `<prefix>-<日期>-<6 位十六进制>` 形式的 id。
|
||||
///
|
||||
/// 日期段让人一眼看出创建时间;随机段用「纳秒 + 自增计数」混合后取低 24 位,
|
||||
/// 不引第三方随机数库,也不依赖操作系统的随机源。
|
||||
pub fn generate_id(prefix: &str) -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let counter = COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed);
|
||||
let mixed = splitmix64(nanos ^ counter);
|
||||
format!("{prefix}-{}-{:06x}", today_compact(), mixed & 0xFF_FFFF)
|
||||
}
|
||||
|
||||
/// splitmix64:把低位不稳定的输入(纳秒)打散,让取出来的后 24 位分布更均匀。
|
||||
fn splitmix64(seed: u64) -> u64 {
|
||||
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// 字符数(不是字节数)。
|
||||
///
|
||||
/// 用 `chars()` 而不是 `len()`:中文一个字是 3 个字节,按字节数判断长度会误判。
|
||||
pub fn char_count(value: &str) -> usize {
|
||||
value.trim().chars().count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn now_iso8601_uses_utc_and_seconds_precision() {
|
||||
let now = now_iso8601();
|
||||
assert!(now.ends_with('Z'), "should end with Z: {now}");
|
||||
assert_eq!(now.len(), 20, "should look like 2026-09-19T06:02:11Z: {now}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn today_compact_has_no_separators() {
|
||||
let today = today_compact();
|
||||
assert_eq!(today.len(), 8, "should look like 20260919: {today}");
|
||||
assert!(today.chars().all(|c| c.is_ascii_digit()), "{today}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_id_is_prefixed_and_unique() {
|
||||
let first = generate_id("s");
|
||||
let second = generate_id("s");
|
||||
assert!(first.starts_with("s-"), "{first}");
|
||||
assert_eq!(first.len(), 17, "s-20260919-8f3a1c: {first}");
|
||||
assert_ne!(first, second, "连续两次生成不应该相同");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_count_counts_chars_not_bytes() {
|
||||
assert_eq!(char_count(" 知乎 "), 2);
|
||||
assert_eq!(char_count("abc"), 3);
|
||||
assert_eq!(char_count(" "), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! 写入前的校验与归一化。
|
||||
//!
|
||||
//! 所有外部写入(用户提交、接口推送、修改)都必须先过这里,
|
||||
//! 保证进内存的数据是干净的:空白已去掉、长度有上限、url 可用。
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::model::{CreateSiteRequest, UpdateSiteRequest};
|
||||
use crate::util::char_count;
|
||||
|
||||
/// 字段长度上限,集中放在这里,改规则只改这一处。
|
||||
const MAX_TITLE_CHARS: usize = 60;
|
||||
const MAX_DESCRIPTION_CHARS: usize = 120;
|
||||
const MAX_GROUP_CHARS: usize = 30;
|
||||
const MAX_SUBMITTED_BY_CHARS: usize = 120;
|
||||
|
||||
/// 校验并归一化之后的提交内容。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SiteDraft {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub description: String,
|
||||
pub group: String,
|
||||
pub sub_group: String,
|
||||
pub submitted_by: String,
|
||||
}
|
||||
|
||||
/// 校验并归一化之后的修改内容;`None` 表示这个字段不改。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SitePatch {
|
||||
pub title: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub group: Option<String>,
|
||||
pub sub_group: Option<String>,
|
||||
}
|
||||
|
||||
impl SitePatch {
|
||||
/// 这次修改是不是什么都没带。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.title.is_none()
|
||||
&& self.url.is_none()
|
||||
&& self.description.is_none()
|
||||
&& self.group.is_none()
|
||||
&& self.sub_group.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验提交 / 推送的入参。
|
||||
pub fn submission(request: &CreateSiteRequest) -> Result<SiteDraft, ApiError> {
|
||||
Ok(SiteDraft {
|
||||
title: title(&request.title)?,
|
||||
url: url(&request.url)?,
|
||||
description: description(&request.description)?,
|
||||
group: group(&request.group)?,
|
||||
sub_group: optional_group("subGroup", &request.sub_group)?,
|
||||
submitted_by: submitted_by(&request.submitted_by)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 校验修改的入参。
|
||||
pub fn patch(request: &UpdateSiteRequest) -> Result<SitePatch, ApiError> {
|
||||
let mut result = SitePatch::default();
|
||||
if let Some(value) = &request.title {
|
||||
result.title = Some(title(value)?);
|
||||
}
|
||||
if let Some(value) = &request.url {
|
||||
result.url = Some(url(value)?);
|
||||
}
|
||||
if let Some(value) = &request.description {
|
||||
result.description = Some(description(value)?);
|
||||
}
|
||||
if let Some(value) = &request.group {
|
||||
result.group = Some(group(value)?);
|
||||
}
|
||||
if let Some(value) = &request.sub_group {
|
||||
result.sub_group = Some(optional_group("subGroup", value)?);
|
||||
}
|
||||
if result.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"请求里没有任何可修改的字段:title / url / description / group / subGroup 至少要给一个",
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn title(raw: &str) -> Result<String, ApiError> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return Err(ApiError::bad_request("title 不能为空"));
|
||||
}
|
||||
if char_count(value) > MAX_TITLE_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"title 最多 {MAX_TITLE_CHARS} 个字符,收到 {} 个",
|
||||
char_count(value)
|
||||
)));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn description(raw: &str) -> Result<String, ApiError> {
|
||||
let value = raw.trim();
|
||||
if char_count(value) > MAX_DESCRIPTION_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"description 最多 {MAX_DESCRIPTION_CHARS} 个字符,收到 {} 个",
|
||||
char_count(value)
|
||||
)));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn group(raw: &str) -> Result<String, ApiError> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"group 不能为空:提交的网站必须自带一个分组名,不能放进内置分组",
|
||||
));
|
||||
}
|
||||
if char_count(value) > MAX_GROUP_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"group 最多 {MAX_GROUP_CHARS} 个字符,收到 {} 个",
|
||||
char_count(value)
|
||||
)));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn optional_group(field: &str, raw: &str) -> Result<String, ApiError> {
|
||||
let value = raw.trim();
|
||||
if char_count(value) > MAX_GROUP_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"{field} 最多 {MAX_GROUP_CHARS} 个字符,收到 {} 个",
|
||||
char_count(value)
|
||||
)));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn submitted_by(raw: &str) -> Result<String, ApiError> {
|
||||
let value = raw.trim();
|
||||
if char_count(value) > MAX_SUBMITTED_BY_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"submittedBy 最多 {MAX_SUBMITTED_BY_CHARS} 个字符"
|
||||
)));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
/// 地址必须是可解析的 http(s),且不能指向本机或内网。
|
||||
///
|
||||
/// 后端并不会去抓这个地址,这条规则是为了保持收录的数据干净:
|
||||
/// 内网地址、localhost 对访客没有意义,出现在导航站里只会是坏链接。
|
||||
fn url(raw: &str) -> Result<String, ApiError> {
|
||||
let value = raw.trim();
|
||||
let parsed = Url::parse(value)
|
||||
.map_err(|_| ApiError::bad_request(format!("url 不是合法地址:{value}")))?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
other => {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"url 协议只能是 http 或 https,收到 {other}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| ApiError::bad_request("url 缺少主机名"))?;
|
||||
if is_internal_host(host) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"url 不能是本机或内网地址:{host}"
|
||||
)));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn is_internal_host(host: &str) -> bool {
|
||||
let host = host.to_ascii_lowercase();
|
||||
if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
// IPv6 的 host_str() 带方括号(例如 `[::1]`),去掉后才能当 IP 解析。
|
||||
let bare = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|value| value.strip_suffix(']'))
|
||||
.unwrap_or(&host);
|
||||
match bare.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V4(v4)) => {
|
||||
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
|
||||
}
|
||||
Ok(IpAddr::V6(v6)) => v6.is_loopback() || v6.is_unspecified(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request(title: &str, url: &str, group: &str) -> CreateSiteRequest {
|
||||
CreateSiteRequest {
|
||||
title: title.to_string(),
|
||||
url: url.to_string(),
|
||||
description: String::new(),
|
||||
group: group.to_string(),
|
||||
sub_group: String::new(),
|
||||
submitted_by: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_submission_and_trims() {
|
||||
let draft = submission(&request(" Hugging Face ", " https://huggingface.co ", " AI 工具 "))
|
||||
.expect("应当通过");
|
||||
assert_eq!(draft.title, "Hugging Face");
|
||||
assert_eq!(draft.url, "https://huggingface.co");
|
||||
assert_eq!(draft.group, "AI 工具");
|
||||
assert_eq!(draft.sub_group, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_title() {
|
||||
let err = submission(&request(" ", "https://a.com", "组")).unwrap_err();
|
||||
assert_eq!(err.status(), axum::http::StatusCode::BAD_REQUEST);
|
||||
assert!(err.message().contains("title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long_title_counting_chars() {
|
||||
let long = "一".repeat(MAX_TITLE_CHARS + 1);
|
||||
assert!(submission(&request(&long, "https://a.com", "组")).is_err());
|
||||
let exact = "一".repeat(MAX_TITLE_CHARS);
|
||||
assert!(submission(&request(&exact, "https://a.com", "组")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_group() {
|
||||
let err = submission(&request("标题", "https://a.com", " ")).unwrap_err();
|
||||
assert!(err.message().contains("group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_scheme() {
|
||||
assert!(submission(&request("标题", "ftp://a.com", "组")).is_err());
|
||||
assert!(submission(&request("标题", "javascript:alert(1)", "组")).is_err());
|
||||
assert!(submission(&request("标题", "不是地址", "组")).is_err());
|
||||
assert!(submission(&request("标题", "http://a.com", "组")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_internal_hosts() {
|
||||
for url in [
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1",
|
||||
"http://10.0.0.5",
|
||||
"http://192.168.1.10",
|
||||
"http://172.16.0.1",
|
||||
"http://169.254.0.1",
|
||||
"http://0.0.0.0",
|
||||
"http://[::1]",
|
||||
"http://nas.local",
|
||||
] {
|
||||
assert!(
|
||||
submission(&request("标题", url, "组")).is_err(),
|
||||
"{url} 应当被拒绝"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long_description() {
|
||||
let mut req = request("标题", "https://a.com", "组");
|
||||
req.description = "字".repeat(MAX_DESCRIPTION_CHARS + 1);
|
||||
assert!(submission(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long_sub_group() {
|
||||
let mut req = request("标题", "https://a.com", "组");
|
||||
req.sub_group = "字".repeat(MAX_GROUP_CHARS + 1);
|
||||
assert!(submission(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_requires_at_least_one_field() {
|
||||
let empty = UpdateSiteRequest::default();
|
||||
assert!(patch(&empty).is_err());
|
||||
|
||||
let with_title = UpdateSiteRequest {
|
||||
title: Some(" 新名字 ".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = patch(&with_title).expect("应当通过");
|
||||
assert_eq!(result.title.as_deref(), Some("新名字"));
|
||||
assert!(result.url.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user