Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
139 lines
4.2 KiB
Go
139 lines
4.2 KiB
Go
package handler
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 文件名只用于展示与下载头;磁盘路径由 sha256 派生,
|
||
// 因此这里守的是「不污染 HTTP 头、不被当成目录」而非路径穿越。
|
||
func TestSanitizeFilename(t *testing.T) {
|
||
cases := []struct{ in, want string }{
|
||
{"report.pdf", "report.pdf"},
|
||
{"中文 文件名.txt", "中文 文件名.txt"},
|
||
|
||
// 目录成分必须剥掉(含 Windows 风格)
|
||
{"../../etc/passwd", "passwd"},
|
||
{"/abs/path/x.log", "x.log"},
|
||
{`C:\Users\me\a.txt`, "a.txt"},
|
||
{"a/b/c.txt", "c.txt"},
|
||
|
||
// 控制字符会污染 Content-Disposition
|
||
{"bad\r\nname.txt", "badname.txt"},
|
||
{"tab\there.txt", "tabhere.txt"},
|
||
|
||
// 无意义的名字兜底
|
||
{"", "unnamed"},
|
||
{" ", "unnamed"},
|
||
{".", "unnamed"},
|
||
{"..", "unnamed"},
|
||
{"/", "unnamed"},
|
||
}
|
||
for _, c := range cases {
|
||
if got := sanitizeFilename(c.in); got != c.want {
|
||
t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 超长名字按 UTF-8 边界截断,不产生非法序列。
|
||
func TestSanitizeFilenameTruncates(t *testing.T) {
|
||
long := strings.Repeat("中", 200) + ".txt" // 每字 3 字节,共 600+
|
||
got := sanitizeFilename(long)
|
||
if len(got) > 255 {
|
||
t.Errorf("截断后 %d 字节,超过 255", len(got))
|
||
}
|
||
for _, r := range got {
|
||
if r == '\uFFFD' {
|
||
t.Fatalf("截断产生非法 UTF-8: %q", got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDetectContentType(t *testing.T) {
|
||
cases := []struct{ declared, filename, want string }{
|
||
{"application/pdf", "x.pdf", "application/pdf"},
|
||
// 客户端没给类型时按扩展名猜
|
||
{"", "notes.txt", "text/plain"},
|
||
{"application/octet-stream", "data.json", "application/json"},
|
||
// 带参数的声明要剥掉参数
|
||
{"text/plain; charset=utf-8", "a.txt", "text/plain"},
|
||
// 认不出就兜底
|
||
{"", "blob.unknownext", "application/octet-stream"},
|
||
{"garbage//not-a-type", "blob.unknownext", "application/octet-stream"},
|
||
}
|
||
for _, c := range cases {
|
||
got := detectContentType(c.declared, c.filename)
|
||
// mime.TypeByExtension 在不同系统上可能返回带参数的值,只比主类型
|
||
if !strings.HasPrefix(got, c.want) {
|
||
t.Errorf("detectContentType(%q, %q) = %q, want prefix %q",
|
||
c.declared, c.filename, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Content-Disposition 必须双写:filename* 承载 UTF-8,filename= 给老客户端兜底。
|
||
// 兜底值里的引号/反斜杠/非 ASCII 一律换成下划线,否则能截断响应头。
|
||
func TestContentDisposition(t *testing.T) {
|
||
got := contentDisposition("报告 v2.pdf")
|
||
if !strings.HasPrefix(got, "attachment; ") {
|
||
t.Errorf("必须以 attachment 开头: %s", got)
|
||
}
|
||
if !strings.Contains(got, "filename*=UTF-8''") {
|
||
t.Errorf("缺少 RFC 5987 编码: %s", got)
|
||
}
|
||
// 非 ASCII 不能出现在 filename= 的兜底值里
|
||
ascii := got[:strings.Index(got, "filename*=")]
|
||
for _, r := range ascii {
|
||
if r > 0x7e {
|
||
t.Errorf("兜底 filename 含非 ASCII 字符 %q: %s", r, ascii)
|
||
}
|
||
}
|
||
|
||
// 引号注入不能逃出引号
|
||
evil := contentDisposition(`a"; x="y`)
|
||
if strings.Contains(evil[:strings.Index(evil, "filename*=")], `"; x=`) {
|
||
t.Errorf("引号未转义,可截断响应头: %s", evil)
|
||
}
|
||
|
||
// 控制字符(若绕过 sanitize 直达此处)也不能出现
|
||
ctl := contentDisposition("a\r\nb.txt")
|
||
if strings.ContainsAny(ctl, "\r\n") {
|
||
t.Errorf("响应头含换行: %q", ctl)
|
||
}
|
||
}
|
||
|
||
func TestURLEncodeRFC5987(t *testing.T) {
|
||
cases := []struct{ in, want string }{
|
||
{"abc.txt", "abc.txt"},
|
||
{"a b", "a%20b"},
|
||
{"中", "%E4%B8%AD"},
|
||
{`a"b`, "a%22b"},
|
||
}
|
||
for _, c := range cases {
|
||
if got := urlEncodeRFC5987(c.in); got != c.want {
|
||
t.Errorf("urlEncodeRFC5987(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseAttachmentIDs(t *testing.T) {
|
||
valid := "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
|
||
|
||
got, err := parseAttachmentIDs([]string{valid, " ", ""})
|
||
if err != nil {
|
||
t.Fatalf("合法输入报错: %v", err)
|
||
}
|
||
if len(got) != 1 {
|
||
t.Errorf("空白项应被忽略,得到 %d 个", len(got))
|
||
}
|
||
|
||
if _, err := parseAttachmentIDs([]string{"not-a-uuid"}); err == nil {
|
||
t.Error("非法 UUID 应报错")
|
||
}
|
||
|
||
if got, err := parseAttachmentIDs(nil); err != nil || len(got) != 0 {
|
||
t.Errorf("nil 应返回空列表,得到 %v, %v", got, err)
|
||
}
|
||
}
|