Files
yoresee_dropbox/docs/superpowers/specs/2026-08-26-ydropbox-design.md

166 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# yDropbox — 文件收发站设计规格
> 日期: 2026-08-26
> 状态: 已用户批准
> 项目名: `yoresee_dropbox`(Go 模块 `yoresee_dropbox`,部署二进制 `yDropbox`)
## 1. 概述
一个自托管单用户文件收发站:用户通过浏览器上传/下载文件,无需 SSH。服务器上的本地程序(自动化脚本、CLI 工具等)通过共享文件系统目录与 Web 端交换文件:
- 用户 Web 上传 → 落入 `workspace/inbox/`,供本地程序消费。
- 本地程序产出 → 写入 `workspace/outbox/`,用户在页面下载。
- 支持生成公开分享链接(可选密码、可选有效期)。
**目标**
- 单 Go 二进制 + `go:embed` 内嵌前端,零外部依赖。
- 上传、列表、下载、删除、分享链接全流程可用。
- 服务器本地程序零 API 依赖(直接读写目录即可集成)。
**非目标**
- 不做多用户/权限体系。
- 不做断点续传、分块上传。
- 不做在线预览/编辑。
## 2. 架构
- **进程**: 单个 Go 二进制 `yDropbox`,HTTP 服务默认监听 `127.0.0.1:8999`(仅本机回环),`--addr` 可配。
- **对外**: 反向代理服务器上由 nginx 将 HTTPS 子域名反代至该端口;证书由 certbot 签发续期。
- **文件存储**: 文件系统两目录 —— `workspace/inbox/`、`workspace/outbox/`。本地程序与 Web 服务共享此目录。
- **元数据**: SQLite(`modernc.org/sqlite`,纯 Go 免 CGO),存文件记录、分享链接、密码哈希、有效期。数据库文件位于 `workspace/.ydropbox/db.sqlite`。
- **前端**: Alpine.js(vendor 单文件 `alpine.min.js` 随仓库提交,`go:embed` 内嵌),声明式响应式绑定(`x-data`/`x-for`/`x-show`),无构建链;不引 CDN,保持零外部依赖。
## 3. API 接口定义
后端用 Go 标准库 `net/http` + `ServeMux`(Go 1.22+ 方法+路径模式),不引第三方路由。所有 API 返回 JSON(`Content-Type: application/json`);上传与分享下载除外。
| 路由 | 方法 | 请求 | 成功响应 | 失败 |
|------|------|------|---------|------|
| `/login` | GET | — | 登录页 | — |
| `/api/login` | POST | `{"token":"..."}` | `200 {}` + Set-Cookie | 401 |
| `/api/logout` | POST | — | `200 {}` 清 cookie | 401 |
| `/api/upload` | POST | multipart `file=@...` | `201 {"file":{...}}` | 401/413/400 |
| `/api/files` | GET | `?dir=inbox\|outbox` 可选过滤 | `200 {"files":[...]}` | 401 |
| `/api/files/{id}/download` | GET | — | 文件流(Content-Disposition 原始名) | 401/404 |
| `/api/files/{id}` | DELETE | — | `204` | 401/404 |
| `/api/files/{id}/share` | POST | `{"password":"可选","expires_in_hours":N 或 "expires_at":ts,均可省略}` | `201 {"url":"/s/<token>",...}` | 401/404/400 |
| `/api/shares` | GET | — | `200 {"shares":[...]}` 活跃分享列表 | 401 |
| `/api/shares/{id}` | DELETE | — | `204` 撤销分享 | 401/404 |
| `/s/{token}` | GET | 密码时先 `POST /s/{token}` `{"password":"..."}` | 文件流 / 密码页 | 403/410/404 |
**file 对象结构**(列表与上传返回统一):
```json
{"id":"uuid","name":"report.pdf","dir":"inbox","size":1048576,"created_at":1750000000}
```
**说明**
- 未登录访问页面类路由 → 302 `/login`;未登录访问 `/api/*` → 401 JSON。
- 上传限 100MB,超限 413。
- 分享有密码且未验证时,GET 返回密码输入页;密码校验通过后签发短期 share cookie 再放行下载流。
- `GET /api/shares` 用于页面管理已创建的分享(撤销入口依赖此列表能力)。
## 4. 数据模型
SQLite 两张表:
```sql
-- 文件记录
CREATE TABLE files (
id TEXT PRIMARY KEY, -- UUIDv4
original_name TEXT NOT NULL, -- 用户原始文件名(展示/下载用)
storage_name TEXT NOT NULL UNIQUE, -- 落盘名 = UUID(无扩展名)
dir TEXT NOT NULL CHECK (dir IN ('inbox','outbox')),
size INTEGER NOT NULL,
created_at INTEGER NOT NULL -- Unix 秒
);
-- 分享链接
CREATE TABLE shares (
id TEXT PRIMARY KEY, -- UUIDv4
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE, -- crypto/rand 24 字节 → base64url
password_hash TEXT, -- bcrypt;NULL = 无密码
expires_at INTEGER, -- Unix 秒;NULL = 永不过期
created_at INTEGER NOT NULL,
last_accessed_at INTEGER -- 可选审计字段
);
```
**设计要点**
- **存储与元数据分离**: 落盘文件名用纯 UUID,杜绝 path traversal 与重名冲突;原始文件名只存库,下载时写入 `Content-Disposition`,MIME 类型按 `original_name` 扩展名推断(默认 `application/octet-stream`)。
- **token 强度**: 24 字节 `crypto/rand` → base64url(32 字符),不可枚举;密码 bcrypt cost 10。
- **有效期语义**: 未设置则长期有效;访问时校验 `expires_at`,过期返回 410 并删除该分享记录。
- **级联删除**: 文件删除时其所有分享随 `ON DELETE CASCADE` 失效。
- **目录同步**: 后台 goroutine 每 10s 扫描 `inbox/` 与 `outbox/` 两个目录(对称扫描——本地程序手动放进 inbox 的文件也能出现在页面),以磁盘为准做增删对账:新文件入库、已消失的文件删记录(级联撤销分享)。
## 5. 鉴权与安全
**鉴权模型**
- **登录凭证**: 启动时传入访问 token —— `yDropbox listen --token=<T>` 或环境变量 `YDROPBOX_TOKEN`。token 仅存在内存,用于 `/api/login` 单次校验。
- **session**: 校验通过后签发 httpOnly cookie(名 `ydropbox_session`,值 = 32 字节随机 id),session 映射存内存,进程重启即全部失效;属性 `HttpOnly; SameSite=Lax; Secure`(Secure 由反代 HTTPS 场景决定)。
- **session 过期**: 默认 7 天滑动过期,登出即删。
- **分享密码**: `POST /s/{token}` 校验 bcrypt 后,签发独立短期 cookie(仅绑定该 share id,有效期 1h),后续 GET 凭此放行下载流。
**安全措施**
| 威胁 | 对策 |
|------|------|
| 路径穿越 | 落盘/读取一律用 UUID 文件名,用户输入的文件名永不参与路径拼接 |
| 分享枚举 | 24 字节 CSPRNG token |
| 密码泄露 | bcrypt 哈希存储,日志与响应永不回显 |
| CSRF | API 全部要求 JSON Content-Type + SameSite=Lax cookie;上传 multipart 为例外但需 session cookie 且由同源页面发起 |
| 暴力破解 | `/api/login` 与分享密码校验均加固定延迟 + 连续 5 次失败锁定 60s(内存计数) |
| 大文件打爆内存 | `http.MaxBytesReader` 100MB 流式落盘;nginx 侧 `client_max_body_size 100m` 双保险 |
| 敏感头泄露 | 下载响应带 `X-Content-Type-Options: nosniff`;分享下载带 `Content-Disposition: attachment` |
**错误码约定**: 401 未登录 / 403 密码错或锁定 / 404 不存在 / 410 分享过期 / 413 超限。
## 6. 测试
**单元测试**(标准库 `testing` + `httptest`)
- **store 层**: 文件 CRUD、分享创建/撤销/级联失效、有效期判断(永不过期/时长到期/指定时刻过期)、bcrypt 校验。
- **handler 层**: 登录成功/失败与锁定、未登录 401、页面 302、上传 201 与超限 413、下载 Content-Disposition 正确性、删除 204 与分享级联、分享密码错 403、过期 410、scanner 对账逻辑(临时目录模拟新增/消失)。
**集成测试**
- 本地起服务,脚本走全链路:登录 → 上传 → 列表 → 下载 → 建分享 → 无密码公开下载 → 撤销后 404。
- 部署后浏览器人工走查一遍 HTTPS 全流程。
## 7. 部署
1. **构建**: `CGO_ENABLED=0 go build -o yDropbox ./cmd/ydropbox`,纯静态单文件。
2. **运行**: systemd 服务,`ExecStart=/usr/local/bin/yDropbox listen --addr=127.0.0.1:8999`,`YDROPBOX_TOKEN` 经 systemd 环境或 credentials 注入;数据目录指向服务器上固定路径(如 `/var/lib/ydropbox/workspace`,`--workspace` 可配)。
3. **反代**: nginx 站点将 HTTPS 子域名反代至 `127.0.0.1:8999`,配置 `client_max_body_size 100m`,certbot 签发续期证书。
4. **DNS**: 子域名 A 记录指向反代服务器公网 IP(具体域名/IP 由部署时确定,不入仓库文档)。
## 8. 目录结构
```
yoresee_dropbox/
├── cmd/ydropbox/main.go # 入口: 仅 flag/env 解析 → app.Run(cfg)
├── go.mod / go.sum
├── internal/
│ ├── app/app.go # 装配: 开库、建路由(server.New)、起 scanner、监听
│ ├── server/
│ │ ├── server.go # New(): 路由注册
│ │ ├── auth.go # 登录/session/锁定
│ │ ├── files.go # 上传/列表/下载/删除
│ │ └── share.go # 分享创建/撤销/公开访问
│ ├── store/store.go # SQLite 层 (modernc.org/sqlite)
│ └── scanner/scanner.go # inbox+outbox 目录对账
├── web/
│ ├── web.go # //go:embed 挂载点,导出 FS
│ ├── index.html
│ ├── style.css
│ ├── app.js
│ └── alpine.min.js # vendor
└── workspace/ # 运行期生成(gitignore): inbox/, outbox/, .ydropbox/db.sqlite
```