feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
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 时转发本轮总结)
This commit is contained in:
149
gateway/internal/blob/store.go
Normal file
149
gateway/internal/blob/store.go
Normal file
@ -0,0 +1,149 @@
|
||||
// Package blob 提供附件文件的内容寻址存储。
|
||||
//
|
||||
// 设计取舍:文件内容存磁盘、数据库只存元数据。
|
||||
// 不把附件塞进 SQLite 的 BLOB —— 附件是「写一次读多次」的冷数据,
|
||||
// 塞进库会让 .db 膨胀、WAL 变大、备份变慢,而这些代价换不来任何好处。
|
||||
//
|
||||
// 路径由内容的 sha256 派生(ab/cdef...),因此:
|
||||
// - 相同内容天然去重,重复上传不占额外空间
|
||||
// - 路径与用户提供的 filename 完全无关,杜绝 ../ 穿越
|
||||
// - 两级目录前缀避免单目录塞进十万个文件
|
||||
package blob
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Store 是附件的磁盘存储。
|
||||
type Store struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// ErrTooLarge 表示写入的数据超过了给定上限。
|
||||
var ErrTooLarge = errors.New("attachment too large")
|
||||
|
||||
var sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// New 打开(必要时创建)一个位于 root 的附件库。
|
||||
func New(root string) (*Store, error) {
|
||||
if root == "" {
|
||||
return nil, errors.New("blob: root 不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("blob: 创建 %s: %w", root, err)
|
||||
}
|
||||
return &Store{root: root}, nil
|
||||
}
|
||||
|
||||
// Root 返回存储根目录(用于日志与运维排查)。
|
||||
func (s *Store) Root() string { return s.root }
|
||||
|
||||
// pathFor 由 sha256 推出磁盘路径。
|
||||
// 调用前必须确认 sum 是合法的 64 位十六进制,否则可能被拼出库外路径。
|
||||
func (s *Store) pathFor(sum string) (string, error) {
|
||||
if !sha256Re.MatchString(sum) {
|
||||
return "", fmt.Errorf("blob: 非法的 sha256 %q", sum)
|
||||
}
|
||||
return filepath.Join(s.root, sum[:2], sum[2:4], sum), nil
|
||||
}
|
||||
|
||||
// Put 把 r 的内容写入存储,返回内容的 sha256 与字节数。
|
||||
//
|
||||
// maxBytes > 0 时超限即中止并清理临时文件(不会留下半个文件)。
|
||||
// 先写临时文件再按内容哈希 rename:写入过程中崩溃不会产生一个「哈希对不上内容」的文件。
|
||||
func (s *Store) Put(r io.Reader, maxBytes int64) (string, int64, error) {
|
||||
tmp, err := os.CreateTemp(s.root, ".upload-*")
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 创建临时文件: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// 失败路径统一清理;成功时 rename 之后这个 Remove 是无害的 no-op
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
src := r
|
||||
if maxBytes > 0 {
|
||||
// 多读 1 字节用于判断是否超限:LimitReader 到达上限时只会 EOF,
|
||||
// 无法区分「刚好等于上限」和「超过上限」。
|
||||
src = io.LimitReader(r, maxBytes+1)
|
||||
}
|
||||
|
||||
n, err := io.Copy(io.MultiWriter(tmp, h), src)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 写入: %w", err)
|
||||
}
|
||||
if maxBytes > 0 && n > maxBytes {
|
||||
return "", 0, ErrTooLarge
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: sync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: close: %w", err)
|
||||
}
|
||||
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
dst, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: 创建目录: %w", err)
|
||||
}
|
||||
|
||||
// 已存在同内容文件:内容寻址下这就是同一个文件,直接复用
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
return sum, n, nil
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: rename: %w", err)
|
||||
}
|
||||
if err := os.Chmod(dst, 0o600); err != nil {
|
||||
return "", 0, fmt.Errorf("blob: chmod: %w", err)
|
||||
}
|
||||
return sum, n, nil
|
||||
}
|
||||
|
||||
// Open 打开某个内容的读取句柄。调用方负责 Close。
|
||||
func (s *Store) Open(sum string) (*os.File, error) {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Open(p)
|
||||
}
|
||||
|
||||
// Exists 判断某内容是否已在库中。
|
||||
func (s *Store) Exists(sum string) bool {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Remove 删除某内容。
|
||||
//
|
||||
// 注意:内容寻址意味着多条附件记录可能指向同一个文件,
|
||||
// 因此调用方必须先确认没有其他记录引用该 sha256 才能删。
|
||||
func (s *Store) Remove(sum string) error {
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
171
gateway/internal/blob/store_test.go
Normal file
171
gateway/internal/blob/store_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package blob
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPutAndOpenRoundTrip(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := []byte("附件内容 with bytes \x00\x01")
|
||||
|
||||
sum, n, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != int64(len(data)) {
|
||||
t.Errorf("写入 %d 字节,报告 %d", len(data), n)
|
||||
}
|
||||
|
||||
h := sha256.Sum256(data)
|
||||
if sum != hex.EncodeToString(h[:]) {
|
||||
t.Errorf("sha256 = %s,与内容不符", sum)
|
||||
}
|
||||
|
||||
f, err := s.Open(sum)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
got, _ := io.ReadAll(f)
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Error("读回的内容与写入不一致")
|
||||
}
|
||||
}
|
||||
|
||||
// 相同内容重复上传必须复用同一个文件,不占额外空间。
|
||||
func TestPutDeduplicates(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := []byte("same content")
|
||||
|
||||
sum1, _, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum2, _, err := s.Put(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sum1 != sum2 {
|
||||
t.Fatalf("同内容得到不同哈希: %s vs %s", sum1, sum2)
|
||||
}
|
||||
|
||||
// 目录里应当只有一个内容文件(外加两级目录)
|
||||
var files int
|
||||
filepath.Walk(s.Root(), func(_ string, info os.FileInfo, _ error) error {
|
||||
if info != nil && !info.IsDir() {
|
||||
files++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if files != 1 {
|
||||
t.Errorf("去重后应只剩 1 个文件,实际 %d", files)
|
||||
}
|
||||
}
|
||||
|
||||
// 超限必须拒绝,且不能留下半个临时文件。
|
||||
func TestPutTooLargeLeavesNoGarbage(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := bytes.Repeat([]byte("x"), 1024)
|
||||
|
||||
_, _, err := s.Put(bytes.NewReader(data), 512)
|
||||
if !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("期望 ErrTooLarge,得到 %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(s.Root())
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".upload-") {
|
||||
t.Errorf("超限后残留临时文件 %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恰好等于上限应当通过 —— 边界不能误杀。
|
||||
func TestPutExactlyAtLimit(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data := bytes.Repeat([]byte("y"), 512)
|
||||
|
||||
if _, n, err := s.Put(bytes.NewReader(data), 512); err != nil {
|
||||
t.Fatalf("恰好等于上限被拒: %v", err)
|
||||
} else if n != 512 {
|
||||
t.Errorf("字节数 = %d,want 512", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 路径完全由 sha256 派生,任何非法 sum 都不能落到库外。
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
s := newStore(t)
|
||||
|
||||
for _, bad := range []string{
|
||||
"../../etc/passwd",
|
||||
"..",
|
||||
"/etc/passwd",
|
||||
"ABCDEF", // 大写非法
|
||||
strings.Repeat("g", 64), // 非十六进制
|
||||
strings.Repeat("a", 63), // 长度不足
|
||||
"",
|
||||
} {
|
||||
if _, err := s.pathFor(bad); err == nil {
|
||||
t.Errorf("pathFor(%q) 应报错", bad)
|
||||
}
|
||||
if _, err := s.Open(bad); err == nil {
|
||||
t.Errorf("Open(%q) 应报错", bad)
|
||||
}
|
||||
if s.Exists(bad) {
|
||||
t.Errorf("Exists(%q) 应为 false", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成的路径必须落在库根目录之内。
|
||||
func TestPathStaysInsideRoot(t *testing.T) {
|
||||
s := newStore(t)
|
||||
sum := strings.Repeat("ab", 32)
|
||||
|
||||
p, err := s.pathFor(sum)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel, err := filepath.Rel(s.Root(), p)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
t.Errorf("路径逃出库根: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
s := newStore(t)
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("z")), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Exists(sum) {
|
||||
t.Fatal("写入后应存在")
|
||||
}
|
||||
if err := s.Remove(sum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Exists(sum) {
|
||||
t.Error("删除后仍存在")
|
||||
}
|
||||
// 重复删除应当幂等,不报错
|
||||
if err := s.Remove(sum); err != nil {
|
||||
t.Errorf("重复删除报错: %v", err)
|
||||
}
|
||||
}
|
||||
94
gateway/internal/config/config.go
Normal file
94
gateway/internal/config/config.go
Normal file
@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
CORSOrigins []string
|
||||
|
||||
// 首次启动时创建的默认管理员
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
|
||||
// Cookie 是否要求 HTTPS(生产环境置 true)
|
||||
SecureCookie bool
|
||||
CookieName string
|
||||
|
||||
// 附件:文件内容存盘,默认与 SQLite 同目录下的 attachments/
|
||||
AttachmentDir string
|
||||
// 单个附件上限(字节)。默认 25MB,与常见邮箱附件限额一致
|
||||
MaxAttachmentBytes int64
|
||||
}
|
||||
|
||||
var C *Config
|
||||
|
||||
func Load() *Config {
|
||||
C = &Config{
|
||||
Port: getEnv("PORT", "8180"),
|
||||
// 空值 = 用内置 SQLite(data/agentmail.db,可用 AGENTMAIL_DATA_DIR 改目录)。
|
||||
// 想接外部库就给 postgres://…;也接受 sqlite:///path/x.db 与裸路径。
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
CORSOrigins: splitEnv("CORS_ORIGINS", []string{
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
}),
|
||||
AdminUser: strings.ToLower(getEnv("ADMIN_USER", "admin")),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||
SecureCookie: getEnvBool("SECURE_COOKIE", false),
|
||||
CookieName: getEnv("COOKIE_NAME", "am_session"),
|
||||
|
||||
AttachmentDir: getEnv("AGENTMAIL_ATTACHMENT_DIR",
|
||||
filepath.Join(getEnv("AGENTMAIL_DATA_DIR", "data"), "attachments")),
|
||||
MaxAttachmentBytes: getEnvInt64("AGENTMAIL_MAX_ATTACHMENT_BYTES", 25<<20),
|
||||
}
|
||||
return C
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt64(key string, fallback int64) int64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func splitEnv(key string, fallback []string) []string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return fallback
|
||||
}
|
||||
return out
|
||||
}
|
||||
193
gateway/internal/db/db.go
Normal file
193
gateway/internal/db/db.go
Normal file
@ -0,0 +1,193 @@
|
||||
// Package db 提供数据库连接与方言适配。
|
||||
//
|
||||
// AgentMail 默认用 SQLite(零依赖、单文件,配合 go:embed 的前端就是「一个二进制 + 一个 .db」),
|
||||
// 用户显式给出 DATABASE_URL 时切换到外部 PostgreSQL。
|
||||
//
|
||||
// 两种方言的差异集中在本包处理,repo 层只写一份 SQL:
|
||||
// - 占位符:SQLite 也支持 $1/$2,无需改写
|
||||
// - NOW() / gen_random_uuid():SQLite 侧注册同名函数补齐
|
||||
// - JSONB 包含判断:走 CCHas/CCArg 辅助函数(唯一必须分支的查询)
|
||||
// - 唯一约束冲突:IsUniqueViolation 统一识别
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // database/sql 驱动:pgx
|
||||
sqlite "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Dialect string
|
||||
|
||||
const (
|
||||
Postgres Dialect = "postgres"
|
||||
SQLite Dialect = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
DB *sql.DB
|
||||
// D 是当前生效的方言,repo 层据此选择 SQL 片段
|
||||
D Dialect
|
||||
)
|
||||
|
||||
func init() {
|
||||
// SQLite 没有 NOW() 与 gen_random_uuid(),注册同名函数使 repo 层 SQL 与 PG 保持一致。
|
||||
// 函数名在 SQLite 中大小写不敏感,注册小写即可匹配 SQL 里的 NOW()。
|
||||
sqlite.MustRegisterDeterministicScalarFunction("gen_random_uuid", 0,
|
||||
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
|
||||
return uuid.NewString(), nil
|
||||
})
|
||||
|
||||
// NOW() 必须非确定性:同一语句内多次调用要各自取当前时刻,
|
||||
// 且格式与 SQLite 的 CURRENT_TIMESTAMP 一致,才能统一扫进 time.Time。
|
||||
sqlite.MustRegisterScalarFunction("now", 0,
|
||||
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
|
||||
return time.Now().UTC().Format("2006-01-02 15:04:05"), nil
|
||||
})
|
||||
}
|
||||
|
||||
// Connect 依据 DATABASE_URL 建立连接。空值时落到 SQLite。
|
||||
func Connect(ctx context.Context, dsn string) error {
|
||||
driverName, connStr, dialect, err := resolve(dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pool, err := sql.Open(driverName, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", dialect, err)
|
||||
}
|
||||
|
||||
switch dialect {
|
||||
case Postgres:
|
||||
pool.SetMaxOpenConns(20)
|
||||
pool.SetMaxIdleConns(4)
|
||||
pool.SetConnMaxLifetime(30 * time.Minute)
|
||||
pool.SetConnMaxIdleTime(5 * time.Minute)
|
||||
case SQLite:
|
||||
// SQLite 单写者:并发写靠 WAL + busy_timeout 排队,连接数放大只会加剧锁竞争。
|
||||
pool.SetMaxOpenConns(1)
|
||||
pool.SetMaxIdleConns(1)
|
||||
pool.SetConnMaxLifetime(0)
|
||||
}
|
||||
|
||||
if err := pool.PingContext(ctx); err != nil {
|
||||
pool.Close()
|
||||
return fmt.Errorf("ping %s: %w", dialect, err)
|
||||
}
|
||||
|
||||
DB = pool
|
||||
D = dialect
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve 把 DATABASE_URL 解析为 (驱动名, 连接串, 方言)。
|
||||
func resolve(dsn string) (string, string, Dialect, error) {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
|
||||
if dsn == "" {
|
||||
return "sqlite", sqliteDSN(defaultDBPath()), SQLite, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"):
|
||||
return "pgx", dsn, Postgres, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "sqlite://"):
|
||||
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite://")), SQLite, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "sqlite:"):
|
||||
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite:")), SQLite, nil
|
||||
|
||||
case strings.HasPrefix(dsn, "file:"):
|
||||
// 已是 SQLite URI,原样透传(调用方自带 pragma)
|
||||
return "sqlite", dsn, SQLite, nil
|
||||
|
||||
case strings.HasSuffix(dsn, ".db"), strings.HasSuffix(dsn, ".sqlite"), strings.HasSuffix(dsn, ".sqlite3"):
|
||||
return "sqlite", sqliteDSN(dsn), SQLite, nil
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("无法识别的 DATABASE_URL %q:期望 postgres://…、sqlite:///path/x.db 或 /path/x.db", dsn)
|
||||
}
|
||||
|
||||
// defaultDBPath 返回默认 SQLite 文件位置(AGENTMAIL_DATA_DIR 可覆盖)。
|
||||
func defaultDBPath() string {
|
||||
dir := os.Getenv("AGENTMAIL_DATA_DIR")
|
||||
if dir == "" {
|
||||
dir = "data"
|
||||
}
|
||||
return filepath.Join(dir, "agentmail.db")
|
||||
}
|
||||
|
||||
// sqliteDSN 把文件路径包装为带 pragma 的 SQLite URI,并确保父目录存在。
|
||||
//
|
||||
// - journal_mode=WAL:读写不互斥,SSE 长连接查询不会被写入阻塞
|
||||
// - busy_timeout=5000:并发写时排队 5s 而不是立刻 SQLITE_BUSY
|
||||
// - foreign_keys=1:SQLite 默认不校验外键,必须显式打开
|
||||
func sqliteDSN(path string) string {
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
return "file:" + path +
|
||||
"?_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=busy_timeout(5000)" +
|
||||
"&_pragma=foreign_keys(1)"
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if DB != nil {
|
||||
DB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 方言差异 ----------
|
||||
|
||||
// CCHas 返回「cc_list 是否抄送了某人」的 SQL 片段,argN 是该人名对应的占位符序号。
|
||||
//
|
||||
// 两个方言的实参都是【纯人名字符串】,不是 JSON 探针——因为多处查询把同一个
|
||||
// 占位符同时用于 from_name/to_name 比较和抄送判断,两种实参约定必然出错。
|
||||
// PG 侧在 SQL 里用 jsonb_build_* 现场构造探针;SQLite 侧用 json_each 展开逐项比对。
|
||||
func CCHas(col string, argN int) string {
|
||||
if D == Postgres {
|
||||
return fmt.Sprintf("%s @> jsonb_build_array(jsonb_build_object('name', $%d::text))", col, argN)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"EXISTS (SELECT 1 FROM json_each(%s) WHERE json_extract(value, '$.name') = $%d)",
|
||||
col, argN)
|
||||
}
|
||||
|
||||
// JSONCast 返回把占位符转成 JSONB 的后缀(PG 需要 ::jsonb,SQLite 存 TEXT 无需转换)。
|
||||
func JSONCast() string {
|
||||
if D == Postgres {
|
||||
return "::jsonb"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsUniqueViolation 判断错误是否为唯一约束冲突(用于别名撞名重试)。
|
||||
func IsUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505"
|
||||
}
|
||||
var liteErr *sqlite.Error
|
||||
if errors.As(err, &liteErr) {
|
||||
// SQLITE_CONSTRAINT_UNIQUE = 2067、SQLITE_CONSTRAINT_PRIMARYKEY = 1555
|
||||
code := liteErr.Code()
|
||||
return code == 2067 || code == 1555
|
||||
}
|
||||
return false
|
||||
}
|
||||
118
gateway/internal/db/migrate.go
Normal file
118
gateway/internal/db/migrate.go
Normal file
@ -0,0 +1,118 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/init.sql
|
||||
var initSQLPostgres string
|
||||
|
||||
//go:embed migrations/init_sqlite.sql
|
||||
var initSQLSQLite string
|
||||
|
||||
// Migrate 建表建索引。两种方言各有一份 schema,语义保持一致。
|
||||
func Migrate(ctx context.Context) error {
|
||||
switch D {
|
||||
case Postgres:
|
||||
// PG 侧含 DO $$ … $$ 迁移块,必须整体提交
|
||||
if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil {
|
||||
return fmt.Errorf("migrate postgres: %w", err)
|
||||
}
|
||||
case SQLite:
|
||||
// modernc.org/sqlite 的 Exec 不接受多语句,逐条执行
|
||||
for i, stmt := range splitStatements(initSQLSQLite) {
|
||||
if _, err := DB.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err)
|
||||
}
|
||||
}
|
||||
// CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有
|
||||
// ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。
|
||||
if err := addMissingColumns(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("migrate: 未初始化的方言")
|
||||
}
|
||||
|
||||
fmt.Printf("数据库迁移完成(%s)\n", D)
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitStatements 按分号切分 SQL 脚本并剔除注释行。
|
||||
// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号,
|
||||
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
|
||||
func splitStatements(script string) []string {
|
||||
var out []string
|
||||
for _, raw := range strings.Split(script, ";") {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" {
|
||||
out = append(out, stmt)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。
|
||||
//
|
||||
// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。
|
||||
// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够,SQLite 没有这个语法,
|
||||
// 只能先查 pragma 再决定加不加。
|
||||
//
|
||||
// 新增列时同时改两处:init_sqlite.sql 的 CREATE TABLE(给新库)与这张表(给老库)。
|
||||
var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
{"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"},
|
||||
{"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"},
|
||||
{"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"},
|
||||
{"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"},
|
||||
// 会话级往返预算(0 = 不限)。旧库默认 0:引入预算不应该把已在进行的会话卡死。
|
||||
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
|
||||
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
|
||||
}
|
||||
|
||||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||||
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
|
||||
var sqliteAddIndexes = []string{
|
||||
// 人类决策后要按 mail_id 反查上游 permission id
|
||||
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
|
||||
}
|
||||
|
||||
func addMissingColumns(ctx context.Context) error {
|
||||
for _, c := range sqliteAddColumns {
|
||||
has, err := columnExists(ctx, c.table, c.column)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err)
|
||||
}
|
||||
if has {
|
||||
continue
|
||||
}
|
||||
if _, err := DB.ExecContext(ctx, c.ddl); err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err)
|
||||
}
|
||||
fmt.Printf("补列 %s.%s\n", c.table, c.column)
|
||||
}
|
||||
for _, ddl := range sqliteAddIndexes {
|
||||
if _, err := DB.ExecContext(ctx, ddl); err != nil {
|
||||
return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(ctx context.Context, table, column string) (bool, error) {
|
||||
// pragma_table_info 是表函数形式的 PRAGMA,可以直接当表查(比解析 PRAGMA 输出干净)。
|
||||
// table 与 column 都来自上面的硬编码常量表,不存在注入面。
|
||||
var n int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
|
||||
table, column).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
297
gateway/internal/db/migrations/init.sql
Normal file
297
gateway/internal/db/migrations/init.sql
Normal file
@ -0,0 +1,297 @@
|
||||
-- AgentMail MVP Schema
|
||||
-- PostgreSQL 14+
|
||||
|
||||
-- Users table(人类多用户;username 与 agents.agent_name 共用命名空间)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'user',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_login TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
token VARCHAR(64) PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
user_agent VARCHAR(256) DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
|
||||
|
||||
-- 用户权限边界:可调用的 Agent 与可访问的工作区目录
|
||||
-- allowed_agents:["deepseekharness","pi"],空数组 = 不限(继承系统默认)
|
||||
-- allowed_paths :["/program","/home/x"],空数组 = 不限;按前缀匹配
|
||||
-- agent_aliases :{"大龙":"deepseekharness","小派":"pi"},发信时自动解析真实名
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_agents JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_paths JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_aliases JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Agents table
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_name VARCHAR(64) NOT NULL UNIQUE,
|
||||
secret VARCHAR(128) NOT NULL,
|
||||
host_url VARCHAR(256) NOT NULL DEFAULT '',
|
||||
workspaces JSONB NOT NULL DEFAULT '[]',
|
||||
platform VARCHAR(32) NOT NULL DEFAULT 'pi',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
||||
max_rounds INT NOT NULL DEFAULT 10,
|
||||
used_rounds INT NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_alias VARCHAR(128),
|
||||
from_agent VARCHAR(64) NOT NULL,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
owner_user_id UUID REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||
|
||||
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
|
||||
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
|
||||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||||
|
||||
-- 已存在的库补列(必须先于依赖该列的索引)
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);
|
||||
|
||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT;
|
||||
|
||||
-- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖)
|
||||
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
|
||||
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT 'platform';
|
||||
|
||||
-- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
--
|
||||
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
|
||||
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
|
||||
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
|
||||
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
--
|
||||
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
|
||||
-- 每条都是全新预算,全局上限就形同虚设。
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||||
|
||||
-- Mails table
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||||
parent_mail_id UUID REFERENCES mails(mail_id),
|
||||
|
||||
from_name VARCHAR(64) NOT NULL,
|
||||
from_workspace VARCHAR(128) DEFAULT '',
|
||||
to_name VARCHAR(64) NOT NULL,
|
||||
to_workspace VARCHAR(128) DEFAULT '',
|
||||
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
|
||||
-- 拄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
|
||||
cc_list JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
mail_type VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
permission_options JSONB,
|
||||
permission_result VARCHAR(32),
|
||||
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unread',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
hop_limit INT DEFAULT 5
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
|
||||
|
||||
-- 已存在的库补列(重复运行安全)
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS cc_list JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT;
|
||||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_reason TEXT;
|
||||
|
||||
-- 抄送检索:cc_list @> '[{"name":"pi"}]' 走 GIN
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_cc_list ON mails USING GIN (cc_list jsonb_path_ops);
|
||||
|
||||
-- 历史数据规整:早期写入过首字母大写的键(Raw/Name/Path/Session),统一成小写
|
||||
UPDATE mails
|
||||
SET cc_list = (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'name', COALESCE(e->>'name', e->>'Name'),
|
||||
'path', COALESCE(e->>'path', e->>'Path'),
|
||||
'session', COALESCE(e->>'session', e->>'Session'),
|
||||
'raw', COALESCE(e->>'raw', e->>'Raw')
|
||||
))
|
||||
), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(cc_list) AS e
|
||||
)
|
||||
WHERE cc_list @? '$[*].Name';
|
||||
|
||||
-- Permission requests table
|
||||
CREATE TABLE IF NOT EXISTS permission_requests (
|
||||
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID NOT NULL REFERENCES mails(mail_id),
|
||||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||||
agent_name VARCHAR(64) NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options JSONB NOT NULL DEFAULT '["同意", "拒绝"]',
|
||||
context TEXT DEFAULT '',
|
||||
result VARCHAR(32),
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
|
||||
|
||||
-- 历史邮件里的字面量 'human' 迁移到默认管理员账号
|
||||
-- (管理员由 Go 侧 EnsureAdminUser 首启创建,此处只做数据重写)
|
||||
DO $$
|
||||
DECLARE
|
||||
admin_name TEXT;
|
||||
BEGIN
|
||||
SELECT username INTO admin_name
|
||||
FROM users WHERE role = 'admin' AND status = 'active'
|
||||
ORDER BY created_at ASC LIMIT 1;
|
||||
|
||||
IF admin_name IS NULL THEN
|
||||
RETURN; -- 还没有管理员,下次迁移再试
|
||||
END IF;
|
||||
|
||||
UPDATE mails SET from_name = admin_name WHERE from_name = 'human';
|
||||
UPDATE mails SET to_name = admin_name WHERE to_name = 'human';
|
||||
UPDATE sessions SET from_agent = admin_name WHERE from_agent = 'human';
|
||||
|
||||
-- 拄送列表里的 human 一并重写
|
||||
UPDATE mails
|
||||
SET cc_list = (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
CASE WHEN e->>'name' = 'human'
|
||||
THEN jsonb_set(
|
||||
jsonb_set(e, '{name}', to_jsonb(admin_name)),
|
||||
'{raw}',
|
||||
to_jsonb(admin_name || '@' || COALESCE(e->>'path','') ||
|
||||
CASE WHEN COALESCE(e->>'session','') = '' THEN ''
|
||||
ELSE '.' || (e->>'session') END))
|
||||
ELSE e END
|
||||
), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(cc_list) AS e
|
||||
)
|
||||
WHERE cc_list @> '[{"name":"human"}]';
|
||||
|
||||
-- 人类发起的会话补上 owner
|
||||
UPDATE sessions s
|
||||
SET owner_user_id = u.user_id
|
||||
FROM users u
|
||||
WHERE u.username = admin_name
|
||||
AND s.owner_user_id IS NULL
|
||||
AND s.from_agent = admin_name;
|
||||
END $$;
|
||||
|
||||
-- ---------- 密钥认证体系 ----------
|
||||
--
|
||||
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys):
|
||||
-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE
|
||||
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
|
||||
--
|
||||
-- key_type:
|
||||
-- permanent — 永不过期,可重复使用
|
||||
-- one_time — 首次验证后写 used_at,再用即拒
|
||||
-- timed — expires_at 之后失效
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_keys (
|
||||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_token VARCHAR(128) NOT NULL UNIQUE,
|
||||
agent_name VARCHAR(64), -- NULL = 待绑定
|
||||
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
|
||||
label VARCHAR(128) NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_token VARCHAR(128) NOT NULL UNIQUE,
|
||||
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
label VARCHAR(128) NOT NULL DEFAULT '',
|
||||
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
|
||||
expires_at TIMESTAMPTZ,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
|
||||
|
||||
-- ---------- 附件 ----------
|
||||
--
|
||||
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
|
||||
--
|
||||
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
|
||||
-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart),
|
||||
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
|
||||
|
||||
-- 插件自动转发的邮件登记表。
|
||||
--
|
||||
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
|
||||
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
|
||||
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
|
||||
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
|
||||
--
|
||||
-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。
|
||||
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
|
||||
-- * 插件重试、SSE 重连后重放都不会产生第二封
|
||||
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
|
||||
CREATE TABLE IF NOT EXISTS relayed_mails (
|
||||
agent_name VARCHAR(64) NOT NULL,
|
||||
relay_key VARCHAR(160) NOT NULL,
|
||||
mail_id UUID REFERENCES mails(mail_id),
|
||||
kind VARCHAR(32) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (agent_name, relay_key)
|
||||
);
|
||||
|
||||
-- 人类决策后要按 mail_id 反查上游 permission id
|
||||
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mail_id UUID REFERENCES mails(mail_id) ON DELETE CASCADE,
|
||||
uploader VARCHAR(64) NOT NULL,
|
||||
filename VARCHAR(512) NOT NULL,
|
||||
content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL,
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
|
||||
241
gateway/internal/db/migrations/init_sqlite.sql
Normal file
241
gateway/internal/db/migrations/init_sqlite.sql
Normal file
@ -0,0 +1,241 @@
|
||||
-- AgentMail Schema — SQLite(默认后端)
|
||||
--
|
||||
-- 与 init.sql(PostgreSQL)保持同一套表结构与语义,差异仅在方言:
|
||||
-- UUID → TEXT(Go 侧 uuid 或 gen_random_uuid() 注册函数生成)
|
||||
-- TIMESTAMPTZ → DATETIME(必须写 DATETIME,database/sql 才能扫进 time.Time)
|
||||
-- JSONB → TEXT(存 JSON 字符串,用 json_each/json_extract 检索)
|
||||
-- VARCHAR(n) → TEXT(SQLite 不强制长度,长度约束由应用层负责)
|
||||
-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致
|
||||
--
|
||||
-- 本文件只建表建索引,不含数据迁移:SQLite 是新引入的默认后端,不存在历史库。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
|
||||
-- 权限边界:空数组 = 不限
|
||||
allowed_agents TEXT NOT NULL DEFAULT '[]',
|
||||
allowed_paths TEXT NOT NULL DEFAULT '[]',
|
||||
agent_aliases TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
user_agent TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
agent_name TEXT NOT NULL UNIQUE,
|
||||
secret TEXT NOT NULL,
|
||||
host_url TEXT NOT NULL DEFAULT '',
|
||||
workspaces TEXT NOT NULL DEFAULT '[]',
|
||||
platform TEXT NOT NULL DEFAULT 'pi',
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
max_rounds INTEGER NOT NULL DEFAULT 10,
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
session_alias TEXT,
|
||||
from_agent TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
owner_user_id TEXT REFERENCES users(user_id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||
rename_dismissed TEXT,
|
||||
|
||||
-- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖)
|
||||
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
|
||||
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉,
|
||||
-- 人上一秒记住的寻址地址下一秒失效。
|
||||
alias_source TEXT NOT NULL DEFAULT 'platform',
|
||||
|
||||
-- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
--
|
||||
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
|
||||
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
|
||||
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
|
||||
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
--
|
||||
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
|
||||
-- 每条都是全新预算,全局上限就形同虚设。
|
||||
max_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
used_rounds INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||||
|
||||
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
|
||||
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
|
||||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
mail_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||
parent_mail_id TEXT REFERENCES mails(mail_id),
|
||||
|
||||
from_name TEXT NOT NULL,
|
||||
from_workspace TEXT DEFAULT '',
|
||||
to_name TEXT NOT NULL,
|
||||
to_workspace TEXT DEFAULT '',
|
||||
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
|
||||
-- 抄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
|
||||
cc_list TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
mail_type TEXT NOT NULL DEFAULT 'normal',
|
||||
permission_options TEXT,
|
||||
permission_result TEXT,
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'unread',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
hop_limit INTEGER DEFAULT 5,
|
||||
|
||||
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,
|
||||
-- 「谁在哪一封里提了什么」应当留痕。
|
||||
rename_alias TEXT,
|
||||
rename_reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_created ON mails(created_at);
|
||||
|
||||
-- 抄送检索无对应索引:SQLite 侧走 json_each 展开。
|
||||
-- 单机邮件量级(数千至数万)下全表展开是毫秒级,不值得为此加物化列。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_requests (
|
||||
request_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
|
||||
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||
agent_name TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL DEFAULT '["同意","拒绝"]',
|
||||
context TEXT DEFAULT '',
|
||||
result TEXT,
|
||||
decided_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
|
||||
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
|
||||
|
||||
-- ---------- 密钥认证体系 ----------
|
||||
--
|
||||
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys):
|
||||
-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE
|
||||
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
|
||||
--
|
||||
-- key_type:
|
||||
-- permanent — 永不过期,可重复使用
|
||||
-- one_time — 首次验证后写 used_at,再用即拒
|
||||
-- timed — expires_at 之后失效
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_keys (
|
||||
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
key_token TEXT NOT NULL UNIQUE,
|
||||
agent_name TEXT, -- NULL = 待绑定
|
||||
key_type TEXT NOT NULL DEFAULT 'permanent',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
expires_at DATETIME,
|
||||
used_at DATETIME,
|
||||
created_by TEXT REFERENCES users(user_id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
key_token TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
key_type TEXT NOT NULL DEFAULT 'permanent',
|
||||
expires_at DATETIME,
|
||||
used_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
|
||||
|
||||
-- ---------- 附件 ----------
|
||||
--
|
||||
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
|
||||
-- 不塞 BLOB:SQLite 的 BLOB 会让 .db 文件膨胀并拖慢 WAL,而附件是只写一次多次读的冷数据。
|
||||
--
|
||||
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
|
||||
-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart),
|
||||
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
|
||||
|
||||
-- 插件自动转发的邮件登记表。
|
||||
--
|
||||
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
|
||||
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
|
||||
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
|
||||
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
|
||||
--
|
||||
-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。
|
||||
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
|
||||
-- * 插件重试、SSE 重连后重放都不会产生第二封
|
||||
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
|
||||
CREATE TABLE IF NOT EXISTS relayed_mails (
|
||||
agent_name TEXT NOT NULL,
|
||||
relay_key TEXT NOT NULL,
|
||||
mail_id TEXT REFERENCES mails(mail_id),
|
||||
kind TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (agent_name, relay_key)
|
||||
);
|
||||
|
||||
-- 人类决策后要按 mail_id 反查上游 permission id
|
||||
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
mail_id TEXT REFERENCES mails(mail_id) ON DELETE CASCADE,
|
||||
|
||||
-- 上传者(Agent 名或用户名),用于「只能挂自己上传的附件」校验
|
||||
uploader TEXT NOT NULL,
|
||||
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes INTEGER NOT NULL,
|
||||
-- sha256 既是去重依据也是磁盘路径来源,绝不用用户给的 filename 拼路径
|
||||
sha256 TEXT NOT NULL,
|
||||
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
|
||||
-- GC 扫描待挂载附件用
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
|
||||
142
gateway/internal/handler/agents.go
Normal file
142
gateway/internal/handler/agents.go
Normal file
@ -0,0 +1,142 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// ---------- Agent ----------
|
||||
|
||||
type registerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/register
|
||||
//
|
||||
// 两种认证方式:
|
||||
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
|
||||
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
|
||||
// 否则等于拿别人的密钥冒充新身份。
|
||||
// 2. body 里带 secret —— 旧方式,兼容保留。
|
||||
func RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing name")
|
||||
return
|
||||
}
|
||||
|
||||
keyToken := middleware.BearerToken(r)
|
||||
if keyToken == "" && req.Secret == "" {
|
||||
Error(w, http.StatusBadRequest, "需要 Authorization: Bearer <密钥> 或 body 里的 secret")
|
||||
return
|
||||
}
|
||||
|
||||
if keyToken != "" {
|
||||
bound, err := repo.VerifyAgentKey(r.Context(), keyToken)
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
if bound != "" && bound != req.Name {
|
||||
Error(w, http.StatusForbidden,
|
||||
"该密钥已绑定到 Agent \""+bound+"\",不能用于注册 \""+req.Name+"\"")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Platform == "" {
|
||||
req.Platform = "pi"
|
||||
}
|
||||
|
||||
// 三维地址的 name 位与人类用户名共用命名空间,不得重名
|
||||
if ok, err := repo.AgentNameAvailable(r.Context(), req.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to validate agent name")
|
||||
return
|
||||
} else if !ok {
|
||||
Error(w, http.StatusConflict, "该名称已被人类用户占用")
|
||||
return
|
||||
}
|
||||
if req.Name == "human" {
|
||||
Error(w, http.StatusBadRequest, "human 是保留别名,不能作为 Agent 名")
|
||||
return
|
||||
}
|
||||
|
||||
// 密钥认证时不需要 secret,但 agents.secret 非空约束仍在;
|
||||
// 存密钥本身作占位,旧的 name/secret 路径不受影响。
|
||||
secret := req.Secret
|
||||
if secret == "" {
|
||||
secret = keyToken
|
||||
}
|
||||
|
||||
if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to register agent")
|
||||
return
|
||||
}
|
||||
|
||||
// 待绑定密钥在首次注册成功后落定到该 Agent
|
||||
if keyToken != "" {
|
||||
if err := repo.ClaimAgentKey(r.Context(), keyToken, req.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to bind key")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "registered",
|
||||
"agent_name": req.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/heartbeat
|
||||
func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
pending, err := repo.HeartbeatAgent(r.Context(), agentName)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to heartbeat")
|
||||
return
|
||||
}
|
||||
|
||||
// 心跳回传配额:插件据此把剩余次数注入 Agent 上下文,
|
||||
// 让它在配额耗尽前主动发总结,而不是撞到 403 才发现。
|
||||
quota, qErr := repo.GetQuota(r.Context(), agentName)
|
||||
if qErr != nil {
|
||||
// 配额读不到不影响心跳本身,降级为不限额
|
||||
quota = repo.Quota{AgentName: agentName, Unlimited: true, Remaining: -1}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"pending_mails": pending,
|
||||
"quota": quota,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agents
|
||||
func ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
statusFilter := r.URL.Query().Get("status")
|
||||
|
||||
agents, err := repo.ListAgents(r.Context(), statusFilter)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"agents": emptySlice(agents),
|
||||
})
|
||||
}
|
||||
69
gateway/internal/handler/alias_test.go
Normal file
69
gateway/internal/handler/alias_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// 平台侧 slug/标题不受本侧寻址约束,normalizeAlias 必须把它改写成
|
||||
// 能安全出现在 name@path.<alias> 末段的形式。
|
||||
func TestNormalizeAlias(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
// opencode 风格 slug 原样通过
|
||||
{"jolly-cactus", "jolly-cactus"},
|
||||
{"fix-memory-leak", "fix-memory-leak"},
|
||||
|
||||
// 非法字符统一换 -,连续的压缩成一个
|
||||
{"fix.memory.leak", "fix-memory-leak"},
|
||||
{"修复 登录态 丢失", "修复-登录态-丢失"},
|
||||
{"a//b..c", "a-b-c"},
|
||||
{"user@host", "user-host"},
|
||||
|
||||
// 首尾的分隔符要去掉
|
||||
{".leading", "leading"},
|
||||
{"trailing.", "trailing"},
|
||||
{" spaced ", "spaced"},
|
||||
|
||||
// 保留字必须避开,否则会被寻址当成「新建会话」
|
||||
{"new", "session-new"},
|
||||
|
||||
// 全是非法字符 → 空串,交由调用方报错
|
||||
{"...", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := normalizeAlias(c.in); got != c.want {
|
||||
t.Errorf("normalizeAlias(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 规范化后的别名必须能通过寻址校验,否则同步会写进一个自己都拒绝的别名。
|
||||
func TestNormalizeAliasPassesValidation(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"jolly-cactus", "fix.memory.leak", "修复 登录态 丢失", "new", "user@host/path",
|
||||
} {
|
||||
norm := normalizeAlias(in)
|
||||
if norm == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateSessionAlias(norm); err != nil {
|
||||
t.Errorf("normalizeAlias(%q) = %q,但未通过 validateSessionAlias: %v", in, norm, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 截断长别名时不能切坏多字节字符(session_alias 是 VARCHAR(128))。
|
||||
func TestNormalizeAliasTruncatesOnValidUTF8(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "修" // 每个 3 字节,共 300 字节
|
||||
}
|
||||
got := normalizeAlias(long)
|
||||
if len(got) > 128 {
|
||||
t.Errorf("normalizeAlias 截断后 %d 字节,超过 128", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r == '\uFFFD' {
|
||||
t.Fatalf("normalizeAlias 截断产生了非法 UTF-8: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
368
gateway/internal/handler/attachments.go
Normal file
368
gateway/internal/handler/attachments.go
Normal file
@ -0,0 +1,368 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/blob"
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 附件 ----------
|
||||
//
|
||||
// 上传与发信是两步:
|
||||
// 1. POST /attachments (multipart)→ 拿到 attachment_id
|
||||
// 2. 发信时把 id 放进 attachment_ids
|
||||
// 之所以不合成一步:Agent 侧的工具接口是 JSON,没法带 multipart;
|
||||
// 而人类侧若只支持一步,就无法在写信过程中先传文件再改正文。
|
||||
//
|
||||
// 未挂载的附件是合法中间态,超时由 GC 清理(repo.SweepOrphanAttachments)。
|
||||
|
||||
// Blobs 是附件内容存储,由 main 在启动时注入。
|
||||
var Blobs *blob.Store
|
||||
|
||||
// sanitizeFilename 清理用户提供的文件名。
|
||||
//
|
||||
// 文件名只用于展示与下载时的 Content-Disposition,磁盘路径完全由 sha256 派生,
|
||||
// 因此这里的目的不是防路径穿越(那已由内容寻址杜绝),而是:
|
||||
// - 去掉目录成分,避免下载时浏览器按 "a/b/c.txt" 解释
|
||||
// - 去掉控制字符与换行,避免污染 HTTP 响应头
|
||||
// - 限长,避免超出数据库列宽
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
// 同时处理 / 与 \:上传方可能是 Windows 客户端
|
||||
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
continue // 控制字符一律丢弃
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
name = strings.TrimSpace(b.String())
|
||||
|
||||
// "." 与 ".." 作为文件名毫无意义,且容易在各层被特殊解释
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "unnamed"
|
||||
}
|
||||
|
||||
const maxBytes = 255
|
||||
if len(name) > maxBytes {
|
||||
cut := name[:maxBytes]
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
name = cut
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// detectContentType 优先用客户端声明的类型,缺失时按扩展名猜,兜底 octet-stream。
|
||||
// 无论如何都不回显未经处理的客户端值到响应头(下载时统一用 octet-stream,见 DownloadAttachment)。
|
||||
func detectContentType(declared, filename string) string {
|
||||
if ct := strings.TrimSpace(declared); ct != "" && ct != "application/octet-stream" {
|
||||
if parsed, _, err := mime.ParseMediaType(ct); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
if ext := filepath.Ext(filename); ext != "" {
|
||||
if byExt := mime.TypeByExtension(ext); byExt != "" {
|
||||
if parsed, _, err := mime.ParseMediaType(byExt); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// uploadAttachment 是 Agent 与人类两条上传路径的公共实现。
|
||||
func uploadAttachment(w http.ResponseWriter, r *http.Request, uploader string) {
|
||||
if Blobs == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
max := config.C.MaxAttachmentBytes
|
||||
|
||||
// 双层限制:MaxBytesReader 卡整个请求体(含 multipart 边界与其他字段),
|
||||
// blob.Put 的 max 卡单个文件内容。少了外层,攻击者可以用超大 multipart 头拖死内存。
|
||||
r.Body = http.MaxBytesReader(w, r.Body, max+1<<20)
|
||||
|
||||
// 32MB 内存缓冲上限,超出部分 multipart 会自动落临时文件
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "缺少 file 字段")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
name := sanitizeFilename(header.Filename)
|
||||
ctype := detectContentType(header.Header.Get("Content-Type"), name)
|
||||
|
||||
// 先落盘再入库:反过来会出现「库里有记录、磁盘没文件」的下载 500
|
||||
sum, size, err := Blobs.Put(file, max)
|
||||
if errors.Is(err, blob.ErrTooLarge) {
|
||||
Error(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("附件超过上限 %.1f MB", float64(max)/(1<<20)))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "保存附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.CreateAttachment(r.Context(), uploader, name, ctype, size, sum)
|
||||
if err != nil {
|
||||
// 落盘成功但入库失败:留下的孤立文件由 GC 回收,不影响正确性
|
||||
Error(w, http.StatusInternalServerError, "登记附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{"attachment": a})
|
||||
}
|
||||
|
||||
// POST /api/v1/attachments —— Agent 侧上传
|
||||
func UploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
uploadAttachment(w, r, agentName)
|
||||
}
|
||||
|
||||
// POST /api/v1/me/attachments —— 人类侧上传
|
||||
func MeUploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
uploadAttachment(w, r, user.Username)
|
||||
}
|
||||
|
||||
// downloadAttachment 是 Agent 与人类两条下载路径的公共实现。
|
||||
func downloadAttachment(w http.ResponseWriter, r *http.Request, viewer string) {
|
||||
if Blobs == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.GetAttachment(r.Context(), id)
|
||||
if errors.Is(err, repo.ErrAttachmentNotFound) {
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := repo.AttachmentAccessible(r.Context(), a, viewer)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "校验权限失败")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该附件")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := Blobs.Open(a.SHA256)
|
||||
if err != nil {
|
||||
// 元数据在库但文件不在盘:说明存储被外部改动过,这是运维问题而非用户输入问题
|
||||
Error(w, http.StatusInternalServerError, "附件内容缺失")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 一律 octet-stream + attachment:绝不按声明的 MIME 内联渲染。
|
||||
// 否则一个上传的 .html/.svg 就能在本站域下执行脚本,等于自带 XSS。
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", a.SizeBytes))
|
||||
w.Header().Set("Content-Disposition", contentDisposition(a.Filename))
|
||||
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// contentDisposition 构造下载头。
|
||||
// filename* 用 RFC 5987 编码承载非 ASCII 名字,filename= 给只认 ASCII 的老客户端兜底;
|
||||
// 兜底值里的引号与反斜杠必须去掉,否则能截断响应头。
|
||||
func contentDisposition(name string) string {
|
||||
var ascii strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r == '"' || r == '\\':
|
||||
ascii.WriteByte('_')
|
||||
case r < 0x20 || r > 0x7e:
|
||||
ascii.WriteByte('_')
|
||||
default:
|
||||
ascii.WriteRune(r)
|
||||
}
|
||||
}
|
||||
fallback := ascii.String()
|
||||
if fallback == "" {
|
||||
fallback = "attachment"
|
||||
}
|
||||
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
|
||||
fallback, urlEncodeRFC5987(name))
|
||||
}
|
||||
|
||||
// urlEncodeRFC5987 按 RFC 5987 的 attr-char 集合做百分号编码。
|
||||
func urlEncodeRFC5987(s string) string {
|
||||
const safe = "!#$&+-.^_`|~" // attr-char 中除字母数字外允许的字符
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
isAlnum := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||
if isAlnum || strings.IndexByte(safe, c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GET /api/v1/attachments/{id} —— Agent 侧下载
|
||||
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
downloadAttachment(w, r, agentName)
|
||||
}
|
||||
|
||||
// GET /api/v1/me/attachments/{id} —— 人类侧下载
|
||||
func MeDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
downloadAttachment(w, r, user.Username)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/me/attachments/{id} —— 删除自己上传且尚未挂载的附件
|
||||
//
|
||||
// 已挂载的不允许删:邮件是不可篡改的历史记录,附件是它的一部分。
|
||||
func MeDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := repo.GetAttachment(r.Context(), id)
|
||||
if errors.Is(err, repo.ErrAttachmentNotFound) {
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "读取附件失败")
|
||||
return
|
||||
}
|
||||
if a.Uploader != user.Username {
|
||||
Error(w, http.StatusForbidden, "只能删除自己上传的附件")
|
||||
return
|
||||
}
|
||||
if a.MailID != nil {
|
||||
Error(w, http.StatusConflict, "附件已随邮件发出,不能删除")
|
||||
return
|
||||
}
|
||||
|
||||
sum, orphaned, err := repo.DeleteAttachment(r.Context(), id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "删除附件失败")
|
||||
return
|
||||
}
|
||||
// 内容寻址下多条记录可能共享同一文件,只有最后一条引用消失才删磁盘
|
||||
if orphaned && Blobs != nil {
|
||||
_ = Blobs.Remove(sum)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// parseAttachmentIDs 把请求里的附件 id 列表解析为 UUID。
|
||||
func parseAttachmentIDs(raw []string) ([]uuid.UUID, error) {
|
||||
out := make([]uuid.UUID, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("非法的 attachment_id %q", s)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachAll 把附件挂到刚创建的邮件上,并把错误翻译成 HTTP 响应。
|
||||
// 返回 false 表示已写出错误响应,调用方应立即返回。
|
||||
func attachAll(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, ids []uuid.UUID, uploader string) bool {
|
||||
if len(ids) == 0 {
|
||||
return true
|
||||
}
|
||||
err := repo.AttachToMail(r.Context(), mailID, ids, uploader)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true
|
||||
case errors.Is(err, repo.ErrAttachmentNotFound):
|
||||
Error(w, http.StatusNotFound, "附件不存在")
|
||||
case errors.Is(err, repo.ErrAttachmentNotOwned):
|
||||
Error(w, http.StatusForbidden, "只能附加自己上传的附件")
|
||||
case errors.Is(err, repo.ErrAttachmentAlreadyAttached):
|
||||
Error(w, http.StatusConflict, "附件已随其他邮件发出")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "附加附件失败")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillAttachments 给邮件填充附件列表(读取单封/线程时用)。
|
||||
// 读附件失败不该让整封邮件打不开,因此吞错只留空列表。
|
||||
func fillAttachments(r *http.Request, mails ...*models.Mail) {
|
||||
for _, m := range mails {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if as, err := repo.ListAttachmentsFor(r.Context(), m.ID); err == nil {
|
||||
m.Attachments = as
|
||||
}
|
||||
}
|
||||
}
|
||||
138
gateway/internal/handler/attachments_test.go
Normal file
138
gateway/internal/handler/attachments_test.go
Normal file
@ -0,0 +1,138 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
414
gateway/internal/handler/auth.go
Normal file
414
gateway/internal/handler/auth.go
Normal file
@ -0,0 +1,414 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 登录 / 登出 / 自身信息 ----------
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type userOut struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
LastLogin string `json:"last_login,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func toUserOut(u *models.User) userOut {
|
||||
o := userOut{
|
||||
UserID: u.ID.String(),
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
AllowedAgents: emptySlice(u.AllowedAgents),
|
||||
AllowedPaths: emptySlice(u.AllowedPaths),
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if u.LastLogin != nil {
|
||||
o.LastLogin = u.LastLogin.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// ---------- 首次初始化 ----------
|
||||
|
||||
// GET /api/v1/setup/status —— 公开:前端据此判断是否展示初始化向导
|
||||
func SetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
needs, err := repo.NeedsSetup(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check setup status")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs})
|
||||
}
|
||||
|
||||
type setupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// POST /api/v1/setup/admin —— 公开,但仅在系统无任何用户时可用
|
||||
func SetupAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
var req setupRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码自少 8 位")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.SetupFirstAdmin(r.Context(), req.Username, req.Password, req.DisplayName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrAlreadySetup):
|
||||
Error(w, http.StatusConflict, "系统已初始化,请直接登录")
|
||||
case errors.Is(err, repo.ErrInvalidUsername):
|
||||
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
|
||||
case errors.Is(err, repo.ErrNameTaken):
|
||||
Error(w, http.StatusConflict, "该名称已被 Agent 占用")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "初始化失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化后直接登录
|
||||
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
|
||||
if err == nil {
|
||||
maxAge := int(time.Until(expires).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
middleware.SetSessionCookie(w, token, maxAge)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(req.Username))
|
||||
if name == "" || req.Password == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing username or password")
|
||||
return
|
||||
}
|
||||
|
||||
if locked, remain := limiter.Locked(name); locked {
|
||||
JSON(w, http.StatusTooManyRequests, map[string]interface{}{
|
||||
"error": "尝试过于频繁,请稍后再试",
|
||||
"retry_after": remain,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.Authenticate(r.Context(), name, req.Password)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrBadCredentials):
|
||||
limiter.Fail(name)
|
||||
Error(w, http.StatusUnauthorized, "用户名或密码错误")
|
||||
case errors.Is(err, repo.ErrUserDisabled):
|
||||
Error(w, http.StatusForbidden, "账号已被禁用")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "登录失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
limiter.Reset(name)
|
||||
|
||||
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "无法创建会话")
|
||||
return
|
||||
}
|
||||
maxAge := int(time.Until(expires).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
middleware.SetSessionCookie(w, token, maxAge)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user": toUserOut(u),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if token := middleware.SessionToken(r); token != "" {
|
||||
_ = repo.DeleteUserSession(r.Context(), token)
|
||||
}
|
||||
middleware.ClearSessionCookie(w)
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
||||
}
|
||||
|
||||
// GET /api/v1/auth/me
|
||||
func Me(w http.ResponseWriter, r *http.Request) {
|
||||
u := middleware.GetUser(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/password
|
||||
func ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
u := middleware.GetUser(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req changePasswordRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
Error(w, http.StatusBadRequest, "新密码至少 8 位")
|
||||
return
|
||||
}
|
||||
if _, err := repo.Authenticate(r.Context(), u.Username, req.OldPassword); err != nil {
|
||||
Error(w, http.StatusUnauthorized, "原密码错误")
|
||||
return
|
||||
}
|
||||
if err := repo.SetPassword(r.Context(), u.ID, req.NewPassword); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "修改密码失败")
|
||||
return
|
||||
}
|
||||
middleware.ClearSessionCookie(w)
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
|
||||
}
|
||||
|
||||
// ---------- 管理员:用户管理 ----------
|
||||
|
||||
// GET /api/v1/admin/users
|
||||
func AdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := repo.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list users")
|
||||
return
|
||||
}
|
||||
out := make([]userOut, 0, len(users))
|
||||
for i := range users {
|
||||
out = append(out, toUserOut(&users[i]))
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"users": out})
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users
|
||||
func AdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req createUserRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码至少 8 位")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.CreateUser(r.Context(), req.Username, req.Password, req.DisplayName, req.Role,
|
||||
req.AllowedAgents, req.AllowedPaths)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrNameTaken):
|
||||
Error(w, http.StatusConflict, "该名称已被用户或 Agent 占用")
|
||||
case errors.Is(err, repo.ErrInvalidUsername):
|
||||
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "创建用户失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
AllowedAgents *[]string `json:"allowed_agents"`
|
||||
AllowedPaths *[]string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/users/{id}
|
||||
func AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateUserRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
// 不允许把最后一个管理员降级或禁用
|
||||
if err := guardLastAdmin(r, id, req.Role, req.Status); err != nil {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, err := repo.UpdateUser(r.Context(), id, repo.UserUpdate{
|
||||
DisplayName: req.DisplayName,
|
||||
Role: req.Role,
|
||||
Status: req.Status,
|
||||
AllowedAgents: req.AllowedAgents,
|
||||
AllowedPaths: req.AllowedPaths,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "更新用户失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/scopes —— 可授权的 Agent 与目录候选
|
||||
func AdminListScopes(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := repo.ListAgents(r.Context(), "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
paths, _ := repo.AllWorkspaceNames(r.Context())
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"agents": emptySlice(names),
|
||||
"paths": emptySlice(paths),
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/users/{id} —— 禁用而非物理删除,保留邮件历史
|
||||
func AdminDisableUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
disabled := "disabled"
|
||||
if err := guardLastAdmin(r, id, nil, &disabled); err != nil {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if err := repo.DisableUser(r.Context(), id); err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "禁用用户失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "disabled"})
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users/{id}/reset
|
||||
func AdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req resetPasswordRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
Error(w, http.StatusBadRequest, "密码至少 8 位")
|
||||
return
|
||||
}
|
||||
if err := repo.SetPassword(r.Context(), id, req.NewPassword); err != nil {
|
||||
if errors.Is(err, repo.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "重置密码失败")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
|
||||
}
|
||||
|
||||
// ---------- 辅助 ----------
|
||||
|
||||
func pathUUID(w http.ResponseWriter, r *http.Request, key string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, key))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid "+key)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// guardLastAdmin 阻止把系统里最后一个可用管理员降级或禁用
|
||||
func guardLastAdmin(r *http.Request, id uuid.UUID, role, status *string) error {
|
||||
demoting := role != nil && *role != "admin"
|
||||
disabling := status != nil && *status != "active"
|
||||
if !demoting && !disabling {
|
||||
return nil
|
||||
}
|
||||
|
||||
target, err := repo.GetUserByID(r.Context(), id)
|
||||
if err != nil || !target.IsAdmin() || target.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
n, err := repo.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if n <= 1 {
|
||||
return errors.New("系统至少需要保留一个可用管理员")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
207
gateway/internal/handler/contacts.go
Normal file
207
gateway/internal/handler/contacts.go
Normal file
@ -0,0 +1,207 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Contacts(左侧联系人界面,按登录用户隔离) ----------
|
||||
|
||||
// GET /api/v1/contacts?archived=false
|
||||
func ListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
archived := r.URL.Query().Get("archived") == "true"
|
||||
|
||||
// 管理员可用 ?all=true 查看全部
|
||||
scope := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
scope = ""
|
||||
}
|
||||
|
||||
contacts, err := repo.ListContactsFor(r.Context(), scope, archived)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list contacts")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"contacts": emptySlice(contacts),
|
||||
})
|
||||
}
|
||||
|
||||
type archiveRequest struct {
|
||||
Address string `json:"address"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// POST /api/v1/contacts/archive
|
||||
// 归档指定 name@path.session:Agent 侧会话归档 + 邮箱界面移除
|
||||
func ArchiveContact(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req archiveRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
var sessionID uuid.UUID
|
||||
switch {
|
||||
case req.SessionID != "":
|
||||
id, err := uuid.Parse(req.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid session_id")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
|
||||
case req.Address != "":
|
||||
addr, err := models.ParseAddress(req.Address)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid address: "+err.Error())
|
||||
return
|
||||
}
|
||||
id, err := repo.FindSessionByAddress(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "No session matches "+req.Address)
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
|
||||
default:
|
||||
Error(w, http.StatusBadRequest, "Provide address or session_id")
|
||||
return
|
||||
}
|
||||
|
||||
// 鉴权:只能归档自己参与的会话(管理员不限)
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权归档他人的会话")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := repo.GetSessionByID(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
mails, _ := repo.GetSessionMails(r.Context(), sessionID)
|
||||
|
||||
if err := repo.ArchiveSession(r.Context(), sessionID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to archive session")
|
||||
return
|
||||
}
|
||||
|
||||
alias := ""
|
||||
if session.Alias != nil {
|
||||
alias = *session.Alias
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": alias,
|
||||
"archived_by": user.Username,
|
||||
}
|
||||
|
||||
// 通知会话内所有参与方(Agent 与人类),各自归档/移除
|
||||
notified := map[string]bool{}
|
||||
for _, m := range mails {
|
||||
names := append([]string{m.FromName, m.ToName}, ccNames(m.CCList)...)
|
||||
for _, name := range names {
|
||||
if name == "" || notified[name] {
|
||||
continue
|
||||
}
|
||||
notified[name] = true
|
||||
sse.Default.SendToRecipient(name, "session_archived", payload)
|
||||
}
|
||||
}
|
||||
if !notified[user.Username] {
|
||||
sse.Default.SendToUser(user.Username, "session_archived", payload)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "archived",
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": alias,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/contacts/suggest?name=xxx&path=yyy
|
||||
// 三段式补全:无 name 给 Agent+人类用户名;有 name 给工作区;两者都有给会话别名(含 new)
|
||||
func SuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
name := r.URL.Query().Get("name")
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
if name == "" {
|
||||
agents, err := repo.ListAgents(r.Context(), "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
users, _ := repo.ListActiveUsernames(r.Context())
|
||||
|
||||
names := make([]string, 0, len(agents)+len(users))
|
||||
for _, a := range agents {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u == user.Username {
|
||||
continue // 不建议给自己发信
|
||||
}
|
||||
names = append(names, u)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "name",
|
||||
"suggestions": emptySlice(names),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
// 人类用户没有工作区,直接给空列表(前端会继续走 session 段)
|
||||
paths, _ := repo.SuggestPaths(r.Context(), name)
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "path",
|
||||
"suggestions": emptySlice(paths),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessions, _ := repo.SuggestSessionsFor(r.Context(), user.Username, name, path)
|
||||
sessions = append(sessions, "new")
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "session",
|
||||
"suggestions": sessions,
|
||||
})
|
||||
}
|
||||
|
||||
func ccNames(list []models.Address) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, a := range list {
|
||||
if a.Name != "" {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
91
gateway/internal/handler/events.go
Normal file
91
gateway/internal/handler/events.go
Normal file
@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
)
|
||||
|
||||
// GET /api/v1/events/stream
|
||||
//
|
||||
// 四种凭证,都必须真正验证过身份才能订阅:
|
||||
// Authorization: Bearer <agent_key_token> → Agent 通道(密钥认证)
|
||||
// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容)
|
||||
// 登录 Cookie 或 Bearer <user_key_token> → 人类用户通道
|
||||
// ?access_token=<token> → 浏览器 EventSource 专用回退
|
||||
//
|
||||
// 注意不能只凭 X-Agent-Name 就分流:那等于任何人报个名字就能读走别人的新邮件通知。
|
||||
// query 令牌仅本端点接受(EventSource 无法带自定义头),其余接口一律要求请求头,
|
||||
// 因为 URL 里的令牌会进访问日志与 Referer。
|
||||
func SSEStream(w http.ResponseWriter, r *http.Request) {
|
||||
agentName, ok := resolveStreamAgent(r)
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "凭证无效")
|
||||
return
|
||||
}
|
||||
|
||||
userName := ""
|
||||
if agentName == "" {
|
||||
u := middleware.OptionalUserWithQuery(r)
|
||||
if u == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
userName = u.Username
|
||||
}
|
||||
|
||||
client := sse.Default.AddClient(w, agentName, userName)
|
||||
if client == nil {
|
||||
Error(w, http.StatusInternalServerError, "SSE not supported")
|
||||
return
|
||||
}
|
||||
|
||||
<-r.Context().Done()
|
||||
sse.Default.RemoveClient(client.ID)
|
||||
}
|
||||
|
||||
// resolveStreamAgent 校验 Agent 侧凭证。
|
||||
// 返回 ("", true) 表示这不是 Agent 请求,交给人类用户分支;
|
||||
// 返回 ("", false) 表示带了 Agent 凭证但验证失败。
|
||||
func resolveStreamAgent(r *http.Request) (string, bool) {
|
||||
// 密钥认证:Bearer 令牌可能是 Agent 密钥,也可能是用户密钥。
|
||||
// 先按 Agent 密钥试,失败就落到人类分支(那里会再按用户密钥试)。
|
||||
token := middleware.BearerToken(r)
|
||||
if token == "" {
|
||||
token = middleware.QueryToken(r) // EventSource 回退
|
||||
}
|
||||
if token != "" {
|
||||
name, err := repo.VerifyAgentKey(r.Context(), token)
|
||||
if err == nil && name != "" {
|
||||
return name, true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
name := r.Header.Get("X-Agent-Name")
|
||||
if name == "" {
|
||||
name = r.URL.Query().Get("agent_name")
|
||||
}
|
||||
if name == "" {
|
||||
return "", true // 非 Agent 请求
|
||||
}
|
||||
|
||||
secret := r.Header.Get("X-Agent-Secret")
|
||||
if secret == "" {
|
||||
return "", false // 报了名字却没给凭证
|
||||
}
|
||||
agent, err := repo.VerifyAgent(r.Context(), name, secret)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return agent.Name, true
|
||||
}
|
||||
|
||||
// GET /api/v1/events/status
|
||||
func SSEStatus(w http.ResponseWriter, r *http.Request) {
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"connected_clients": sse.Default.ClientCount(),
|
||||
})
|
||||
}
|
||||
264
gateway/internal/handler/forward.go
Normal file
264
gateway/internal/handler/forward.go
Normal file
@ -0,0 +1,264 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 转发 ----------
|
||||
//
|
||||
// 转发 = 引用原文 + 新收件人。与「回复」的区别:
|
||||
// 回复(reply_to)落回原会话,收件人是原发件人;
|
||||
// 转发按目标地址的 session 位另行定位会话,收件人是新指定的人。
|
||||
// 因此转发不复用 reply_to,而是走完整的三维寻址。
|
||||
|
||||
type forwardRequest struct {
|
||||
// To 新收件人,完整三维地址
|
||||
To string `json:"to"`
|
||||
// CC 可选抄送
|
||||
CC string `json:"cc"`
|
||||
// Comment 转发者附加的说明,置于引用原文之前
|
||||
Comment string `json:"comment"`
|
||||
// Subject 可选;留空时自动加 "Fwd: " 前缀
|
||||
Subject string `json:"subject"`
|
||||
// SessionAlias 仅在目标地址以 .new 结尾时生效
|
||||
SessionAlias string `json:"session_alias"`
|
||||
}
|
||||
|
||||
// quoteBody 把原文渲染为 Markdown 引用块。
|
||||
// 逐行加 "> " 而不是整段包裹:原文本身可能含代码块与列表,
|
||||
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
|
||||
func quoteBody(m *models.Mail) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(fmt.Sprintf("> **转发自** %s", m.FromName))
|
||||
if m.FromWorkspace != "" {
|
||||
b.WriteString("@" + m.FromWorkspace)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("> **主题** %s\n", m.Subject))
|
||||
b.WriteString(fmt.Sprintf("> **时间** %s\n", m.CreatedAt.Format("2006-01-02 15:04:05")))
|
||||
if len(m.CCList) > 0 {
|
||||
names := make([]string, 0, len(m.CCList))
|
||||
for _, c := range m.CCList {
|
||||
names = append(names, c.Raw)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("> **抄送** %s\n", strings.Join(names, ", ")))
|
||||
}
|
||||
b.WriteString(">\n")
|
||||
for _, line := range strings.Split(m.Body, "\n") {
|
||||
b.WriteString("> " + line + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// forwardSubject 生成转发主题,避免 "Fwd: Fwd: Fwd:" 无限叠加。
|
||||
func forwardSubject(custom, original string) string {
|
||||
if s := strings.TrimSpace(custom); s != "" {
|
||||
return s
|
||||
}
|
||||
if strings.HasPrefix(original, "Fwd: ") {
|
||||
return original
|
||||
}
|
||||
return "Fwd: " + original
|
||||
}
|
||||
|
||||
// doForward 是 Agent 与人类两条转发路径的公共实现。
|
||||
// actor 是转发者名(Agent 名或用户名),fromWorkspace 仅 Agent 有。
|
||||
func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, fromWorkspace string, isAgent bool) {
|
||||
var req forwardRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.To) == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to")
|
||||
return
|
||||
}
|
||||
|
||||
src, err := repo.LoadForwardSource(r.Context(), mailID, actor)
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrMailNotFound):
|
||||
Error(w, http.StatusNotFound, "待转发的邮件不存在")
|
||||
return
|
||||
case errors.Is(err, repo.ErrForwardNotAllowed):
|
||||
Error(w, http.StatusForbidden, "只能转发自己参与过的邮件")
|
||||
return
|
||||
case err != nil:
|
||||
Error(w, http.StatusInternalServerError, "Failed to load mail")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user := middleware.GetUser(r)
|
||||
if !isAgent && user != nil {
|
||||
to = resolveHumanAlias(to, user.Username)
|
||||
for i := range ccList {
|
||||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||||
}
|
||||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||||
Error(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
subject := forwardSubject(req.Subject, src.Subject)
|
||||
|
||||
// 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话
|
||||
sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
if isAgent {
|
||||
quota, qErr := repo.ConsumeQuota(r.Context(), actor)
|
||||
if errors.Is(qErr, repo.ErrQuotaExhausted) {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"发信配额已用尽(%d/%d)。请先向人类发送最终总结,或联系管理员重置配额。",
|
||||
quota.Used, quota.Max))
|
||||
return
|
||||
}
|
||||
if qErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check quota")
|
||||
return
|
||||
}
|
||||
} else if user != nil {
|
||||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||||
}
|
||||
|
||||
body := quoteBody(src)
|
||||
if c := strings.TrimSpace(req.Comment); c != "" {
|
||||
body = c + "\n\n" + body
|
||||
}
|
||||
attachedCount := 0
|
||||
|
||||
// parent_mail_id 指向原邮件:即便落在新会话里,也能回溯这封转发从何而来
|
||||
newID, err := repo.CreateMail(r.Context(), sessionID, &src.ID,
|
||||
actor, fromWorkspace, to.Name, to.Path, subject, body, ccList)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
|
||||
// 附件随转发一同带过去——只引用正文而丢掉附件,收件人拿到的是一封残缺的邮件。
|
||||
// 内容寻址下这只是新增元数据,不拷磁盘文件。
|
||||
if n, err := repo.CopyAttachmentsTo(r.Context(), src.ID, newID, actor); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "复制附件失败")
|
||||
return
|
||||
} else {
|
||||
attachedCount = n
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, newID, actor, subject)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mail_id": newID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
"forwarded_from": src.ID.String(),
|
||||
"attachments": attachedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/{id}/forward —— Agent 侧转发
|
||||
func ForwardMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doForward(w, r, mailID, agentName, agentName, true)
|
||||
}
|
||||
|
||||
// POST /api/v1/me/mail/{id}/forward —— 人类侧转发
|
||||
func MeForwardMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doForward(w, r, mailID, user.Username, "", false)
|
||||
}
|
||||
|
||||
// ---------- 配额管理(管理员) ----------
|
||||
|
||||
// GET /api/v1/admin/quotas
|
||||
func AdminListQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
quotas, err := repo.ListQuotas(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list quotas")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quotas": quotas})
|
||||
}
|
||||
|
||||
type setQuotaRequest struct {
|
||||
// MaxRounds 发信配额上限;0 = 不限
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
// Reset 为 true 时把已用次数归零
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/quotas/{name}
|
||||
func AdminSetQuota(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
|
||||
var req setQuotaRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.MaxRounds == nil && !req.Reset {
|
||||
Error(w, http.StatusBadRequest, "需要 max_rounds 或 reset 之一")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
q repo.Quota
|
||||
err error
|
||||
)
|
||||
if req.MaxRounds != nil {
|
||||
if q, err = repo.SetQuota(r.Context(), name, *req.MaxRounds); err != nil {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Reset {
|
||||
if q, err = repo.ResetQuota(r.Context(), name); err != nil {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quota": q})
|
||||
}
|
||||
77
gateway/internal/handler/forward_test.go
Normal file
77
gateway/internal/handler/forward_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 转发主题不能无限叠加 Fwd: 前缀,否则转发几轮后主题栏全是前缀。
|
||||
func TestForwardSubject(t *testing.T) {
|
||||
cases := []struct{ custom, original, want string }{
|
||||
{"", "修复登录态", "Fwd: 修复登录态"},
|
||||
{"", "Fwd: 修复登录态", "Fwd: 修复登录态"}, // 已有前缀不再叠加
|
||||
{"自定义主题", "修复登录态", "自定义主题"},
|
||||
{" ", "修复登录态", "Fwd: 修复登录态"}, // 全空白视为未指定
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := forwardSubject(c.custom, c.original); got != c.want {
|
||||
t.Errorf("forwardSubject(%q, %q) = %q, want %q", c.custom, c.original, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 引用块必须逐行加 "> ":原文含代码块或列表时,
|
||||
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
|
||||
func TestQuoteBodyPrefixesEveryLine(t *testing.T) {
|
||||
m := &models.Mail{
|
||||
ID: uuid.New(),
|
||||
FromName: "opencode",
|
||||
FromWorkspace: "/root",
|
||||
Subject: "巡检结果",
|
||||
Body: "第一行\n\n```go\nfmt.Println(1)\n```\n- 列表项",
|
||||
CreatedAt: time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC),
|
||||
CCList: []models.Address{
|
||||
{Name: "pi", Path: "root", Raw: "pi@root.new"},
|
||||
},
|
||||
}
|
||||
|
||||
out := quoteBody(m)
|
||||
|
||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||||
if line == "---" || line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, ">") {
|
||||
t.Errorf("引用块出现未加前缀的行: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// 元信息必须齐全,否则收件人不知道这封转发的来路
|
||||
for _, want := range []string{"opencode@/root", "巡检结果", "2026-09-02 10:30:00", "pi@root.new"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("引用块缺少 %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 原文正文本身要在引用里
|
||||
if !strings.Contains(out, "> fmt.Println(1)") {
|
||||
t.Errorf("原文代码行未被引用:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 无抄送时不该渲染出空的「抄送」行。
|
||||
func TestQuoteBodyOmitsEmptyCC(t *testing.T) {
|
||||
m := &models.Mail{
|
||||
FromName: "admin",
|
||||
Subject: "x",
|
||||
Body: "y",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if strings.Contains(quoteBody(m), "抄送") {
|
||||
t.Error("无抄送时不应出现「抄送」行")
|
||||
}
|
||||
}
|
||||
128
gateway/internal/handler/helpers.go
Normal file
128
gateway/internal/handler/helpers.go
Normal file
@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// JSON 写入 JSON 响应
|
||||
func JSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// Error 写入错误响应
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
JSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// Decode 从请求体解析 JSON
|
||||
func Decode(r *http.Request, v interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// httpError 携带 HTTP 状态码的错误
|
||||
type httpError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e httpError) Error() string { return e.msg }
|
||||
|
||||
func errBadRequest(msg string) error { return httpError{http.StatusBadRequest, msg} }
|
||||
func errNotFound(msg string) error { return httpError{http.StatusNotFound, msg} }
|
||||
func errConflict(msg string) error { return httpError{http.StatusConflict, msg} }
|
||||
|
||||
// writeKeyErr 把 repo 层的密钥错误映射成 HTTP 响应。
|
||||
// 「已使用 / 已过期」与「无效」分开报,便于运维判断是重签还是查配置。
|
||||
func writeKeyErr(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrKeyUsed):
|
||||
Error(w, http.StatusUnauthorized, "密钥已使用(一次性密钥只能用一次)")
|
||||
case errors.Is(err, repo.ErrKeyExpired):
|
||||
Error(w, http.StatusUnauthorized, "密钥已过期")
|
||||
case errors.Is(err, repo.ErrKeyNotFound):
|
||||
Error(w, http.StatusUnauthorized, "密钥无效")
|
||||
case errors.Is(err, repo.ErrKeyTypeInvalid):
|
||||
Error(w, http.StatusBadRequest, "密钥类型非法,应为 permanent / one_time / timed")
|
||||
case errors.Is(err, repo.ErrKeyNeedsExpiry):
|
||||
Error(w, http.StatusBadRequest, "timed 密钥必须给出正的 expires_hours")
|
||||
case errors.Is(err, repo.ErrKeyTooShort):
|
||||
Error(w, http.StatusBadRequest, "密钥太短(至少 32 位)")
|
||||
case errors.Is(err, repo.ErrKeyTokenTaken):
|
||||
Error(w, http.StatusConflict, "该密钥已登记过")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "密钥操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。
|
||||
// "new" 是寻址保留字;含 . 会让 path/session 切分歧义;含 @ 与空白同理。
|
||||
func validateSessionAlias(alias string) error {
|
||||
if alias == "new" {
|
||||
return errBadRequest(`会话别名不可为 "new":该词已作为寻址保留字`)
|
||||
}
|
||||
if strings.ContainsAny(alias, ". \t/@") {
|
||||
return errBadRequest("会话别名不可含 . 空白 / 或 @(会与三维地址解析冲突)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeAlias 把 Agent 平台侧的 slug/标题改写为合法的寻址别名。
|
||||
//
|
||||
// 平台侧命名不一定遵守本侧的寻址约束(可能含 . / @ 空白),直接入库会让
|
||||
// name@path.session 切分歧义,因此非法字符统一换成 -,并压缩连续的 -。
|
||||
// 保留字 "new" 加前缀避开;全部不可用时返回空串交由调用方报错。
|
||||
func normalizeAlias(s string) string {
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '.' || r == '/' || r == '@' || r == ' ' || r == '\t' || r == '\n' || r == '\r':
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "new" {
|
||||
return "session-new"
|
||||
}
|
||||
// VARCHAR(128) 上限,按字节截断时不能切坏多字节字符
|
||||
const maxBytes = 128
|
||||
if len(out) > maxBytes {
|
||||
cut := out[:maxBytes]
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
out = strings.Trim(cut, "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeErr 将 httpError 按其状态码写出,其余错误统一 500 + fallback 文案
|
||||
func writeErr(w http.ResponseWriter, err error, fallback string) {
|
||||
if he, ok := err.(httpError); ok {
|
||||
Error(w, he.status, he.msg)
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, fallback)
|
||||
}
|
||||
|
||||
// emptySlice 把 nil slice 转为空 JSON 数组 []
|
||||
func emptySlice[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
179
gateway/internal/handler/keys.go
Normal file
179
gateway/internal/handler/keys.go
Normal file
@ -0,0 +1,179 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// ---------- 密钥管理 ----------
|
||||
//
|
||||
// 两套接口,权限边界不同:
|
||||
// /admin/agent-keys —— 管理员签发 Agent 接入密钥
|
||||
// /me/keys —— 用户自助签发客户端连接密钥(不能注册 Agent)
|
||||
//
|
||||
// 密钥全文只在创建响应里出现一次,列表接口只给前 8 位 hint。
|
||||
|
||||
type createKeyRequest struct {
|
||||
// AgentName 仅 Agent 密钥使用;留空表示「待绑定」,首次注册时按注册请求的 name 落定
|
||||
AgentName string `json:"agent_name"`
|
||||
// Label 人类可读备注(如「我的笔记本」「CI 机器」)
|
||||
Label string `json:"label"`
|
||||
// KeyType permanent / one_time / timed
|
||||
KeyType string `json:"key_type"`
|
||||
// ExpiresHours 仅 timed 使用,必须为正
|
||||
ExpiresHours int `json:"expires_hours"`
|
||||
// KeyToken 仅 Agent 密钥使用:登记一把客户端已在本地生成的密钥。
|
||||
// 插件首次安装时自己生成密钥并打印出来,管理员把它填到这里完成登记,
|
||||
// 密钥全文因此不需要从服务器往客户端传。留空则由服务器生成。
|
||||
KeyToken string `json:"key_token"`
|
||||
}
|
||||
|
||||
// normalizeKeyType 默认给 permanent,避免调用方漏填时落到非法值
|
||||
func normalizeKeyType(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return models.KeyPermanent
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/agent-keys
|
||||
func CreateAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
admin := middleware.GetUser(r)
|
||||
if admin == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req createKeyRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
key, err := repo.CreateAgentKey(r.Context(),
|
||||
strings.TrimSpace(req.AgentName), normalizeKeyType(req.KeyType),
|
||||
strings.TrimSpace(req.Label), req.ExpiresHours, admin.ID,
|
||||
strings.TrimSpace(req.KeyToken))
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 唯一一次回传全文
|
||||
JSON(w, http.StatusOK, map[string]any{"key": key})
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/agent-keys?agent_name=xxx
|
||||
func ListAgentKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := repo.ListAgentKeys(r.Context(), r.URL.Query().Get("agent_name"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list keys")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"keys": keys})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/agent-keys/{id}
|
||||
func DeleteAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := repo.DeleteAgentKey(r.Context(), id); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
type bindKeyRequest struct {
|
||||
AgentName string `json:"agent_name"`
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/agent-keys/{id}/bind
|
||||
func BindAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req bindKeyRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.AgentName)
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent_name")
|
||||
return
|
||||
}
|
||||
if err := repo.BindAgentKey(r.Context(), id, name); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "bound", "agent_name": name})
|
||||
}
|
||||
|
||||
// ---------- 用户连接密钥 ----------
|
||||
|
||||
// POST /api/v1/me/keys
|
||||
func CreateMyKey(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req createKeyRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
key, err := repo.CreateUserKey(r.Context(), user.ID,
|
||||
strings.TrimSpace(req.Label), normalizeKeyType(req.KeyType), req.ExpiresHours)
|
||||
if err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"key": key})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/keys
|
||||
func ListMyKeys(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
keys, err := repo.ListUserKeys(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list keys")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"keys": keys})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/me/keys/{id}
|
||||
func DeleteMyKey(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
id, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// repo 层带 user_id 条件,删不到就是不属于自己或不存在,统一 404
|
||||
if err := repo.DeleteUserKey(r.Context(), user.ID, id); err != nil {
|
||||
writeKeyErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
421
gateway/internal/handler/mail.go
Normal file
421
gateway/internal/handler/mail.go
Normal file
@ -0,0 +1,421 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Mail ----------
|
||||
|
||||
type sendMailRequest struct {
|
||||
To string `json:"to"` // name@path.session(省略 session=默认会话,new=新建,别名=必须已存在)
|
||||
CC string `json:"cc"` // 逗号/分号/空格分隔的多个 name@path.session
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名,
|
||||
// 之后即可用 name@path.<alias> 续谈。命中已有会话时该字段被忽略。
|
||||
SessionAlias string `json:"session_alias"`
|
||||
// AttachmentIDs 先用 POST /attachments 上传拿到的 id;只能附加自己上传且未挂载的
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
|
||||
// Relay 标识本次发信是【插件代劳转发】而不是模型自主发信。
|
||||
//
|
||||
// 基本原则:**配额约束的是模型的自主发信,不是 harness 的转发**。
|
||||
// 平台原生的权限询问与本轮的最终总结都是插件搬运的,不计配额。
|
||||
//
|
||||
// RelayKey 必須是上游那条消息的稳定标识(permission id / assistant message id):
|
||||
// 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。
|
||||
Relay string `json:"relay"` // "" | "permission" | "summary"
|
||||
RelayKey string `json:"relay_key"` // 上游消息 id;relay 非空时必填
|
||||
}
|
||||
|
||||
// resolveTarget 根据三维地址 name@path.session 决定投递的会话。
|
||||
//
|
||||
// session 位三态语义(设计文档):
|
||||
// - 省略(pi@root) → 投递到 name@path 的默认会话;从未通信则建立
|
||||
// - new(pi@root.new) → 强制新建一个会话
|
||||
// - 具体别名(pi@root.fix-leak)→ 必须已存在且该收件人参与过,否则 404 无法送达
|
||||
//
|
||||
// alias 为新建会话命名(仅新建时生效),使其之后可被 name@path.<alias> 寻址。
|
||||
// reply_to 优先于地址:显式回复某封邮件时沿用该邮件的会话。
|
||||
func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string) (uuid.UUID, *uuid.UUID, error) {
|
||||
if replyTo != "" {
|
||||
replyID, err := uuid.Parse(replyTo)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID")
|
||||
}
|
||||
mail, err := repo.GetMailByID(r.Context(), replyID)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, errNotFound("Parent mail not found")
|
||||
}
|
||||
repo.TouchSession(r.Context(), mail.SessionID)
|
||||
return mail.SessionID, &replyID, nil
|
||||
}
|
||||
|
||||
switch addr.Mode() {
|
||||
case models.SessionNew:
|
||||
// 新建会话:若调用方给了别名,当场命名,之后即可用 name@path.<alias> 续谈。
|
||||
// 别名全局唯一(负责寻址),已被占用时报 409 而不是静默吐出重名会话。
|
||||
var aliasPtr *string
|
||||
if a := strings.TrimSpace(alias); a != "" {
|
||||
if err := validateSessionAlias(a); err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil {
|
||||
return uuid.Nil, nil, errConflict(fmt.Sprintf(
|
||||
"会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a))
|
||||
}
|
||||
aliasPtr = &a
|
||||
}
|
||||
id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject)
|
||||
return id, nil, err
|
||||
|
||||
case models.SessionDefault:
|
||||
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
|
||||
return id, nil, err
|
||||
|
||||
default: // models.SessionNamed
|
||||
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
if errors.Is(err, repo.ErrSessionNotFound) {
|
||||
return uuid.Nil, nil, errNotFound(fmt.Sprintf(
|
||||
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位",
|
||||
addr.Session, addr.Name, addr.Path, addr.Name, addr.Path))
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
repo.TouchSession(r.Context(), id)
|
||||
return id, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/send
|
||||
func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req sendMailRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
|
||||
// 配额在建邮件之前扣:否则邮件已入库再报 403,收件方会看到一封发件方以为发失败的邮件。
|
||||
// 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。
|
||||
//
|
||||
// 插件代劳转发(relay)走免配额通道:配额约束的是模型的自主发信,
|
||||
// 不是 harness 把平台原生的权限询问与最终总结搬到邮件里。
|
||||
relay, relayKey, err := parseRelay(req.Relay, req.RelayKey)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Invalid relay")
|
||||
return
|
||||
}
|
||||
|
||||
var quota repo.Quota
|
||||
var budget repo.SessionBudget
|
||||
if relay != "" {
|
||||
// 先占幂等键。重复则说明这条上游消息已经转过,
|
||||
// 这是插件重试 / SSE 重放的正常结果,不是故障 —— 幂等地返回成功。
|
||||
if cErr := repo.ClaimRelay(r.Context(), agentName, relayKey, relay); cErr != nil {
|
||||
if errors.Is(cErr, repo.ErrRelayDuplicate) {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "duplicate_relay",
|
||||
"relay": relay,
|
||||
"relay_key": relayKey,
|
||||
"detail": "该上游消息已转发过,本次调用未产生新邮件",
|
||||
})
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||||
return
|
||||
}
|
||||
// 仅读快照用于回传,不扣任何一层
|
||||
quota, _ = repo.GetQuota(r.Context(), agentName)
|
||||
budget, _ = repo.GetSessionBudget(r.Context(), sessionID)
|
||||
} else {
|
||||
// 两层都要过:会话预算管「这件事值得多少个来回」,
|
||||
// Agent 全局配额管「这个 Agent 总共能发多少」。
|
||||
// 先扣会话、后扣全局;全局拦下时把会话那次退回去 ——
|
||||
// 那次往返实际上没有发生,不能白掉一格。
|
||||
budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID)
|
||||
if errors.Is(err, repo.ErrSessionBudgetExhausted) {
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"本会话的往返预算已用尽(%d/%d)。自动转发的总结与权限询问不占预算;"+
|
||||
"若需继续主动发信,请让人在对话页调高本会话的预算。",
|
||||
budget.Used, budget.Max))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check session budget")
|
||||
return
|
||||
}
|
||||
|
||||
quota, err = repo.ConsumeQuota(r.Context(), agentName)
|
||||
if errors.Is(err, repo.ErrQuotaExhausted) {
|
||||
repo.RefundSessionBudget(r.Context(), sessionID)
|
||||
Error(w, http.StatusForbidden, fmt.Sprintf(
|
||||
"Agent 全局发信配额已用尽(%d/%d)。插件代劳转发的权限询问与最终总结不占配额;"+
|
||||
"若需继续主动发信请联系管理员重置配额。",
|
||||
quota.Used, quota.Max))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
repo.RefundSessionBudget(r.Context(), sessionID)
|
||||
Error(w, http.StatusInternalServerError, "Failed to check quota")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Agent 可以在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||||
// 标记从入库正文里剥掉:它是给系统看的元数据,不该出现在人读的正文里
|
||||
// (react-markdown 会把 HTML 注释转义成可见文本,不会自动吞掉)。
|
||||
//
|
||||
// 提议只是提议 —— 别名是人的寻址入口,Agent 干到一半自己改掉会让人
|
||||
// 上一秒记住的地址下一秒失效。真正改名要等用户在前端点「接受」。
|
||||
proposal, body := extractRenameProposal(req.Body)
|
||||
|
||||
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
|
||||
agentName, agentName, to.Name, to.Path, req.Subject, body, ccList)
|
||||
if err != nil {
|
||||
// 建邮件失败时必须把幂等键还回去,否则这条上游消息永远转不出来了
|
||||
if relay != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
if relay != "" {
|
||||
// 关联失败不影响功能,只是少一条审计记录
|
||||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||||
}
|
||||
if proposal != nil {
|
||||
// 记不上提议不该让发信失败:邮件本身已经入库,提议是旁支信息
|
||||
_ = repo.SetMailRenameProposal(r.Context(), mailID, proposal.Alias, proposal.Reason)
|
||||
}
|
||||
|
||||
if !attachAll(w, r, mailID, attachIDs, agentName) {
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, mailID, agentName, req.Subject)
|
||||
|
||||
// 回传会话别名与剩余配额,让发件方知道后续用什么地址续谈、还能发几封
|
||||
resp := map[string]any{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
}
|
||||
if !quota.Unlimited {
|
||||
resp["quota_remaining"] = quota.Remaining
|
||||
resp["quota_used"] = quota.Used
|
||||
resp["quota_max"] = quota.Max
|
||||
}
|
||||
// 会话预算是【本任务】的剩余往返,Agent 更应该看这个而不是全局配额
|
||||
if !budget.Unlimited {
|
||||
resp["budget_remaining"] = budget.Remaining
|
||||
resp["budget_used"] = budget.Used
|
||||
resp["budget_max"] = budget.Max
|
||||
}
|
||||
if relay != "" {
|
||||
// 告知本次未扣配额,否则插件看到 quota_remaining 没变会以为数据错了
|
||||
resp["relay"] = relay
|
||||
resp["quota_charged"] = false
|
||||
}
|
||||
if proposal != nil {
|
||||
// 回传规范化后的别名:Agent 提的名字可能含非法字符被改写过,
|
||||
// 让它知道最终会拿什么去问用户
|
||||
resp["rename_proposed"] = proposal.Alias
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// notifyRecipients 向主收件人与抄送方推送 new_mail,并刷新相关方的会话列表。
|
||||
// 收件人可能是 Agent 也可能是人类用户(三维地址 name 位共享命名空间),
|
||||
// 因此统一用 SendToRecipient 同时试 Agent 通道与用户通道。
|
||||
func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) {
|
||||
payload := func(role string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"from_name": from,
|
||||
"subject": subject,
|
||||
"mail_type": "normal",
|
||||
"role": role, // to / cc
|
||||
}
|
||||
}
|
||||
|
||||
update := map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
// 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱)
|
||||
seen := map[string]bool{}
|
||||
|
||||
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to"))
|
||||
sse.Default.SendToRecipient(to.Name, "session_update", update)
|
||||
seen[to.Name] = true
|
||||
|
||||
for _, c := range cc {
|
||||
if seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc"))
|
||||
sse.Default.SendToRecipient(c.Name, "session_update", update)
|
||||
}
|
||||
|
||||
if !seen[from] {
|
||||
sse.Default.SendToRecipient(from, "session_update", update)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/inbox
|
||||
func GetInbox(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "unread"
|
||||
}
|
||||
limit := 10
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListInbox(r.Context(), agentName, status, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list inbox")
|
||||
return
|
||||
}
|
||||
// Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id
|
||||
for i := range mails {
|
||||
fillAttachments(r, &mails[i])
|
||||
}
|
||||
total, _ := repo.CountUnread(r.Context(), agentName)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/{id} —— 需登录,且需对所属会话有权限
|
||||
func GetMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
fillAttachments(r, mail)
|
||||
JSON(w, http.StatusOK, mail)
|
||||
}
|
||||
|
||||
// POST /api/v1/mail/{id}/read —— 需登录,只能标记自己可见的邮件
|
||||
func MarkMailRead(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权操作该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.MarkMailRead(r.Context(), mailID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to mark read")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "read"})
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, nil
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
285
gateway/internal/handler/me.go
Normal file
285
gateway/internal/handler/me.go
Normal file
@ -0,0 +1,285 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- /me:当前登录人类用户的邮箱(全部路由需 UserAuth) ----------
|
||||
|
||||
type meSendMailRequest struct {
|
||||
To string `json:"to"` // name@path.session
|
||||
CC string `json:"cc"` // 多个 name@path.session
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
// SessionAlias 仅在本次投递【新建】会话时生效,为新会话命名
|
||||
SessionAlias string `json:"session_alias"`
|
||||
// AttachmentIDs 先用 POST /me/attachments 上传拿到的 id
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
// MaxRounds 是本次任务的往返预算(0/省略 = 不限)。
|
||||
//
|
||||
// 配额的真实语义是「这件事值得多少个来回」——那是任务的属性,
|
||||
// 所以在派活的这一刻给,而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||||
// 仅在本次投递【新建】会话时生效;续谈已有会话请用
|
||||
// PUT /sessions/{id}/budget(对话页里可随时改)。
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
}
|
||||
|
||||
// POST /api/v1/me/mail/send
|
||||
func MeSendMail(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req meSendMailRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing to, subject, or body")
|
||||
return
|
||||
}
|
||||
|
||||
to, err := models.ParseAddress(req.To)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid to address: "+err.Error())
|
||||
return
|
||||
}
|
||||
ccList, err := models.ParseAddressList(req.CC)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid cc address: "+err.Error())
|
||||
return
|
||||
}
|
||||
attachIDs, err := parseAttachmentIDs(req.AttachmentIDs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// human@ 是兼容别名,人类发信时解析为自己
|
||||
to = resolveHumanAlias(to, user.Username)
|
||||
for i := range ccList {
|
||||
ccList[i] = resolveHumanAlias(ccList[i], user.Username)
|
||||
}
|
||||
|
||||
// 权限边界:校验可调用的 Agent 与可访问的目录
|
||||
if msg := checkScope(r, user, append([]models.Address{to}, ccList...)); msg != "" {
|
||||
Error(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias)
|
||||
if err != nil {
|
||||
writeErr(w, err, "Failed to resolve session")
|
||||
return
|
||||
}
|
||||
// 人类发起的会话归属于该用户
|
||||
_ = repo.SetSessionOwner(r.Context(), sessionID, user.ID)
|
||||
|
||||
// 新建会话时接受往返预算。只在新建时设:续谈已有会话若也接受这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算,人却不一定意识到自己改了。
|
||||
if req.MaxRounds != nil && parentMailID == nil {
|
||||
if *req.MaxRounds < 0 {
|
||||
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
if _, err := repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set session budget")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议),
|
||||
// 但仍然剥掉标记:粘贴进正文时它会被渲染成一行可见的转义文本。
|
||||
_, body := extractRenameProposal(req.Body)
|
||||
|
||||
mailID, err := repo.CreateMail(r.Context(), sessionID, parentMailID,
|
||||
user.Username, "", to.Name, to.Path, req.Subject, body, ccList)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create mail")
|
||||
return
|
||||
}
|
||||
|
||||
if !attachAll(w, r, mailID, attachIDs, user.Username) {
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, mailID, user.Username, req.Subject)
|
||||
|
||||
resp := map[string]any{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"session_alias": repo.SessionAliasOf(r.Context(), sessionID),
|
||||
}
|
||||
// 回传预算,让前端不必再单独查一次就能显示「本任务还剩几个来回」
|
||||
if b, err := repo.GetSessionBudget(r.Context(), sessionID); err == nil && !b.Unlimited {
|
||||
resp["budget_max"] = b.Max
|
||||
resp["budget_used"] = b.Used
|
||||
resp["budget_remaining"] = b.Remaining
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/v1/me/mail/inbox
|
||||
func MeGetInbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListInbox(r.Context(), user.Username, status, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list inbox")
|
||||
return
|
||||
}
|
||||
// 列表页要显示附件图标与下载入口
|
||||
for i := range mails {
|
||||
fillAttachments(r, &mails[i])
|
||||
}
|
||||
total, _ := repo.CountUnread(r.Context(), user.Username)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/mail/sent
|
||||
func MeGetSent(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
mails, err := repo.ListSentBy(r.Context(), user.Username, limit)
|
||||
if err == nil {
|
||||
for i := range mails {
|
||||
fillAttachments(r, &mails[i])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list sent")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/me/sessions
|
||||
func MeGetSessions(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
scope := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
scope = ""
|
||||
}
|
||||
|
||||
sessions, err := repo.ListSessionsFor(r.Context(), scope, 50)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list sessions")
|
||||
return
|
||||
}
|
||||
|
||||
type SessionOut struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
SessionAlias *string `json:"session_alias"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MailCount int `json:"mail_count"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}
|
||||
|
||||
result := make([]SessionOut, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
unread, _ := repo.CountUnreadInSession(r.Context(), user.Username, s.ID)
|
||||
result = append(result, SessionOut{
|
||||
SessionID: s.ID,
|
||||
SessionAlias: s.Alias,
|
||||
FromAgent: s.FromAgent,
|
||||
Subject: s.Subject,
|
||||
Status: s.Status,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
MailCount: s.MailCount,
|
||||
UnreadCount: unread,
|
||||
})
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"sessions": result,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveHumanAlias 把兼容别名 human 解析为具体用户名
|
||||
func resolveHumanAlias(a models.Address, username string) models.Address {
|
||||
if a.Name != "human" {
|
||||
return a
|
||||
}
|
||||
a.Name = username
|
||||
a.Raw = username + "@" + a.Path
|
||||
if a.Session != "" {
|
||||
a.Raw += "." + a.Session
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// checkScope 校验用户的 Agent 白名单与目录白名单;返回空串表示通过。
|
||||
// 收件方是人类用户时不受 Agent 白名单约束(人与人通信始终允许)。
|
||||
func checkScope(r *http.Request, user *models.User, addrs []models.Address) string {
|
||||
if user.IsAdmin() {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if a.Name == "" || a.Name == user.Username {
|
||||
continue
|
||||
}
|
||||
isHuman, err := repo.IsHumanUser(r.Context(), a.Name)
|
||||
if err != nil {
|
||||
return "无法校验收件人权限"
|
||||
}
|
||||
if !isHuman && !user.CanUseAgent(a.Name) {
|
||||
return "无权调用 Agent: " + a.Name
|
||||
}
|
||||
if !user.CanUsePath(a.Path) {
|
||||
return "无权访问目录: " + a.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
279
gateway/internal/handler/permission.go
Normal file
279
gateway/internal/handler/permission.go
Normal file
@ -0,0 +1,279 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Permission ----------
|
||||
|
||||
type permissionRequestRequest struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
Context string `json:"context"`
|
||||
SessionID *string `json:"session_id"`
|
||||
// 可选:显式指定决策人(人类用户名)。省略时由会话 owner 决定。
|
||||
To string `json:"to"`
|
||||
// RelayKey 是上游那条权限询问的稳定 id(opencode 的 permission.id)。
|
||||
//
|
||||
// 权限请求本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」),
|
||||
// 这里要的只是**幂等**:permission.updated 事件会重复触发,插件也会重连重放,
|
||||
// 没有幂等键就会给同一次询问生成好几封邮件。
|
||||
RelayKey string `json:"relay_key"`
|
||||
}
|
||||
|
||||
type permissionDecideRequest struct {
|
||||
MailID string `json:"mail_id"`
|
||||
Decision string `json:"decision"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// POST /api/v1/permission/request
|
||||
func RequestPermission(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req permissionRequestRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Question == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing question")
|
||||
return
|
||||
}
|
||||
|
||||
options := req.Options
|
||||
if len(options) == 0 {
|
||||
options = []string{"同意", "拒绝"}
|
||||
}
|
||||
|
||||
// 幂等:同一条上游询问只生成一封邮件。
|
||||
// 重复不是故障(插件重试/事件重放的正常结果),因此幂等地返回已存在的结论而非报错。
|
||||
relayKey := strings.TrimSpace(req.RelayKey)
|
||||
if relayKey != "" {
|
||||
if len(relayKey) > 160 {
|
||||
Error(w, http.StatusBadRequest, "relay_key 过长(上限 160 字节)")
|
||||
return
|
||||
}
|
||||
if err := repo.ClaimRelay(r.Context(), agentName, relayKey, "permission"); err != nil {
|
||||
if errors.Is(err, repo.ErrRelayDuplicate) {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "duplicate_relay",
|
||||
"relay_key": relayKey,
|
||||
"detail": "该权限询问已转发过,本次调用未产生新邮件",
|
||||
})
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to claim relay")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 确定 session
|
||||
var sessionID uuid.UUID
|
||||
if req.SessionID != nil && *req.SessionID != "" {
|
||||
id, err := uuid.Parse(*req.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid session_id")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
repo.TouchSession(r.Context(), sessionID)
|
||||
} else {
|
||||
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create session")
|
||||
return
|
||||
}
|
||||
sessionID = id
|
||||
}
|
||||
|
||||
// 决策人:显式指定优先,否则取会话 owner
|
||||
decider := req.To
|
||||
if decider == "" || decider == "human" {
|
||||
owner, err := repo.SessionOwnerUsername(r.Context(), sessionID)
|
||||
if err == nil && owner != "" {
|
||||
decider = owner
|
||||
}
|
||||
}
|
||||
if decider == "" {
|
||||
// 会话无归属(Agent 自发起)时退回默认管理员
|
||||
admin, err := repo.FirstAdminUsername(r.Context())
|
||||
if err != nil || admin == "" {
|
||||
Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to")
|
||||
return
|
||||
}
|
||||
decider = admin
|
||||
}
|
||||
|
||||
body := req.Context
|
||||
if body == "" {
|
||||
body = req.Question
|
||||
}
|
||||
mailID, err := repo.CreatePermissionMail(r.Context(), sessionID, agentName, decider, req.Question, body, options)
|
||||
if err != nil {
|
||||
// 归还幂等键,否则这次询问永远转不出来了
|
||||
if relayKey != "" {
|
||||
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to create permission mail")
|
||||
return
|
||||
}
|
||||
if relayKey != "" {
|
||||
_ = repo.BindRelayMail(r.Context(), agentName, relayKey, mailID)
|
||||
}
|
||||
if err := repo.CreatePermissionRequest(r.Context(), mailID, sessionID, agentName, req.Question, options, req.Context); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create permission request")
|
||||
return
|
||||
}
|
||||
|
||||
// 只推给该决策人
|
||||
sse.Default.SendToUser(decider, "new_mail", map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"from_name": agentName,
|
||||
"subject": "权限请求: " + req.Question,
|
||||
"mail_type": "permission_request",
|
||||
"role": "to",
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
"permission_mail_id": mailID.String(),
|
||||
"decider": decider,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/permission/decide —— 需登录;只有该权限请求的收件人或管理员可决策
|
||||
func DecidePermission(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req permissionDecideRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.MailID == "" || req.Decision == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing mail_id or decision")
|
||||
return
|
||||
}
|
||||
|
||||
mailID, err := uuid.Parse(req.MailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid mail_id UUID")
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := repo.GetPermissionByMailID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Permission request not found")
|
||||
return
|
||||
}
|
||||
if perm.Result != nil && *perm.Result != "" {
|
||||
Error(w, http.StatusConflict, "该请求已被处理")
|
||||
return
|
||||
}
|
||||
|
||||
// 鉴权:必须是这封权限邮件的收件人,或管理员
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin() && mail.ToName != user.Username {
|
||||
Error(w, http.StatusForbidden, "无权决策他人的权限请求")
|
||||
return
|
||||
}
|
||||
|
||||
// 决策选项必须在候选内
|
||||
if !contains(perm.Options, req.Decision) {
|
||||
Error(w, http.StatusBadRequest, "决策必须是候选项之一")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := repo.DecidePermission(r.Context(), mailID, req.Decision); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to decide permission")
|
||||
return
|
||||
}
|
||||
|
||||
decisionMailID, err := repo.CreateDecisionMail(
|
||||
r.Context(), perm.SessionID, mailID, user.Username, perm.AgentName, req.Decision, req.Note)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create decision mail")
|
||||
return
|
||||
}
|
||||
|
||||
// 通知发起 Agent 恢复执行
|
||||
// 带上上游 permission id:插件要拿它回复 opencode 的原生权限询问。
|
||||
// 两边 id 空间不同,光给 AgentMail 的 mail_id 插件对不上;
|
||||
// 而插件重启后内存映射会丢,所以这个映射由服务端持久化并在此回传。
|
||||
payload := map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"decision_mail_id": decisionMailID.String(),
|
||||
"decision": req.Decision,
|
||||
"note": req.Note,
|
||||
"decided_by": user.Username,
|
||||
}
|
||||
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
|
||||
payload["relay_key"] = key
|
||||
payload["relay_kind"] = kind
|
||||
}
|
||||
sse.Default.SendToAgent(perm.AgentName, "permission_decision", payload)
|
||||
// 只刷新决策人自己的界面
|
||||
sse.Default.SendToUser(user.Username, "session_update", map[string]interface{}{
|
||||
"session_id": perm.SessionID.String(),
|
||||
"status": "active",
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "decided",
|
||||
"decision_mail_id": decisionMailID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/permission/pending —— 需登录;普通用户只看发给自己的
|
||||
func ListPendingPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
forUser := user.Username
|
||||
if user.IsAdmin() && r.URL.Query().Get("all") == "true" {
|
||||
forUser = ""
|
||||
}
|
||||
|
||||
reqs, err := repo.ListPendingPermissionsFor(r.Context(), forUser)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list pending permissions")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"requests": emptySlice(reqs),
|
||||
})
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, s := range list {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
87
gateway/internal/handler/ratelimit.go
Normal file
87
gateway/internal/handler/ratelimit.go
Normal file
@ -0,0 +1,87 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 登录失败限速:同一用户名连续 N 次失败后锁定一段时间
|
||||
const (
|
||||
maxLoginFailures = 5
|
||||
lockoutDuration = 5 * time.Minute
|
||||
failureWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
type failureRecord struct {
|
||||
count int
|
||||
firstSeen time.Time
|
||||
lockedAt time.Time
|
||||
}
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
recs map[string]*failureRecord
|
||||
}
|
||||
|
||||
var limiter = &loginLimiter{recs: make(map[string]*failureRecord)}
|
||||
|
||||
// Locked 返回该用户名是否处于锁定期,以及剩余秒数
|
||||
func (l *loginLimiter) Locked(name string) (bool, int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
r, ok := l.recs[name]
|
||||
if !ok || r.lockedAt.IsZero() {
|
||||
return false, 0
|
||||
}
|
||||
elapsed := time.Since(r.lockedAt)
|
||||
if elapsed >= lockoutDuration {
|
||||
delete(l.recs, name)
|
||||
return false, 0
|
||||
}
|
||||
return true, int((lockoutDuration - elapsed).Seconds())
|
||||
}
|
||||
|
||||
// Fail 记录一次失败,达到阈值则锁定
|
||||
func (l *loginLimiter) Fail(name string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
r, ok := l.recs[name]
|
||||
if !ok || now.Sub(r.firstSeen) > failureWindow {
|
||||
l.recs[name] = &failureRecord{count: 1, firstSeen: now}
|
||||
return
|
||||
}
|
||||
r.count++
|
||||
if r.count >= maxLoginFailures {
|
||||
r.lockedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 登录成功后清除失败计数
|
||||
func (l *loginLimiter) Reset(name string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.recs, name)
|
||||
}
|
||||
|
||||
// 定期清理过期记录,避免 map 无限增长
|
||||
func init() {
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
limiter.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, r := range limiter.recs {
|
||||
stale := now.Sub(r.firstSeen) > failureWindow &&
|
||||
(r.lockedAt.IsZero() || now.Sub(r.lockedAt) > lockoutDuration)
|
||||
if stale {
|
||||
delete(limiter.recs, k)
|
||||
}
|
||||
}
|
||||
limiter.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
85
gateway/internal/handler/relay_test.go
Normal file
85
gateway/internal/handler/relay_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parseRelay 是免配额通道的入口校验。白名单 + 强制幂等键这两条必须守住:
|
||||
// 前者防止 relay 变成任意字符串的后门,后者是「同一条上游消息只转一次」的基础。
|
||||
func TestParseRelay(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind, key string
|
||||
wantKind string
|
||||
wantKey string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "都为空 = 普通自主发信,正常扣配额", kind: "", key: "", wantKind: "", wantKey: ""},
|
||||
{name: "总结转发", kind: "summary", key: "msg_1", wantKind: "summary", wantKey: "msg_1"},
|
||||
{name: "权限转发", kind: "permission", key: "per_1", wantKind: "permission", wantKey: "per_1"},
|
||||
{name: "两端空白被裁掉", kind: " summary ", key: " msg_2 ", wantKind: "summary", wantKey: "msg_2"},
|
||||
|
||||
// 白名单外的类型必须拒:否则 relay:"anything" 就绕过了配额
|
||||
{name: "未知类型", kind: "whatever", key: "k", wantErr: true},
|
||||
// 没有幂等键就无法阻止同一条上游消息反复转发
|
||||
{name: "缺幂等键", kind: "summary", key: "", wantErr: true},
|
||||
{name: "只给了键没给类型", kind: "", key: "k", wantErr: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
kind, key, err := parseRelay(c.kind, c.key)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("期望报错,实际通过:kind=%q key=%q", kind, key)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("意外报错: %v", err)
|
||||
}
|
||||
if kind != c.wantKind || key != c.wantKey {
|
||||
t.Fatalf("得到 (%q, %q),期望 (%q, %q)", kind, key, c.wantKind, c.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRelayRejectsOverlongKey(t *testing.T) {
|
||||
long := make([]byte, 161)
|
||||
for i := range long {
|
||||
long[i] = 'k'
|
||||
}
|
||||
if _, _, err := parseRelay("summary", string(long)); err == nil {
|
||||
t.Fatal("超长 relay_key 应被拒绝(列宽 160)")
|
||||
}
|
||||
}
|
||||
|
||||
// 免配额类型是白名单,不是黑名单。新增一种转发时必须同时更新这里,
|
||||
// 免得悄悄多出一条不受审视的免费通道。
|
||||
func TestRelayKindsIsExactlyTwo(t *testing.T) {
|
||||
want := map[string]bool{"permission": true, "summary": true}
|
||||
if len(relayKinds) != len(want) {
|
||||
t.Fatalf("免配额类型数量变了:%v。新增前请确认它确实是 harness 代劳而非模型自主发信", relayKinds)
|
||||
}
|
||||
for k := range want {
|
||||
if !relayKinds[k] {
|
||||
t.Fatalf("缺少免配额类型 %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 报错必须是 400 而不是 500:这些都是调用方参数问题
|
||||
func TestParseRelayErrorsAreBadRequest(t *testing.T) {
|
||||
for _, c := range [][2]string{{"whatever", "k"}, {"summary", ""}, {"", "k"}} {
|
||||
_, _, err := parseRelay(c[0], c[1])
|
||||
if err == nil {
|
||||
t.Fatalf("(%q,%q) 应报错", c[0], c[1])
|
||||
}
|
||||
var he httpError
|
||||
if !errors.As(err, &he) || he.status != 400 {
|
||||
t.Fatalf("(%q,%q) 的错误不是 400: %#v", c[0], c[1], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
141
gateway/internal/handler/rename_proposal.go
Normal file
141
gateway/internal/handler/rename_proposal.go
Normal file
@ -0,0 +1,141 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------- Agent 在正文里提议改会话别名 ----------
|
||||
//
|
||||
// 与「平台命名自动同步」(POST /sessions/:id/sync)互补:
|
||||
// 自动同步 = 平台起的名字,后台静默生效,不打扰人
|
||||
// 正文提议 = Agent 干完活后觉得该换个更贴切的名字,需要人点头
|
||||
//
|
||||
// 为什么走正文而不是让 Agent 直接调 PUT alias:
|
||||
// 别名是**人**的寻址入口。Agent 干到一半自己改掉,人上一秒记住的地址下一秒失效。
|
||||
// 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
//
|
||||
// 载体选 HTML 注释:
|
||||
// - react-markdown 默认不解析 raw HTML,注释在页面上不可见(实测渲染为转义文本节点,
|
||||
// 不是节点丢失 —— 所以必须从原始正文里剥掉,不能指望渲染器吞掉它)
|
||||
// - 纯文本邮件客户端里它是一行不碍事的注释,不像自造标记那样显眼
|
||||
// - 不与 Markdown 语法冲突,不会被格式化工具改写
|
||||
|
||||
// renameProposalRe 匹配 Agent 提议改名的标记。
|
||||
//
|
||||
// 形如:<!-- agentmail:rename-session alias="fix-login-leak" reason="定位到是登录态泄漏" -->
|
||||
// reason 可选。alias 用双引号包裹,因此别名本身不能含双引号 —— 但合法别名连
|
||||
// 空白和 . / @ 都不许有,双引号自然也在禁止之列,不构成限制。
|
||||
//
|
||||
// 用正则而不是完整 HTML 解析:这是一个格式固定的单行标记,正则足够且不引依赖。
|
||||
var renameProposalRe = regexp.MustCompile(
|
||||
`(?s)<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->`)
|
||||
|
||||
// RenameProposal 是从正文里解析出的一条改名提议。
|
||||
type RenameProposal struct {
|
||||
// Alias 已经过 normalizeAlias 规范化,可直接用于 PUT /sessions/:id/alias
|
||||
Alias string `json:"alias"`
|
||||
// Reason 是 Agent 给出的理由,可为空
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// extractRenameProposal 从正文里取出改名提议,并返回剥掉标记后的正文。
|
||||
//
|
||||
// 只认**最后一条**:Agent 在长回复里可能反复修正措辞,最后写下的才是它的结论。
|
||||
// 标记一律从正文里剥掉 —— 它是给系统看的元数据,不该出现在人读的正文里
|
||||
// (react-markdown 会把 HTML 注释转义成可见文本)。
|
||||
//
|
||||
// 非法别名(规范化后为空或不合法)视为无提议,但标记仍然剥掉:
|
||||
// 与其在正文里留一行乱码,不如当它没提。
|
||||
func extractRenameProposal(body string) (*RenameProposal, string) {
|
||||
matches := renameProposalRe.FindAllStringSubmatch(body, -1)
|
||||
cleaned := stripProposalMarkers(body)
|
||||
if len(matches) == 0 {
|
||||
return nil, cleaned
|
||||
}
|
||||
|
||||
last := matches[len(matches)-1]
|
||||
alias := normalizeAlias(strings.TrimSpace(last[1]))
|
||||
if alias == "" {
|
||||
return nil, cleaned
|
||||
}
|
||||
if err := validateSessionAlias(alias); err != nil {
|
||||
return nil, cleaned
|
||||
}
|
||||
reason := ""
|
||||
if len(last) > 2 {
|
||||
reason = strings.TrimSpace(last[2])
|
||||
}
|
||||
// 理由是展示给人看的一句话,过长会把提示条撑破
|
||||
const maxReason = 200
|
||||
if len(reason) > maxReason {
|
||||
reason = preview(reason, maxReason)
|
||||
}
|
||||
return &RenameProposal{Alias: alias, Reason: reason}, cleaned
|
||||
}
|
||||
|
||||
// stripProposalMarkers 移除全部提议标记,并把因此产生的多余空行压回一个。
|
||||
func stripProposalMarkers(body string) string {
|
||||
out := renameProposalRe.ReplaceAllString(body, "")
|
||||
// 标记独占一行时会留下连续空行,压成一个空行(Markdown 的段落分隔)
|
||||
for strings.Contains(out, "\n\n\n") {
|
||||
out = strings.ReplaceAll(out, "\n\n\n", "\n\n")
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// preview 按 UTF-8 边界截断。与 repo.preview 同逻辑,这里为避免 handler → repo
|
||||
// 的反向依赖而复制一份(两处都是 5 行,抽公共包不值当)。
|
||||
func preview(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && s[cut]&0xC0 == 0x80 {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
|
||||
// ---------- 插件代劳转发(免配额通道) ----------
|
||||
|
||||
// relayKinds 是允许免配额的转发类型。
|
||||
//
|
||||
// 白名单而不是任意字符串:免配额通道必须有明确边界,
|
||||
// 否则 `relay: "whatever"` 就成了绕过配额的后门。
|
||||
//
|
||||
// permission —— 平台原生的权限询问(opencode 的 permission.updated)。
|
||||
// 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答。
|
||||
// summary —— 本轮的最终总结(session.idle 时最后一条 assistant 消息)。
|
||||
// 模型已经把话说完了,插件只是搬运;对它收费会导致配额用尽时
|
||||
// Agent 连交代都做不了。
|
||||
var relayKinds = map[string]bool{
|
||||
"permission": true,
|
||||
"summary": true,
|
||||
}
|
||||
|
||||
// parseRelay 校验免配额转发参数,返回规范化后的 (kind, key)。
|
||||
// 两者都为空表示这是普通的自主发信,正常扣配额。
|
||||
func parseRelay(kind, key string) (string, string, error) {
|
||||
kind = strings.TrimSpace(kind)
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
if kind == "" {
|
||||
if key != "" {
|
||||
return "", "", errBadRequest("给了 relay_key 却没给 relay 类型")
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
if !relayKinds[kind] {
|
||||
return "", "", errBadRequest(`relay 只能是 "permission" 或 "summary"`)
|
||||
}
|
||||
// 幂等键是免配额通道的唯一约束基础,不能省:
|
||||
// 没有它就无法阻止同一条上游消息被反复转发。
|
||||
if key == "" {
|
||||
return "", "", errBadRequest("relay 转发必须带 relay_key(上游消息的稳定 id)")
|
||||
}
|
||||
if len(key) > 160 {
|
||||
return "", "", errBadRequest("relay_key 过长(上限 160 字节)")
|
||||
}
|
||||
return kind, key, nil
|
||||
}
|
||||
143
gateway/internal/handler/rename_proposal_test.go
Normal file
143
gateway/internal/handler/rename_proposal_test.go
Normal file
@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractRenameProposal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantAlias string
|
||||
wantReason string
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "无标记时原样返回",
|
||||
body: "普通正文。",
|
||||
wantAlias: "",
|
||||
wantBody: "普通正文。",
|
||||
},
|
||||
{
|
||||
name: "带理由",
|
||||
body: "已定位问题。\n\n<!-- agentmail:rename-session alias=\"fix-login-leak\" reason=\"是登录态泄漏\" -->",
|
||||
wantAlias: "fix-login-leak",
|
||||
wantReason: "是登录态泄漏",
|
||||
wantBody: "已定位问题。",
|
||||
},
|
||||
{
|
||||
name: "无理由",
|
||||
body: "<!-- agentmail:rename-session alias=\"cache-eval\" -->\n\n正文在后。",
|
||||
wantAlias: "cache-eval",
|
||||
wantBody: "正文在后。",
|
||||
},
|
||||
{
|
||||
// Agent 在长回复里反复修正措辞,最后写下的才是它的结论
|
||||
name: "多条只取最后一条",
|
||||
body: "<!-- agentmail:rename-session alias=\"first\" -->\n中间\n<!-- agentmail:rename-session alias=\"second\" -->",
|
||||
wantAlias: "second",
|
||||
wantBody: "中间",
|
||||
},
|
||||
{
|
||||
// 别名含 . / @ 会让三维地址切分歧义,normalizeAlias 改写为 -
|
||||
name: "非法字符被规范化",
|
||||
body: "<!-- agentmail:rename-session alias=\"fix login.leak/now\" -->",
|
||||
wantAlias: "fix-login-leak-now",
|
||||
wantBody: "",
|
||||
},
|
||||
{
|
||||
// "new" 是寻址保留字
|
||||
name: "保留字被改写",
|
||||
body: "<!-- agentmail:rename-session alias=\"new\" -->",
|
||||
wantAlias: "session-new",
|
||||
wantBody: "",
|
||||
},
|
||||
{
|
||||
// 规范化后为空 → 视为无提议,但标记仍要剥掉
|
||||
name: "空别名视为无提议且剥掉标记",
|
||||
body: "正文\n<!-- agentmail:rename-session alias=\"\" -->",
|
||||
wantAlias: "",
|
||||
wantBody: "正文",
|
||||
},
|
||||
{
|
||||
name: "多余空格容错",
|
||||
body: "<!-- agentmail:rename-session alias=\"ok-name\" -->",
|
||||
wantAlias: "ok-name",
|
||||
wantBody: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, body := extractRenameProposal(c.body)
|
||||
gotAlias := ""
|
||||
gotReason := ""
|
||||
if p != nil {
|
||||
gotAlias, gotReason = p.Alias, p.Reason
|
||||
}
|
||||
if gotAlias != c.wantAlias {
|
||||
t.Errorf("alias = %q,期望 %q", gotAlias, c.wantAlias)
|
||||
}
|
||||
if gotReason != c.wantReason {
|
||||
t.Errorf("reason = %q,期望 %q", gotReason, c.wantReason)
|
||||
}
|
||||
if body != c.wantBody {
|
||||
t.Errorf("剥标记后正文 = %q,期望 %q", body, c.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 标记必须从入库正文里彻底消失:react-markdown 不解析 raw HTML,
|
||||
// 留着会被转义成一行可见的乱码文本,而不是被渲染器吞掉。
|
||||
func TestProposalMarkerNeverSurvivesInBody(t *testing.T) {
|
||||
bodies := []string{
|
||||
"<!-- agentmail:rename-session alias=\"a\" -->",
|
||||
"前\n<!-- agentmail:rename-session alias=\"a\" reason=\"r\" -->\n后",
|
||||
"<!-- agentmail:rename-session alias=\"\" -->", // 无效提议也要剥
|
||||
}
|
||||
for _, b := range bodies {
|
||||
_, out := extractRenameProposal(b)
|
||||
if renameProposalRe.MatchString(out) {
|
||||
t.Errorf("正文里仍残留标记:%q", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提议出来的别名必须能直接通过 PUT alias 的校验,
|
||||
// 否则前端点「接受」时会拿到 400 —— 系统自己造出了自己拒绝的值。
|
||||
func TestProposedAliasPassesValidation(t *testing.T) {
|
||||
inputs := []string{
|
||||
"fix login.leak",
|
||||
"new",
|
||||
"a@b/c",
|
||||
" spaced name ",
|
||||
"正常中文别名",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
p, _ := extractRenameProposal("<!-- agentmail:rename-session alias=\"" + in + "\" -->")
|
||||
if p == nil {
|
||||
continue // 规范化后为空,已按无提议处理
|
||||
}
|
||||
if err := validateSessionAlias(p.Alias); err != nil {
|
||||
t.Errorf("提议 %q → %q 未通过 validateSessionAlias: %v", in, p.Alias, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonTruncatedOnUTF8Boundary(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "很长的理由"
|
||||
}
|
||||
p, _ := extractRenameProposal("<!-- agentmail:rename-session alias=\"x\" reason=\"" + long + "\" -->")
|
||||
if p == nil {
|
||||
t.Fatal("应当解析出提议")
|
||||
}
|
||||
if len(p.Reason) > 210 { // 200 + "..."
|
||||
t.Errorf("理由未截断:%d 字节", len(p.Reason))
|
||||
}
|
||||
for _, r := range p.Reason {
|
||||
if r == 0xFFFD {
|
||||
t.Fatal("截断产生了替换符,说明切在多字节字符中间")
|
||||
}
|
||||
}
|
||||
}
|
||||
340
gateway/internal/handler/sessions.go
Normal file
340
gateway/internal/handler/sessions.go
Normal file
@ -0,0 +1,340 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/agentmail/gateway/internal/sse"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- Session(均需登录,且做会话级鉴权) ----------
|
||||
|
||||
// requireSessionAccess 解析路径中的会话 ID 并校验当前用户有权访问
|
||||
func requireSessionAccess(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
sessionID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该会话")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return sessionID, true
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}
|
||||
func GetSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
session, err := repo.GetSessionByID(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
mails, err := repo.GetSessionMails(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to get session mails")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"session": session,
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/mails
|
||||
func GetSessionMails(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mails, err := repo.GetSessionMails(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to get mails")
|
||||
return
|
||||
}
|
||||
// 会话线程要展示附件,逐封填充
|
||||
for i := range mails {
|
||||
fillAttachments(r, &mails[i])
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": emptySlice(mails),
|
||||
})
|
||||
}
|
||||
|
||||
type updateAliasRequest struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/sessions/{id}/alias
|
||||
//
|
||||
// 会话别名负责三维寻址(name@path.<alias>),因此必须全局唯一,
|
||||
// 且不能叫 "new"(那是寻址保留字)。
|
||||
func UpdateSessionAlias(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateAliasRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
alias := strings.TrimSpace(req.Alias)
|
||||
if alias == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing alias")
|
||||
return
|
||||
}
|
||||
if err := validateSessionAlias(alias); err != nil {
|
||||
writeErr(w, err, "Invalid alias")
|
||||
return
|
||||
}
|
||||
|
||||
// 被其他会话占用时报 409,而不是默默造出两个同名可寻址会话
|
||||
if s, err := repo.FindSessionByAlias(r.Context(), alias); err == nil && s.ID != sessionID {
|
||||
Error(w, http.StatusConflict, "会话别名 \""+alias+"\" 已被其他会话占用")
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.UpdateSessionAlias(r.Context(), sessionID, alias); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update alias")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "updated",
|
||||
"alias": alias,
|
||||
})
|
||||
}
|
||||
|
||||
// syncSessionRequest 是 Agent 平台回传自己那侧的会话标识。
|
||||
//
|
||||
// 各 Agent 平台(opencode / Claude Code / DSH…)都会由模型为会话生成一个摘要标题,
|
||||
// 并配一个短 slug。不在本侧另造一套命名:平台那边叫什么,本侧就叫什么。
|
||||
type syncSessionRequest struct {
|
||||
// Alias 是平台侧的短标识(如 opencode 的 slug "jolly-cactus"),写入本侧 session_alias 供寻址。
|
||||
Alias string `json:"alias"`
|
||||
// Title 是平台侧模型生成的摘要标题(如「修复登录态丢失」),写入本侧 subject 供展示。
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// POST /api/v1/sessions/{id}/sync
|
||||
//
|
||||
// Agent 侧端点:把平台生成的会话标题与 slug 同步到本侧。
|
||||
// alias 撞名时自动追加 -2/-3 后缀(本侧别名负责寻址必须唯一,而平台 slug 不保证全局唯一),
|
||||
// 因此本接口不会因撞名失败,响应里回传最终落库的别名。
|
||||
func SyncSession(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
sessionID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Agent 只能同步自己参与过的会话
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权修改未参与的会话")
|
||||
return
|
||||
}
|
||||
|
||||
var req syncSessionRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]string{"status": "synced"}
|
||||
|
||||
if title := strings.TrimSpace(req.Title); title != "" {
|
||||
if err := repo.SyncSessionTitle(r.Context(), sessionID, title); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to sync title")
|
||||
return
|
||||
}
|
||||
resp["title"] = title
|
||||
}
|
||||
|
||||
if alias := strings.TrimSpace(req.Alias); alias != "" {
|
||||
// 平台 slug 可能带非法字符,落库前按本侧寻址规则规范化
|
||||
norm := normalizeAlias(alias)
|
||||
if norm == "" {
|
||||
Error(w, http.StatusBadRequest, "alias 规范化后为空,无法作为寻址别名")
|
||||
return
|
||||
}
|
||||
final, err := repo.SyncSessionAlias(r.Context(), sessionID, norm)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to sync alias")
|
||||
return
|
||||
}
|
||||
resp["alias"] = final
|
||||
}
|
||||
|
||||
// 让参与方前端立即看到新标题/别名
|
||||
sse.Default.Broadcast("session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"alias": resp["alias"],
|
||||
"title": resp["title"],
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/rename-proposal
|
||||
//
|
||||
// 返回该会话里最新一条尚未处理的改名提议(Agent 在正文里提的)。
|
||||
// 「尚未处理」= 既不是当前别名(已接受),也不在驳回记录里。
|
||||
// 无提议时返回 {"proposal": null},前端据此决定要不要显示提示条。
|
||||
func GetRenameProposal(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
alias, reason, err := repo.PendingRenameProposal(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load proposal")
|
||||
return
|
||||
}
|
||||
if alias == "" {
|
||||
JSON(w, http.StatusOK, map[string]interface{}{"proposal": nil})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"proposal": map[string]string{"alias": alias, "reason": reason},
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/sessions/{id}/rename-proposal/dismiss
|
||||
//
|
||||
// 用户驳回当前提议。记下被驳回的别名,好让提示条不再反复弹同一个建议 ——
|
||||
// 否则每次打开会话都要重新点一次「忽略」。
|
||||
//
|
||||
// 接受提议走已有的 PUT /sessions/{id}/alias,不另开端点:
|
||||
// 那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。
|
||||
func DismissRenameProposal(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
alias, _, err := repo.PendingRenameProposal(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load proposal")
|
||||
return
|
||||
}
|
||||
if alias == "" {
|
||||
// 已经没有待处理提议(可能是另一个标签页刚处理过),当作成功
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "no_pending"})
|
||||
return
|
||||
}
|
||||
if err := repo.DismissRenameProposal(r.Context(), sessionID, alias); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to dismiss proposal")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "dismissed",
|
||||
"dismissed": alias,
|
||||
})
|
||||
}
|
||||
|
||||
type sessionBudgetRequest struct {
|
||||
// MaxRounds 是本会话的往返预算上限(0 = 不限)。
|
||||
MaxRounds *int `json:"max_rounds"`
|
||||
// Reset 把已用次数归零(上限不变)。可与 MaxRounds 同时给:
|
||||
// 「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会让前端多一次往返。
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
// GET /api/v1/sessions/{id}/budget
|
||||
//
|
||||
// 本会话的往返预算。与 Agent 全局配额是两层,都要过:
|
||||
// 会话预算管「这件事值得多少个来回」,全局配额管「这个 Agent 总共能发多少」。
|
||||
func GetSessionBudgetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b, err := repo.GetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
// PUT /api/v1/sessions/{id}/budget
|
||||
//
|
||||
// 在对话页里随时调本任务的预算 —— 这是配额最该被编辑的地方:
|
||||
// 人看着往来内容才知道这件事还值不值得再来几个回合。
|
||||
func UpdateSessionBudget(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, ok := requireSessionAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req sessionBudgetRequest
|
||||
if err := Decode(r, &req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.MaxRounds == nil && !req.Reset {
|
||||
Error(w, http.StatusBadRequest, "需要给出 max_rounds 或 reset")
|
||||
return
|
||||
}
|
||||
|
||||
b, err := repo.GetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Session not found")
|
||||
return
|
||||
}
|
||||
if req.MaxRounds != nil {
|
||||
if *req.MaxRounds < 0 {
|
||||
Error(w, http.StatusBadRequest, "max_rounds 不能为负")
|
||||
return
|
||||
}
|
||||
// 允许调到低于已用次数:那表示「就到这里为止」,是人的合法意图。
|
||||
// 此时剩余为 0,Agent 下次发信即被拦。
|
||||
b, err = repo.SetSessionBudget(r.Context(), sessionID, *req.MaxRounds)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to set budget")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Reset {
|
||||
b, err = repo.ResetSessionBudget(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to reset budget")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 让会话的其他参与方(含 Agent 侧界面)立刻看到新预算
|
||||
sse.Default.Broadcast("session_update", map[string]interface{}{
|
||||
"session_id": sessionID.String(),
|
||||
"budget_max": b.Max,
|
||||
"budget_used": b.Used,
|
||||
"budget_remaining": b.Remaining,
|
||||
})
|
||||
JSON(w, http.StatusOK, b)
|
||||
}
|
||||
209
gateway/internal/handler/thread.go
Normal file
209
gateway/internal/handler/thread.go
Normal file
@ -0,0 +1,209 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 对话树(按方向分块加载) ----------
|
||||
|
||||
// 分页参数。上限存在的意义是防止 ?limit=100000 一次把整条线索拉走 ——
|
||||
// 那就等于绕过了分块加载。
|
||||
const (
|
||||
threadDefaultLimit = 40
|
||||
threadMaxLimit = 200
|
||||
)
|
||||
|
||||
// threadNode 是返回给前端的树节点。
|
||||
//
|
||||
// Detached 表示「这封的父邮件当前不在返回集里」,两种原因:
|
||||
// - 父邮件不可见(转发把线索引到别处,下游往来不回流给上游参与者)
|
||||
// - 父邮件还没加载(分块加载的边界,往上滑会补上)
|
||||
//
|
||||
// 前端据此画出断点,而不是因为找不到父节点就把它悄悄丢掉。
|
||||
// 两种原因用 ParentHidden 区分:不可见是永久的,未加载是暂时的。
|
||||
type threadNode struct {
|
||||
repo.TreeMail
|
||||
Detached bool `json:"detached,omitempty"`
|
||||
// ParentHidden 为真表示父邮件确实存在但无权查看(不是尚未加载)
|
||||
ParentHidden bool `json:"parent_hidden,omitempty"`
|
||||
}
|
||||
|
||||
// GET /api/v1/mail/{id}/thread
|
||||
//
|
||||
// 以给定邮件为锚点,按方向分块返回线索:
|
||||
//
|
||||
// ?dir=around(默认) 锚点 + 一批祖先 + 一批子孙,首屏用
|
||||
// ?dir=up&offset=N 继续往上取祖先(上滑加载)
|
||||
// ?dir=down&offset=N 继续往下取子孙
|
||||
//
|
||||
// offset 是**相对锚点**的偏移:up 方向按层数(已取到的祖先数),
|
||||
// down 方向按节点数(已取到的子孙数)。锚点本身只在 around/down&offset=0 时返回。
|
||||
//
|
||||
// 树可跨会话(转发是新线索但仍指向原件),因此**逐个会话鉴权**,
|
||||
// 只返回当前用户有权访问的节点。被过滤掉的计入 hidden。
|
||||
func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 先确认调用者确实看得到作为锚点的这封邮件,否则等于给了一个
|
||||
// 「随便报 mail_id 就能探测线索存在性」的接口
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
dir := r.URL.Query().Get("dir")
|
||||
if dir == "" {
|
||||
dir = "around"
|
||||
}
|
||||
if dir != "around" && dir != "up" && dir != "down" {
|
||||
Error(w, http.StatusBadRequest, "dir 只能是 around、up 或 down")
|
||||
return
|
||||
}
|
||||
limit := intQuery(r, "limit", threadDefaultLimit, 1, threadMaxLimit)
|
||||
offset := intQuery(r, "offset", 0, 0, 1<<20)
|
||||
|
||||
// 会话鉴权结果按会话缓存:一条线索里同一会话通常有多封,逐封查是浪费
|
||||
seen := map[uuid.UUID]bool{}
|
||||
canSee := func(sid uuid.UUID) bool {
|
||||
if v, ok := seen[sid]; ok {
|
||||
return v
|
||||
}
|
||||
v, err := repo.UserCanAccessSession(r.Context(), user, sid)
|
||||
if err != nil {
|
||||
v = false // 查不出来就当看不到:宁可少给,不可多给
|
||||
}
|
||||
seen[sid] = v
|
||||
return v
|
||||
}
|
||||
|
||||
var (
|
||||
raw []repo.TreeMail
|
||||
hasMoreUp bool
|
||||
hasMoreDn bool
|
||||
wantUp = dir == "around" || dir == "up"
|
||||
wantDown = dir == "around" || dir == "down"
|
||||
upOffset = offset
|
||||
downOffset = offset
|
||||
)
|
||||
|
||||
// around 时两个方向各取一半,避免首屏一次要求 2×limit。
|
||||
// 两边至少各给 1:否则 limit=1 时会算出 downLimit=0,连锚点自己都不返回。
|
||||
upLimit, downLimit := limit, limit
|
||||
if dir == "around" {
|
||||
upLimit = limit / 2
|
||||
if upLimit < 1 {
|
||||
upLimit = 1
|
||||
}
|
||||
downLimit = limit - upLimit
|
||||
if downLimit < 1 {
|
||||
downLimit = 1
|
||||
}
|
||||
upOffset, downOffset = 0, 0
|
||||
}
|
||||
|
||||
if wantUp {
|
||||
anc, more, err := repo.AncestorsRaw(r.Context(), mailID, upOffset, upLimit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load ancestors")
|
||||
return
|
||||
}
|
||||
raw = append(raw, anc...)
|
||||
hasMoreUp = more
|
||||
}
|
||||
if wantDown {
|
||||
// around 与 down&offset=0 会带上锚点自己(Depth 0);
|
||||
// up 方向单独请求时不带,前端已经有它了
|
||||
desc, more, err := repo.DescendantsRaw(r.Context(), mailID, downOffset, downLimit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load descendants")
|
||||
return
|
||||
}
|
||||
raw = append(raw, desc...)
|
||||
hasMoreDn = more
|
||||
}
|
||||
|
||||
// 可见性过滤。父节点是否在**本次返回集**里决定 detached;
|
||||
// 父存在却不在集里,再判断是"无权看"还是"没加载"。
|
||||
visible := map[uuid.UUID]bool{}
|
||||
for _, m := range raw {
|
||||
if canSee(m.SessionID) {
|
||||
visible[m.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
nodes := []threadNode{}
|
||||
for _, m := range raw {
|
||||
if !visible[m.ID] {
|
||||
continue
|
||||
}
|
||||
n := threadNode{TreeMail: m}
|
||||
if m.ParentMailID != nil && !visible[*m.ParentMailID] {
|
||||
n.Detached = true
|
||||
// 父邮件在本次结果里出现过但被过滤掉 = 确实无权查看;
|
||||
// 完全没出现过 = 只是还没加载到,往上滑会补上
|
||||
for _, other := range raw {
|
||||
if other.ID == *m.ParentMailID {
|
||||
n.ParentHidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"anchor_mail_id": mailID,
|
||||
"dir": dir,
|
||||
"nodes": nodes,
|
||||
"total": len(nodes),
|
||||
"hidden": len(raw) - len(nodes),
|
||||
// 下一页的 offset。前端把它原样回传即可,不必自己算已加载数量。
|
||||
"has_more_up": hasMoreUp,
|
||||
"has_more_down": hasMoreDn,
|
||||
"next_up": upOffset + upLimit,
|
||||
"next_down": downOffset + downLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// intQuery 读取整数 query 参数并夹到 [min, max]。
|
||||
// 非法值一律回落到默认值 —— 分页参数不该因为一个笔误就让整个请求失败。
|
||||
func intQuery(r *http.Request, key string, def, min, max int) int {
|
||||
s := r.URL.Query().Get(key)
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
26
gateway/internal/handler/thread_test.go
Normal file
26
gateway/internal/handler/thread_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntQueryClampsAndFallsBack(t *testing.T) {
|
||||
cases := []struct {
|
||||
q string
|
||||
want int
|
||||
}{
|
||||
{"", 40}, // 缺省
|
||||
{"limit=10", 10}, // 正常
|
||||
{"limit=0", 1}, // 低于下限 → 夹到下限
|
||||
{"limit=999", 200}, // 高于上限 → 夹到上限
|
||||
{"limit=abc", 40}, // 非法 → 回落默认值,而不是让整个请求 400
|
||||
{"limit=-5", 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest("GET", "/x?"+c.q, nil)
|
||||
if got := intQuery(r, "limit", 40, 1, 200); got != c.want {
|
||||
t.Fatalf("intQuery(%q) = %d,期望 %d", c.q, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
98
gateway/internal/middleware/auth.go
Normal file
98
gateway/internal/middleware/auth.go
Normal file
@ -0,0 +1,98 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const AgentNameKey contextKey = "agent_name"
|
||||
|
||||
// bearerToken 从 Authorization: Bearer <token> 取出令牌,缺失时返回空串。
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
const p = "Bearer "
|
||||
if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) {
|
||||
return strings.TrimSpace(h[len(p):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BearerToken 导出给 handler 层用(注册接口不过中间件,需要自己取密钥)。
|
||||
func BearerToken(r *http.Request) string { return bearerToken(r) }
|
||||
|
||||
// keyAuthError 把密钥校验错误翻译成对外文案。
|
||||
// 「不存在」与「已使用/已过期」区分开:前两者是拿错了密钥,后者是密钥生命周期到了,
|
||||
// 运维需要据此判断该重新签发还是该检查配置。
|
||||
func keyAuthError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, repo.ErrKeyUsed):
|
||||
return `{"error":"密钥已使用(一次性密钥只能用一次)"}`
|
||||
case errors.Is(err, repo.ErrKeyExpired):
|
||||
return `{"error":"密钥已过期"}`
|
||||
default:
|
||||
return `{"error":"密钥无效"}`
|
||||
}
|
||||
}
|
||||
|
||||
// AgentAuth 验证 Agent 身份,支持两种凭证:
|
||||
//
|
||||
// Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)
|
||||
// X-Agent-Name + X-Agent-Secret —— 旧的 name/secret 方式(兼容保留)
|
||||
//
|
||||
// 用户密钥(user_keys)不接受:两类密钥共享 token 命名空间但走各自的验证表,
|
||||
// 因此用用户密钥调 Agent 接口只会得到「密钥无效」。
|
||||
func AgentAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if token := bearerToken(r); token != "" {
|
||||
agentName, err := repo.VerifyAgentKey(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, keyAuthError(err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if agentName == "" {
|
||||
// 密钥有效但尚未绑定 Agent:注册接口会用请求里的 name 落定它,
|
||||
// 其余接口无法确定调用者身份,只能拒。
|
||||
http.Error(w, `{"error":"密钥尚未绑定 Agent,请先调用 /agent/register 完成注册"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
repo.HeartbeatAgent(r.Context(), agentName)
|
||||
ctx := context.WithValue(r.Context(), AgentNameKey, agentName)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
agentName := r.Header.Get("X-Agent-Name")
|
||||
agentSecret := r.Header.Get("X-Agent-Secret")
|
||||
if agentName == "" || agentSecret == "" {
|
||||
http.Error(w, `{"error":"Missing Authorization: Bearer <key> or X-Agent-Name/X-Agent-Secret header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := repo.VerifyAgent(r.Context(), agentName, agentSecret)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"Invalid credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
repo.HeartbeatAgent(r.Context(), agent.Name)
|
||||
ctx := context.WithValue(r.Context(), AgentNameKey, agent.Name)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgentName 从 context 中获取 agent_name
|
||||
func GetAgentName(r *http.Request) string {
|
||||
if v := r.Context().Value(AgentNameKey); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
161
gateway/internal/middleware/user.go
Normal file
161
gateway/internal/middleware/user.go
Normal file
@ -0,0 +1,161 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
const UserKey contextKey = "auth_user"
|
||||
|
||||
// SetSessionCookie 写入登录 Cookie
|
||||
func SetSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: config.C.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: config.C.SecureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: maxAge,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie 清除登录 Cookie
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
SetSessionCookie(w, "", -1)
|
||||
}
|
||||
|
||||
// SessionToken 从请求中取出登录令牌
|
||||
func SessionToken(r *http.Request) string {
|
||||
c, err := r.Cookie(config.C.CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// QueryToken 从 ?access_token= 取出令牌。
|
||||
//
|
||||
// 仅为浏览器 EventSource 存在:它不支持自定义请求头,因此订阅 SSE 时
|
||||
// 除了 Cookie 就只剩 query 一条路。代价是令牌会进访问日志,
|
||||
// 所以只在 SSE 端点启用,其余接口一律要求 Authorization 头。
|
||||
func QueryToken(r *http.Request) string {
|
||||
return strings.TrimSpace(r.URL.Query().Get("access_token"))
|
||||
}
|
||||
|
||||
// UserAuth 校验人类用户登录态,把 *models.User 注入 context。
|
||||
// 支持两种凭证:浏览器 Cookie,或 Authorization: Bearer <user_key_token>(第三方客户端)。
|
||||
func UserAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
// 只有 Cookie 路径才清 Cookie;密钥认证失败不应频带浏览器会话
|
||||
if bearerToken(r) == "" {
|
||||
ClearSessionCookie(w)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"not authenticated"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
// AdminOnly 叠在 UserAuth 之后,要求 role = admin
|
||||
func AdminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := GetUser(r)
|
||||
if u == nil || !u.IsAdmin() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":"admin only"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// UserAuthAllowQueryToken 与 UserAuth 相同,但额外接受 ?access_token=。
|
||||
//
|
||||
// 只给那些【由浏览器直接发起、无法设置请求头】的端点用(附件下载的 <a download>)。
|
||||
// URL 里的令牌会进访问日志与 Referer,所以不能全局开启。
|
||||
func UserAuthAllowQueryToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
if token := QueryToken(r); token != "" {
|
||||
if ku, kErr := repo.VerifyUserKey(r.Context(), token); kErr == nil {
|
||||
u, err = ku, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"not authenticated"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), UserKey, u)))
|
||||
})
|
||||
}
|
||||
|
||||
// GetUser 从 context 取登录用户;未登录返回 nil
|
||||
func GetUser(r *http.Request) *models.User {
|
||||
if v := r.Context().Value(UserKey); v != nil {
|
||||
if u, ok := v.(*models.User); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserName 便捷取登录用户名
|
||||
func GetUserName(r *http.Request) string {
|
||||
if u := GetUser(r); u != nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OptionalUser 解析登录态但不拦截(SSE 等需要区分匿名/登录的场景)
|
||||
func OptionalUser(r *http.Request) *models.User {
|
||||
u, err := resolve(r)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// OptionalUserWithQuery 在 resolve 的三种凭证之外额外接受 ?access_token=。
|
||||
// 仅 SSE 用:浏览器 EventSource 无法带自定义头。
|
||||
func OptionalUserWithQuery(r *http.Request) *models.User {
|
||||
if u := OptionalUser(r); u != nil {
|
||||
return u
|
||||
}
|
||||
if token := QueryToken(r); token != "" {
|
||||
if u, err := repo.VerifyUserKey(r.Context(), token); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve 解析调用者身份:Cookie 优先,其次 Bearer 用户密钥。
|
||||
//
|
||||
// 用户密钥只能走到这里(/me/* 与会话级接口),Agent 密钥只能走 AgentAuth,
|
||||
// 两者各自查自己的表,因此拿 Agent 密钥读人类邮箱会得到 not authenticated。
|
||||
func resolve(r *http.Request) (*models.User, error) {
|
||||
if token := SessionToken(r); token != "" {
|
||||
return repo.ResolveUserSession(r.Context(), token)
|
||||
}
|
||||
if token := bearerToken(r); token != "" {
|
||||
return repo.VerifyUserKey(r.Context(), token)
|
||||
}
|
||||
return nil, repo.ErrSessionInvalid
|
||||
}
|
||||
138
gateway/internal/models/address.go
Normal file
138
gateway/internal/models/address.go
Normal file
@ -0,0 +1,138 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address 是三维寻址 name@path.session 的解析结果
|
||||
type Address struct {
|
||||
Name string `json:"name"` // Agent 实例名(或人类用户名)
|
||||
Path string `json:"path"` // 工作区路径(可含 /,可为空)
|
||||
Session string `json:"session"` // 会话别名;"new" = 新建;"" = 默认会话
|
||||
Raw string `json:"raw"` // 原始字符串
|
||||
}
|
||||
|
||||
// SessionMode 是 session 位的三种语义
|
||||
type SessionMode int
|
||||
|
||||
const (
|
||||
// SessionDefault:session 位省略 → 投递到 name@path 的默认会话(不存在则建立)
|
||||
SessionDefault SessionMode = iota
|
||||
// SessionNew:session 位为 new → 强制新建一个会话
|
||||
SessionNew
|
||||
// SessionNamed:session 位为具体别名 → 必须已存在,否则无法送达
|
||||
SessionNamed
|
||||
)
|
||||
|
||||
// Mode 返回该地址 session 位的语义
|
||||
func (a Address) Mode() SessionMode {
|
||||
switch a.Session {
|
||||
case "":
|
||||
return SessionDefault
|
||||
case "new":
|
||||
return SessionNew
|
||||
default:
|
||||
return SessionNamed
|
||||
}
|
||||
}
|
||||
|
||||
// IsNewSession 表示该地址要求新建会话(仅 session == "new")。
|
||||
// 注意:session 位省略不等于 new,那是「默认会话」,见 Mode()。
|
||||
func (a Address) IsNewSession() bool {
|
||||
return a.Mode() == SessionNew
|
||||
}
|
||||
|
||||
// IsDefaultSession 表示该地址省略了 session 位,走默认会话
|
||||
func (a Address) IsDefaultSession() bool {
|
||||
return a.Mode() == SessionDefault
|
||||
}
|
||||
|
||||
func (a Address) String() string {
|
||||
return a.Raw
|
||||
}
|
||||
|
||||
// ParseAddress 解析 name@path.session 三维地址。
|
||||
//
|
||||
// 支持形态:
|
||||
//
|
||||
// deepseekharness@/program.updatefeature → name=deepseekharness path=/program session=updatefeature
|
||||
// pi@root.new → name=pi path=root session=new(新建)
|
||||
// builder@ModelRouter.fix-leak → name=builder path=ModelRouter session=fix-leak
|
||||
// human@.new → name=human path="" session=new
|
||||
// human → name=human path="" session=""(默认会话)
|
||||
//
|
||||
// 规则:
|
||||
// - 第一个 @ 之前是 name(必填)
|
||||
// - @ 之后按【最后一个 .】切成 path 与 session,因此 path 内可以包含 . 与 /
|
||||
// - 没有 . 时,整段视为 path,session 为空(默认会话)
|
||||
//
|
||||
// session 位三态语义见 Address.Mode():省略=默认会话,new=新建,其他=必须已存在。
|
||||
func ParseAddress(s string) (Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return Address{}, fmt.Errorf("empty address")
|
||||
}
|
||||
|
||||
// 只有形如 "@name@path.session" 时才剥掉前导 @;
|
||||
// "@ModelRouter.new" 缺少 name,应当报错而不是被当成名字。
|
||||
trimmed := raw
|
||||
if strings.HasPrefix(raw, "@") && strings.Contains(raw[1:], "@") {
|
||||
trimmed = raw[1:]
|
||||
}
|
||||
|
||||
at := strings.Index(trimmed, "@")
|
||||
if at < 0 {
|
||||
// 只有名字:human / builder
|
||||
name := strings.TrimSpace(trimmed)
|
||||
if name == "" {
|
||||
return Address{}, fmt.Errorf("missing agent name in %q", raw)
|
||||
}
|
||||
return Address{Name: name, Raw: raw}, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(trimmed[:at])
|
||||
if name == "" {
|
||||
return Address{}, fmt.Errorf("missing agent name in %q", raw)
|
||||
}
|
||||
|
||||
rest := trimmed[at+1:]
|
||||
|
||||
// 按最后一个 . 切 path / session;path 内允许 / 与 .
|
||||
var path, session string
|
||||
if dot := strings.LastIndex(rest, "."); dot >= 0 {
|
||||
path = rest[:dot]
|
||||
session = rest[dot+1:]
|
||||
} else {
|
||||
path = rest
|
||||
}
|
||||
|
||||
return Address{
|
||||
Name: name,
|
||||
Path: strings.TrimSpace(path),
|
||||
Session: strings.TrimSpace(session),
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC)
|
||||
func ParseAddressList(s string) ([]Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' '
|
||||
})
|
||||
|
||||
out := make([]Address, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
addr, err := ParseAddress(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, addr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
94
gateway/internal/models/address_test.go
Normal file
94
gateway/internal/models/address_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
path string
|
||||
session string
|
||||
mode SessionMode
|
||||
}{
|
||||
// 用户给出的两个例子
|
||||
{"deepseekharness@/program.upadtefeature", "deepseekharness", "/program", "upadtefeature", SessionNamed},
|
||||
{"pi@root.new", "pi", "root", "new", SessionNew},
|
||||
|
||||
// 常规形态
|
||||
{"builder@ModelRouter.fix-memory-leak", "builder", "ModelRouter", "fix-memory-leak", SessionNamed},
|
||||
{"@builder@ModelRouter.new", "builder", "ModelRouter", "new", SessionNew},
|
||||
{"human@.new", "human", "", "new", SessionNew},
|
||||
|
||||
// 省略 session 位 = 默认会话(不等于 new)
|
||||
{"human", "human", "", "", SessionDefault},
|
||||
{"ops@prod", "ops", "prod", "", SessionDefault},
|
||||
|
||||
// path 内含 . 与 /(按最后一个 . 切)
|
||||
{"agent@/home/a.b/c.deploy", "agent", "/home/a.b/c", "deploy", SessionNamed},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := ParseAddress(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAddress(%q) unexpected error: %v", c.in, err)
|
||||
}
|
||||
if got.Name != c.name || got.Path != c.path || got.Session != c.session {
|
||||
t.Errorf("ParseAddress(%q) = {name:%q path:%q session:%q}, want {name:%q path:%q session:%q}",
|
||||
c.in, got.Name, got.Path, got.Session, c.name, c.path, c.session)
|
||||
}
|
||||
if got.Mode() != c.mode {
|
||||
t.Errorf("ParseAddress(%q).Mode() = %v, want %v", c.in, got.Mode(), c.mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// session 位省略与 new 必须是两种不同语义:
|
||||
// 省略 → 默认会话;new → 强制新建;其他 → 必须已存在。
|
||||
func TestSessionModeSemantics(t *testing.T) {
|
||||
def, _ := ParseAddress("pi@root")
|
||||
if def.Mode() != SessionDefault || def.IsNewSession() || !def.IsDefaultSession() {
|
||||
t.Errorf("pi@root 应为默认会话,得到 mode=%v isNew=%v", def.Mode(), def.IsNewSession())
|
||||
}
|
||||
|
||||
new_, _ := ParseAddress("pi@root.new")
|
||||
if new_.Mode() != SessionNew || !new_.IsNewSession() || new_.IsDefaultSession() {
|
||||
t.Errorf("pi@root.new 应为新建,得到 mode=%v", new_.Mode())
|
||||
}
|
||||
|
||||
named, _ := ParseAddress("pi@root.fix-leak")
|
||||
if named.Mode() != SessionNamed || named.IsNewSession() || named.IsDefaultSession() {
|
||||
t.Errorf("pi@root.fix-leak 应为具名会话,得到 mode=%v", named.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddressErrors(t *testing.T) {
|
||||
for _, in := range []string{"", " ", "@", "@ModelRouter.new"} {
|
||||
if _, err := ParseAddress(in); err == nil {
|
||||
t.Errorf("ParseAddress(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddressList(t *testing.T) {
|
||||
list, err := ParseAddressList("pi@root.new, deepseekharness@/program.upadtefeature;ops@prod")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("got %d addresses, want 3", len(list))
|
||||
}
|
||||
if list[0].Name != "pi" || !list[0].IsNewSession() {
|
||||
t.Errorf("addr[0] = %+v", list[0])
|
||||
}
|
||||
if list[1].Path != "/program" || list[1].Session != "upadtefeature" {
|
||||
t.Errorf("addr[1] = %+v", list[1])
|
||||
}
|
||||
if list[2].Name != "ops" || list[2].Path != "prod" || list[2].Mode() != SessionDefault {
|
||||
t.Errorf("addr[2] = %+v", list[2])
|
||||
}
|
||||
|
||||
empty, err := ParseAddressList(" ")
|
||||
if err != nil || empty != nil {
|
||||
t.Errorf("empty list = %v, %v; want nil, nil", empty, err)
|
||||
}
|
||||
}
|
||||
242
gateway/internal/models/models.go
Normal file
242
gateway/internal/models/models.go
Normal file
@ -0,0 +1,242 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Agent 代表一个已注册的 Agent 实例
|
||||
type Agent struct {
|
||||
ID uuid.UUID `json:"agent_id"`
|
||||
Name string `json:"agent_name"`
|
||||
Secret string `json:"-"`
|
||||
HostURL string `json:"host_url"`
|
||||
Workspaces []Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Workspace 是 Agent 管理的项目工作区
|
||||
type Workspace struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Session 是有明确边界的任务会话
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"session_id"`
|
||||
Alias *string `json:"session_alias"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID *uuid.UUID `json:"owner_user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MailCount int `json:"mail_count,omitempty"`
|
||||
|
||||
// RenameDismissed 是用户驳回过的改名提议。
|
||||
// 记下来才能让提示条不再反复弹同一个建议。
|
||||
RenameDismissed string `json:"rename_dismissed,omitempty"`
|
||||
|
||||
// AliasSource 记录别名是谁定的:
|
||||
// platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
// manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
// 没有这个区分,平台下一次 session.updated 会把人刚定的名字冲掉。
|
||||
AliasSource string `json:"alias_source,omitempty"`
|
||||
|
||||
// MaxRounds/UsedRounds 是本任务的往返预算(0 = 本会话不限)。
|
||||
// 配额的语义是「这件事值得多少个来回」,那是任务的属性而非 Agent 的属性,
|
||||
// 所以在写信时给、在对话页里随时调。
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
}
|
||||
|
||||
// User 是人类用户(多用户账号体系)
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"` // admin / user
|
||||
Status string `json:"status"` // active / disabled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
|
||||
// 权限边界:空切片 = 不限
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// IsAdmin 判断是否管理员
|
||||
func (u User) IsAdmin() bool { return u.Role == "admin" }
|
||||
|
||||
// CanUseAgent 判断用户是否可向指定 Agent 发信
|
||||
// 空白名单(或为空) = 不限;管理员不受限;收件方是人类用户时不走此限制
|
||||
func (u User) CanUseAgent(agentName string) bool {
|
||||
if u.IsAdmin() || len(u.AllowedAgents) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range u.AllowedAgents {
|
||||
if a == agentName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanUsePath 判断用户是否可访问指定工作区。
|
||||
// 空白名单 = 不限;管理员不受限;空 path(人类地址)总是允许。
|
||||
// 匹配规则:完全相等,或白名单项作为目录前缀(/program 允许 /program/sub)。
|
||||
func (u User) CanUsePath(path string) bool {
|
||||
if u.IsAdmin() || len(u.AllowedPaths) == 0 || path == "" {
|
||||
return true
|
||||
}
|
||||
for _, p := range u.AllowedPaths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if path == p {
|
||||
return true
|
||||
}
|
||||
prefix := p
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Mail 是会话中的一封邮件
|
||||
type Mail struct {
|
||||
ID uuid.UUID `json:"mail_id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
ParentMailID *uuid.UUID `json:"parent_mail_id"`
|
||||
FromName string `json:"from_name"`
|
||||
FromWorkspace string `json:"from_workspace"`
|
||||
ToName string `json:"to_name"`
|
||||
ToWorkspace string `json:"to_workspace"`
|
||||
CCList []Address `json:"cc_list"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
MailType string `json:"mail_type"`
|
||||
PermOptions []string `json:"permission_options,omitempty"`
|
||||
PermResult string `json:"permission_result,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HopLimit int `json:"hop_limit"`
|
||||
|
||||
SessionAlias string `json:"session_alias,omitempty"`
|
||||
BodyPreview string `json:"body_preview,omitempty"`
|
||||
|
||||
// Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
|
||||
// RenameAlias / RenameReason 是 Agent 在本封正文里提议的新会话别名。
|
||||
// 存在邮件上而非会话上:邮件是不可篡改的历史记录,
|
||||
// 「谁在哪一封里提了什么」应当留痕。
|
||||
RenameAlias string `json:"rename_alias,omitempty"`
|
||||
RenameReason string `json:"rename_reason,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionRequest 是 Agent 向人类发起的权限请求
|
||||
type PermissionRequest struct {
|
||||
ID uuid.UUID `json:"request_id"`
|
||||
MailID uuid.UUID `json:"mail_id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
Context string `json:"context"`
|
||||
Result *string `json:"result"`
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SSE 事件类型
|
||||
const (
|
||||
EventNewMail = "new_mail"
|
||||
EventPermissionDecision = "permission_decision"
|
||||
EventSessionUpdate = "session_update"
|
||||
EventAgentOnline = "agent_online"
|
||||
)
|
||||
|
||||
// ---------- 密钥认证 ----------
|
||||
|
||||
// 密钥类型:签发时决定其生命周期
|
||||
const (
|
||||
// KeyPermanent 永不过期,可重复使用(正式部署的 Agent 用这个)
|
||||
KeyPermanent = "permanent"
|
||||
// KeyOneTime 首次验证后即失效(用于把 Agent 首次接入的窗口压到最小)
|
||||
KeyOneTime = "one_time"
|
||||
// KeyTimed 到 ExpiresAt 之后失效
|
||||
KeyTimed = "timed"
|
||||
)
|
||||
|
||||
// ValidKeyType 判断密钥类型是否受支持
|
||||
func ValidKeyType(t string) bool {
|
||||
return t == KeyPermanent || t == KeyOneTime || t == KeyTimed
|
||||
}
|
||||
|
||||
// AgentKey 是管理员签发的 Agent 接入密钥。
|
||||
// AgentName 为空表示「待绑定」——密钥有效但还没指定属于哪个 Agent,
|
||||
// 首次注册时由注册请求里的 name 落定。
|
||||
type AgentKey struct {
|
||||
ID uuid.UUID `json:"key_id"`
|
||||
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
|
||||
TokenHint string `json:"token_hint"` // 前 8 位 + 省略号,用于列表展示
|
||||
AgentName *string `json:"agent_name"`
|
||||
KeyType string `json:"key_type"`
|
||||
Label string `json:"label"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserKey 是用户自助签发的客户端连接密钥,只能用于 /me/* 人类邮箱接口。
|
||||
type UserKey struct {
|
||||
ID uuid.UUID `json:"key_id"`
|
||||
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
|
||||
TokenHint string `json:"token_hint"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TokenHint 返回密钥的展示形式:只露前 8 位。
|
||||
// 密钥全文仅在创建响应里出现一次,之后任何列表接口都只给 hint。
|
||||
func TokenHint(token string) string {
|
||||
if len(token) <= 8 {
|
||||
return token
|
||||
}
|
||||
return token[:8] + "…"
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
|
||||
// Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。
|
||||
//
|
||||
// MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作
|
||||
//(Agent 侧工具走 JSON,无法在发信请求里带 multipart),中间态必须允许存在。
|
||||
type Attachment struct {
|
||||
ID uuid.UUID `json:"attachment_id"`
|
||||
MailID *uuid.UUID `json:"mail_id"`
|
||||
Uploader string `json:"uploader"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
226
gateway/internal/repo/attachments.go
Normal file
226
gateway/internal/repo/attachments.go
Normal file
@ -0,0 +1,226 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 附件 ----------
|
||||
//
|
||||
// 元数据在库、内容在磁盘(internal/blob)。两者的一致性由调用顺序保证:
|
||||
// 先落盘再入库 —— 反过来会出现「库里有记录但文件不存在」的下载 500。
|
||||
// 落盘成功但入库失败时最多留下一个无引用的文件,由 GC 回收,不影响正确性。
|
||||
|
||||
var (
|
||||
// ErrAttachmentNotFound 附件不存在
|
||||
ErrAttachmentNotFound = errors.New("attachment not found")
|
||||
// ErrAttachmentNotOwned 附件不属于该上传者
|
||||
ErrAttachmentNotOwned = errors.New("attachment not owned by uploader")
|
||||
// ErrAttachmentAlreadyAttached 附件已挂到别的邮件上
|
||||
ErrAttachmentAlreadyAttached = errors.New("attachment already attached")
|
||||
)
|
||||
|
||||
const attachmentCols = `attachment_id, mail_id, uploader, filename, content_type, size_bytes, sha256, created_at`
|
||||
|
||||
func scanAttachment(sc interface{ Scan(...any) error }) (*models.Attachment, error) {
|
||||
var a models.Attachment
|
||||
if err := sc.Scan(&a.ID, &a.MailID, &a.Uploader, &a.Filename,
|
||||
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// CreateAttachment 登记一条待挂载的附件(mail_id 为空)。
|
||||
func CreateAttachment(ctx context.Context, uploader, filename, contentType string, size int64, sum string) (*models.Attachment, error) {
|
||||
a := &models.Attachment{
|
||||
Uploader: uploader,
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: size,
|
||||
SHA256: sum,
|
||||
}
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO attachments (uploader, filename, content_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING attachment_id, created_at
|
||||
`, uploader, filename, contentType, size, sum).Scan(&a.ID, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAttachment 读取一条附件元数据。
|
||||
func GetAttachment(ctx context.Context, id uuid.UUID) (*models.Attachment, error) {
|
||||
a, err := scanAttachment(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE attachment_id = $1`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentNotFound
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
// ListAttachmentsFor 列出某封邮件的附件。
|
||||
func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachment, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT `+attachmentCols+` FROM attachments WHERE mail_id = $1 ORDER BY created_at`, mailID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Attachment{}
|
||||
for rows.Next() {
|
||||
a, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AttachToMail 把一批待挂载附件绑到某封邮件上。
|
||||
//
|
||||
// 每条都要求:存在、属于该上传者、且尚未挂载。
|
||||
// 用 WHERE mail_id IS NULL AND uploader = ? 一条 UPDATE 完成判断与写入,
|
||||
// 避免「先查后改」在并发下把同一个附件挂到两封邮件上。
|
||||
func AttachToMail(ctx context.Context, mailID uuid.UUID, ids []uuid.UUID, uploader string) error {
|
||||
for _, id := range ids {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE attachments SET mail_id = $1
|
||||
WHERE attachment_id = $2 AND uploader = $3 AND mail_id IS NULL
|
||||
`, mailID, id, uploader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 没改到:查明原因,给调用方一个能照着修的错误
|
||||
a, gErr := GetAttachment(ctx, id)
|
||||
if gErr != nil {
|
||||
return gErr
|
||||
}
|
||||
if a.Uploader != uploader {
|
||||
return ErrAttachmentNotOwned
|
||||
}
|
||||
return ErrAttachmentAlreadyAttached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyAttachmentsTo 把源邮件的附件复制到目标邮件(转发时用)。
|
||||
//
|
||||
// 内容寻址下「复制」只是新增一条指向同一 sha256 的元数据,不拷磁盘文件。
|
||||
// uploader 记为转发人:附件随新邮件重新分发,其可见范围由新邮件的参与方决定,
|
||||
// 而不是沿用原上传者。返回复制的数量。
|
||||
func CopyAttachmentsTo(ctx context.Context, srcMailID, dstMailID uuid.UUID, forwarder string) (int, error) {
|
||||
src, err := ListAttachmentsFor(ctx, srcMailID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, a := range src {
|
||||
_, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, dstMailID, forwarder, a.Filename, a.ContentType, a.SizeBytes, a.SHA256)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(src), nil
|
||||
}
|
||||
|
||||
// DeleteAttachment 删除一条附件元数据,返回它的 sha256 以及该内容是否已无人引用。
|
||||
// 内容寻址下多条记录可能共享同一个文件,只有最后一条引用消失才能删磁盘文件。
|
||||
func DeleteAttachment(ctx context.Context, id uuid.UUID) (sum string, orphaned bool, err error) {
|
||||
a, err := GetAttachment(ctx, id)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if _, err = db.DB.ExecContext(ctx,
|
||||
`DELETE FROM attachments WHERE attachment_id = $1`, id); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var refs int
|
||||
if err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, a.SHA256).Scan(&refs); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return a.SHA256, refs == 0, nil
|
||||
}
|
||||
|
||||
// SweepOrphanAttachments 清理超过 age 仍未挂载到邮件的附件记录,
|
||||
// 返回可以从磁盘删除的 sha256 列表(已确认无任何记录引用)。
|
||||
//
|
||||
// 上传后没走完发信流程(用户取消、Agent 崩溃)会留下这类记录,
|
||||
// 不清理的话磁盘只会单调增长。
|
||||
func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, error) {
|
||||
cutoff := time.Now().Add(-age)
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT attachment_id, sha256 FROM attachments
|
||||
WHERE mail_id IS NULL AND created_at < $1`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type orphan struct {
|
||||
id uuid.UUID
|
||||
sum string
|
||||
}
|
||||
var found []orphan
|
||||
for rows.Next() {
|
||||
var o orphan
|
||||
if err := rows.Scan(&o.id, &o.sum); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
found = append(found, o)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var removable []string
|
||||
for _, o := range found {
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM attachments WHERE attachment_id = $1`, o.id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var refs int
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, o.sum).Scan(&refs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if refs == 0 {
|
||||
removable = append(removable, o.sum)
|
||||
}
|
||||
}
|
||||
return removable, nil
|
||||
}
|
||||
|
||||
// AttachmentAccessible 判断某人是否有权读取某附件:
|
||||
// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。
|
||||
func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) {
|
||||
if a.MailID == nil {
|
||||
return a.Uploader == name, nil
|
||||
}
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM mails m
|
||||
WHERE m.mail_id = $1
|
||||
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
|
||||
`, *a.MailID, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
165
gateway/internal/repo/budget_test.go
Normal file
165
gateway/internal/repo/budget_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// setupBudgetDB 复用 quota_test.go 的临时库,再建一个会话。
|
||||
// 用真实 SQLite 而非 mock:预算的正确性核心是「判断与自增在同一条 UPDATE 里」,
|
||||
// 那正是只有真实数据库才能验证的部分。
|
||||
func setupBudgetDB(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
setupTestDB(t)
|
||||
id, err := CreateSession(context.Background(), nil, "bot", "预算测试")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestSessionBudgetZeroMeansUnlimited(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 默认 0 = 不限:引入预算不该把已在进行的会话卡死
|
||||
b, err := GetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !b.Unlimited || b.Remaining != -1 {
|
||||
t.Fatalf("默认应为不限:%+v", b)
|
||||
}
|
||||
// 不限时反复占用都成功
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := ConsumeSessionBudget(ctx, id); err != nil {
|
||||
t.Fatalf("不限额下第 %d 次占用失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionBudgetExhausts(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := SetSessionBudget(ctx, id, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= 2; i++ {
|
||||
b, err := ConsumeSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次应成功: %v", i, err)
|
||||
}
|
||||
if b.Used != i {
|
||||
t.Fatalf("第 %d 次后 used = %d", i, b.Used)
|
||||
}
|
||||
}
|
||||
b, err := ConsumeSessionBudget(ctx, id)
|
||||
if !errors.Is(err, ErrSessionBudgetExhausted) {
|
||||
t.Fatalf("第 3 次应耗尽,得到 err=%v b=%+v", err, b)
|
||||
}
|
||||
if b.Remaining != 0 {
|
||||
t.Fatalf("耗尽后剩余应为 0:%+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 判断与自增必须在同一条 UPDATE 里,否则并发下会把预算刷穿
|
||||
func TestSessionBudgetConcurrentDoesNotOverdraw(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
const limit = 10
|
||||
if _, err := SetSessionBudget(ctx, id, limit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
ok := 0
|
||||
for i := 0; i < 40; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := ConsumeSessionBudget(ctx, id); err == nil {
|
||||
mu.Lock()
|
||||
ok++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if ok != limit {
|
||||
t.Fatalf("40 并发下成功 %d 次,期望恰好 %d 次(预算被刷穿或误拒)", ok, limit)
|
||||
}
|
||||
b, _ := GetSessionBudget(ctx, id)
|
||||
if b.Used != limit {
|
||||
t.Fatalf("used = %d,期望 %d", b.Used, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionBudgetResetAndLowerBelowUsed(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
SetSessionBudget(ctx, id, 5)
|
||||
for i := 0; i < 3; i++ {
|
||||
ConsumeSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// 调到低于已用次数 = 「就到这里为止」,是人的合法意图,不该报错
|
||||
b, err := SetSessionBudget(ctx, id, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("下调预算不该失败: %v", err)
|
||||
}
|
||||
if b.Remaining != 0 {
|
||||
t.Fatalf("已用 3 上限 1 时剩余应为 0:%+v", b)
|
||||
}
|
||||
if _, err := ConsumeSessionBudget(ctx, id); !errors.Is(err, ErrSessionBudgetExhausted) {
|
||||
t.Fatal("下调后应立即拦住")
|
||||
}
|
||||
|
||||
// 重置只清已用次数,不动上限
|
||||
b, err = ResetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Used != 0 || b.Max != 1 {
|
||||
t.Fatalf("重置后应为 0/1:%+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话预算先扣、全局配额后扣;全局拦下时必须把会话那次退回去,
|
||||
// 否则那格白掉了 —— 那次往返实际上没有发生
|
||||
func TestRefundSessionBudget(t *testing.T) {
|
||||
id := setupBudgetDB(t)
|
||||
ctx := context.Background()
|
||||
SetSessionBudget(ctx, id, 3)
|
||||
ConsumeSessionBudget(ctx, id)
|
||||
|
||||
RefundSessionBudget(ctx, id)
|
||||
b, _ := GetSessionBudget(ctx, id)
|
||||
if b.Used != 0 {
|
||||
t.Fatalf("退还后 used 应为 0:%+v", b)
|
||||
}
|
||||
|
||||
// 已经是 0 时再退不该变成负数
|
||||
RefundSessionBudget(ctx, id)
|
||||
b, _ = GetSessionBudget(ctx, id)
|
||||
if b.Used != 0 {
|
||||
t.Fatalf("重复退还把 used 变成了 %d", b.Used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionBudgetMissingSession(t *testing.T) {
|
||||
setupBudgetDB(t)
|
||||
if _, err := GetSessionBudget(context.Background(), uuid.New()); err == nil {
|
||||
t.Fatal("不存在的会话应报错")
|
||||
} else if errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatal("应包装成可读错误而不是裸 sql.ErrNoRows")
|
||||
}
|
||||
}
|
||||
341
gateway/internal/repo/keys.go
Normal file
341
gateway/internal/repo/keys.go
Normal file
@ -0,0 +1,341 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 密钥认证 ----------
|
||||
//
|
||||
// 两类密钥共享一个全局唯一的 token 命名空间:验证时先查 agent_keys 再查 user_keys。
|
||||
// 这样一个 token 永远只有一种身份,不会出现「同一串字符既能注册 Agent 又能读人类邮箱」。
|
||||
|
||||
var (
|
||||
// ErrKeyNotFound 密钥不存在
|
||||
ErrKeyNotFound = errors.New("key not found")
|
||||
// ErrKeyUsed 一次性密钥已被使用
|
||||
ErrKeyUsed = errors.New("key already used")
|
||||
// ErrKeyExpired 定时密钥已过期
|
||||
ErrKeyExpired = errors.New("key expired")
|
||||
// ErrKeyTypeInvalid 密钥类型不受支持
|
||||
ErrKeyTypeInvalid = errors.New("invalid key type")
|
||||
// ErrKeyNeedsExpiry timed 密钥缺少有效的 expires_hours
|
||||
ErrKeyNeedsExpiry = errors.New("timed key requires positive expires_hours")
|
||||
// ErrKeyTooShort 登记的客户端密钥长度不足
|
||||
ErrKeyTooShort = errors.New("key token too short")
|
||||
)
|
||||
|
||||
// expiryFor 依据密钥类型算出过期时间。
|
||||
// 只有 timed 需要 expires_at;permanent 与 one_time 都是 NULL,
|
||||
// 各自的失效条件由 key_type 本身表达,不混用 expires_at。
|
||||
func expiryFor(keyType string, hours int) (*time.Time, error) {
|
||||
if !models.ValidKeyType(keyType) {
|
||||
return nil, ErrKeyTypeInvalid
|
||||
}
|
||||
if keyType != models.KeyTimed {
|
||||
return nil, nil
|
||||
}
|
||||
if hours <= 0 {
|
||||
return nil, ErrKeyNeedsExpiry
|
||||
}
|
||||
t := time.Now().Add(time.Duration(hours) * time.Hour)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// checkKeyUsable 判断一条密钥记录当前是否可用。
|
||||
func checkKeyUsable(keyType string, expiresAt, usedAt *time.Time) error {
|
||||
switch keyType {
|
||||
case models.KeyOneTime:
|
||||
if usedAt != nil {
|
||||
return ErrKeyUsed
|
||||
}
|
||||
case models.KeyTimed:
|
||||
if expiresAt == nil || time.Now().After(*expiresAt) {
|
||||
return ErrKeyExpired
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Agent 密钥(管理员签发) ----------
|
||||
|
||||
// ErrKeyTokenTaken 登记的密钥已被占用
|
||||
var ErrKeyTokenTaken = errors.New("key token already registered")
|
||||
|
||||
// CreateAgentKey 签发一条 Agent 接入密钥。agentName 为空表示待绑定。
|
||||
//
|
||||
// presetToken 非空时登记客户端已在本地生成的密钥(插件首装场景),
|
||||
// 这样密钥全文只从客户端往服务器走一次,不需要反方向传递;留空则由服务器生成。
|
||||
func CreateAgentKey(ctx context.Context, agentName, keyType, label string, expiresHours int, createdBy uuid.UUID, presetToken string) (*models.AgentKey, error) {
|
||||
expires, err := expiryFor(keyType, expiresHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := presetToken
|
||||
if token == "" {
|
||||
if token, err = newToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if len(token) < 32 {
|
||||
// 太短的客户端密钥不接受,否则等于把弱口令当凭证
|
||||
return nil, ErrKeyTooShort
|
||||
}
|
||||
|
||||
var namePtr *string
|
||||
if agentName != "" {
|
||||
namePtr = &agentName
|
||||
}
|
||||
|
||||
k := &models.AgentKey{
|
||||
Token: token,
|
||||
TokenHint: models.TokenHint(token),
|
||||
AgentName: namePtr,
|
||||
KeyType: keyType,
|
||||
Label: label,
|
||||
ExpiresAt: expires,
|
||||
CreatedBy: &createdBy,
|
||||
}
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO agent_keys (key_token, agent_name, key_type, label, expires_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING key_id, created_at
|
||||
`, token, namePtr, keyType, label, expires, createdBy).Scan(&k.ID, &k.CreatedAt)
|
||||
if db.IsUniqueViolation(err) {
|
||||
return nil, ErrKeyTokenTaken
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ListAgentKeys 列出 Agent 密钥;agentName 非空时按 Agent 过滤。
|
||||
// 返回值不含 token 全文,只有 hint。
|
||||
func ListAgentKeys(ctx context.Context, agentName string) ([]models.AgentKey, error) {
|
||||
q := `SELECT key_id, key_token, agent_name, key_type, label, expires_at, used_at, created_by, created_at
|
||||
FROM agent_keys`
|
||||
args := []any{}
|
||||
if agentName != "" {
|
||||
q += ` WHERE agent_name = $1`
|
||||
args = append(args, agentName)
|
||||
}
|
||||
q += ` ORDER BY created_at DESC`
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.AgentKey{}
|
||||
for rows.Next() {
|
||||
var k models.AgentKey
|
||||
var token string
|
||||
if err := rows.Scan(&k.ID, &token, &k.AgentName, &k.KeyType, &k.Label,
|
||||
&k.ExpiresAt, &k.UsedAt, &k.CreatedBy, &k.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.TokenHint = models.TokenHint(token) // 不回传全文
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteAgentKey 吊销一条 Agent 密钥。
|
||||
func DeleteAgentKey(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx, `DELETE FROM agent_keys WHERE key_id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindAgentKey 把一条密钥绑定到指定 Agent 名。
|
||||
func BindAgentKey(ctx context.Context, id uuid.UUID, agentName string) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET agent_name = $2 WHERE key_id = $1`, id, agentName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyAgentKey 校验 Agent 密钥并返回它绑定的 Agent 名(未绑定时返回空串)。
|
||||
//
|
||||
// 一次性密钥在校验通过时立刻写 used_at —— 用 WHERE used_at IS NULL 保证并发下
|
||||
// 只有一个请求能把它标记掉,避免两个 Agent 拿同一把一次性密钥同时注册成功。
|
||||
func VerifyAgentKey(ctx context.Context, token string) (string, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
agentName *string
|
||||
keyType string
|
||||
expiresAt *time.Time
|
||||
usedAt *time.Time
|
||||
)
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT key_id, agent_name, key_type, expires_at, used_at
|
||||
FROM agent_keys WHERE key_token = $1
|
||||
`, token).Scan(&id, &agentName, &keyType, &expiresAt, &usedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if keyType == models.KeyOneTime {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return "", ErrKeyUsed // 并发下被别人抢先用掉了
|
||||
}
|
||||
}
|
||||
|
||||
if agentName == nil {
|
||||
return "", nil
|
||||
}
|
||||
return *agentName, nil
|
||||
}
|
||||
|
||||
// ClaimAgentKey 在待绑定密钥首次注册时把它落定到该 Agent 名。
|
||||
// 已绑定的密钥不受影响(WHERE agent_name IS NULL),因此不能借一把已绑定的密钥改注册别的 Agent。
|
||||
func ClaimAgentKey(ctx context.Context, token, agentName string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agent_keys SET agent_name = $2 WHERE key_token = $1 AND agent_name IS NULL`,
|
||||
token, agentName)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 用户密钥(用户自助签发) ----------
|
||||
|
||||
// CreateUserKey 为用户签发一条客户端连接密钥。
|
||||
func CreateUserKey(ctx context.Context, userID uuid.UUID, label, keyType string, expiresHours int) (*models.UserKey, error) {
|
||||
expires, err := expiryFor(keyType, expiresHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k := &models.UserKey{
|
||||
Token: token,
|
||||
TokenHint: models.TokenHint(token),
|
||||
UserID: userID,
|
||||
Label: label,
|
||||
KeyType: keyType,
|
||||
ExpiresAt: expires,
|
||||
}
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO user_keys (key_token, user_id, label, key_type, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING key_id, created_at
|
||||
`, token, userID, label, keyType, expires).Scan(&k.ID, &k.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ListUserKeys 列出某用户的连接密钥(不含 token 全文)。
|
||||
func ListUserKeys(ctx context.Context, userID uuid.UUID) ([]models.UserKey, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT key_id, key_token, user_id, label, key_type, expires_at, used_at, created_at
|
||||
FROM user_keys WHERE user_id = $1 ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.UserKey{}
|
||||
for rows.Next() {
|
||||
var k models.UserKey
|
||||
var token string
|
||||
if err := rows.Scan(&k.ID, &token, &k.UserID, &k.Label, &k.KeyType,
|
||||
&k.ExpiresAt, &k.UsedAt, &k.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.TokenHint = models.TokenHint(token)
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUserKey 删除自己的一条密钥。带 user_id 条件,避免删掉别人的。
|
||||
func DeleteUserKey(ctx context.Context, userID, keyID uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM user_keys WHERE key_id = $1 AND user_id = $2`, keyID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyUserKey 校验用户密钥并返回对应用户。
|
||||
// 用户必须仍处于 active 状态——禁用账号后其密钥应当立即失效。
|
||||
func VerifyUserKey(ctx context.Context, token string) (*models.User, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
userID uuid.UUID
|
||||
keyType string
|
||||
expiresAt *time.Time
|
||||
usedAt *time.Time
|
||||
)
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT key_id, user_id, key_type, expires_at, used_at
|
||||
FROM user_keys WHERE key_token = $1
|
||||
`, token).Scan(&id, &userID, &keyType, &expiresAt, &usedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkKeyUsable(keyType, expiresAt, usedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if keyType == models.KeyOneTime {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE user_keys SET used_at = NOW() WHERE key_id = $1 AND used_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return nil, ErrKeyUsed
|
||||
}
|
||||
}
|
||||
|
||||
u, err := GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrKeyNotFound // 账号已禁用,密钥一并失效
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
282
gateway/internal/repo/quota.go
Normal file
282
gateway/internal/repo/quota.go
Normal file
@ -0,0 +1,282 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 配额 ----------
|
||||
//
|
||||
// 配额限制的是 Agent「主动发信」的次数,不限制收信:
|
||||
// 收信是被动的,卡住收信只会让邮件凭空消失;卡住发信才能真正阻止 Agent 无限自我循环。
|
||||
//
|
||||
// agents.max_rounds = 0 表示不限额。used_rounds 单调递增,由管理员显式重置。
|
||||
|
||||
// ErrQuotaExhausted 表示 Agent 的发信配额已用尽。
|
||||
var ErrQuotaExhausted = errors.New("quota exhausted")
|
||||
|
||||
// Quota 是一个 Agent 的配额快照。
|
||||
type Quota struct {
|
||||
AgentName string `json:"agent_name"`
|
||||
Max int `json:"max_rounds"` // 0 = 不限
|
||||
Used int `json:"used_rounds"`
|
||||
Remaining int `json:"remaining"` // 不限时为 -1
|
||||
Unlimited bool `json:"unlimited"`
|
||||
}
|
||||
|
||||
func makeQuota(name string, max, used int) Quota {
|
||||
q := Quota{AgentName: name, Max: max, Used: used, Unlimited: max <= 0}
|
||||
if q.Unlimited {
|
||||
q.Remaining = -1
|
||||
return q
|
||||
}
|
||||
if r := max - used; r > 0 {
|
||||
q.Remaining = r
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// GetQuota 读取某 Agent 的配额状态。
|
||||
func GetQuota(ctx context.Context, agentName string) (Quota, error) {
|
||||
var max, used int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT max_rounds, used_rounds FROM agents WHERE agent_name = $1`, agentName).Scan(&max, &used)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
return makeQuota(agentName, max, used), nil
|
||||
}
|
||||
|
||||
// ConsumeQuota 原子地占用一次发信配额,返回占用后的快照。
|
||||
//
|
||||
// 判断与自增必须在同一条 UPDATE 里完成(WHERE used_rounds < max_rounds),
|
||||
// 否则并发发信会双双通过检查再各自 +1,把配额刷穿。
|
||||
// 配额耗尽时返回 ErrQuotaExhausted,同时给出快照供调用方生成提示。
|
||||
func ConsumeQuota(ctx context.Context, agentName string) (Quota, error) {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE agents SET used_rounds = used_rounds + 1
|
||||
WHERE agent_name = $1
|
||||
AND (max_rounds <= 0 OR used_rounds < max_rounds)
|
||||
`, agentName)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
n, _ := tag.RowsAffected()
|
||||
if n == 0 {
|
||||
// 要么 Agent 不存在,要么配额用尽——用快照区分
|
||||
q, qErr := GetQuota(ctx, agentName)
|
||||
if qErr != nil {
|
||||
return Quota{}, qErr
|
||||
}
|
||||
return q, ErrQuotaExhausted
|
||||
}
|
||||
return GetQuota(ctx, agentName)
|
||||
}
|
||||
|
||||
// SetQuota 设置某 Agent 的配额上限(0 = 不限)。
|
||||
func SetQuota(ctx context.Context, agentName string, max int) (Quota, error) {
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET max_rounds = $2 WHERE agent_name = $1`, agentName, max)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
return GetQuota(ctx, agentName)
|
||||
}
|
||||
|
||||
// ResetQuota 把已用次数归零(配额上限不变)。
|
||||
func ResetQuota(ctx context.Context, agentName string) (Quota, error) {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET used_rounds = 0 WHERE agent_name = $1`, agentName)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
return GetQuota(ctx, agentName)
|
||||
}
|
||||
|
||||
// ListQuotas 列出所有 Agent 的配额(管理员视图)。
|
||||
func ListQuotas(ctx context.Context) ([]Quota, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT agent_name, max_rounds, used_rounds FROM agents ORDER BY agent_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Quota{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var max, used int
|
||||
if err := rows.Scan(&name, &max, &used); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, makeQuota(name, max, used))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 转发 ----------
|
||||
|
||||
// ForwardSource 是被转发邮件的必要信息。
|
||||
type ForwardSource struct {
|
||||
Mail *models.Mail
|
||||
Session uuid.UUID
|
||||
}
|
||||
|
||||
// LoadForwardSource 读取待转发的邮件,并校验转发者确实参与过该邮件
|
||||
//(收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。
|
||||
func LoadForwardSource(ctx context.Context, mailID uuid.UUID, actor string) (*models.Mail, error) {
|
||||
m, err := GetMailByID(ctx, mailID)
|
||||
if err != nil {
|
||||
return nil, ErrMailNotFound
|
||||
}
|
||||
|
||||
if m.FromName == actor || m.ToName == actor {
|
||||
return m, nil
|
||||
}
|
||||
for _, cc := range m.CCList {
|
||||
if cc.Name == actor {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrForwardNotAllowed
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrMailNotFound 待转发的邮件不存在
|
||||
ErrMailNotFound = errors.New("mail not found")
|
||||
// ErrForwardNotAllowed 转发者未参与该邮件
|
||||
ErrForwardNotAllowed = errors.New("not a participant of that mail")
|
||||
)
|
||||
|
||||
// ---------- 会话级往返预算 ----------
|
||||
//
|
||||
// 配额的真实语义是「这件事值得多少个来回」——那是**任务**的属性,不是 Agent 的属性。
|
||||
// 只有 agents.max_rounds 一个全局计数器时有两个问题:
|
||||
// 1. 两个并行任务互相抢额度:给紧急任务留的份被另一条线索吃掉
|
||||
// 2. used_rounds 单调递增,跑满就得管理员手工重置才能再干活
|
||||
// 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
//
|
||||
// **两层都要过**:会话预算 + Agent 全局配额。少了后者,Agent 自己 `.new` 开一串会话
|
||||
// 每条都是全新预算,全局上限形同虚设;少了前者,就回到抢额度的老问题。
|
||||
|
||||
// SessionBudget 是一个会话的往返预算快照。
|
||||
type SessionBudget struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
Max int `json:"max_rounds"` // 0 = 本会话不限
|
||||
Used int `json:"used_rounds"`
|
||||
Remaining int `json:"remaining"` // 不限时为 -1
|
||||
Unlimited bool `json:"unlimited"`
|
||||
}
|
||||
|
||||
func makeSessionBudget(id uuid.UUID, max, used int) SessionBudget {
|
||||
b := SessionBudget{SessionID: id, Max: max, Used: used, Unlimited: max <= 0}
|
||||
if b.Unlimited {
|
||||
b.Remaining = -1
|
||||
return b
|
||||
}
|
||||
if r := max - used; r > 0 {
|
||||
b.Remaining = r
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ErrSessionBudgetExhausted 表示该会话的往返预算已用尽。
|
||||
var ErrSessionBudgetExhausted = errors.New("session budget exhausted")
|
||||
|
||||
// GetSessionBudget 读取会话预算。
|
||||
func GetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
var max, used int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(max_rounds,0), COALESCE(used_rounds,0) FROM sessions WHERE session_id = $1`,
|
||||
id).Scan(&max, &used)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
return makeSessionBudget(id, max, used), nil
|
||||
}
|
||||
|
||||
// ConsumeSessionBudget 原子地占用会话的一次往返。
|
||||
//
|
||||
// 与 ConsumeQuota 同理:判断与自增必须在同一条 UPDATE 里(WHERE used_rounds < max_rounds),
|
||||
// 否则并发发信会双双通过检查再各自 +1,把预算刷穿。
|
||||
func ConsumeSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) + 1
|
||||
WHERE session_id = $1
|
||||
AND (COALESCE(max_rounds,0) <= 0 OR COALESCE(used_rounds,0) < max_rounds)
|
||||
`, id)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
b, bErr := GetSessionBudget(ctx, id)
|
||||
if bErr != nil {
|
||||
return SessionBudget{}, bErr
|
||||
}
|
||||
return b, ErrSessionBudgetExhausted
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// SetSessionBudget 设置会话预算上限(0 = 不限)。
|
||||
//
|
||||
// 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图,
|
||||
// 不该因为算不出正的剩余量就拒绝。此时 Remaining 为 0,下次发信即被拦。
|
||||
func SetSessionBudget(ctx context.Context, id uuid.UUID, max int) (SessionBudget, error) {
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET max_rounds = $2, updated_at = NOW() WHERE session_id = $1`, id, max)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// ResetSessionBudget 把该会话的已用次数归零(上限不变)。
|
||||
func ResetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET used_rounds = 0, updated_at = NOW() WHERE session_id = $1`, id)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// RefundSessionBudget 退还一次往返。
|
||||
//
|
||||
// 会话预算先扣、Agent 全局配额后扣,全局那层拦下时必须把会话这次还回去,
|
||||
// 否则会话预算白掉一格 —— 那次往返实际上没有发生。
|
||||
func RefundSessionBudget(ctx context.Context, id uuid.UUID) {
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) - 1
|
||||
WHERE session_id = $1 AND COALESCE(used_rounds,0) > 0`, id)
|
||||
}
|
||||
173
gateway/internal/repo/quota_test.go
Normal file
173
gateway/internal/repo/quota_test.go
Normal file
@ -0,0 +1,173 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// setupTestDB 起一个临时 SQLite 库并建表,供配额测试使用。
|
||||
// 直接用真实的 SQLite 而非 mock:配额的正确性核心在于「判断与自增在同一条 UPDATE 里」,
|
||||
// 这正是只有真实数据库才能验证的部分。
|
||||
func setupTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func seedAgent(t *testing.T, name string, max int) {
|
||||
t.Helper()
|
||||
_, err := db.DB.ExecContext(context.Background(),
|
||||
`INSERT INTO agents (agent_name, secret, platform, max_rounds) VALUES ($1, 'x', 'test', $2)`,
|
||||
name, max)
|
||||
if err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeQuotaCountsDown(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "bot", 3)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
q, err := ConsumeQuota(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次占用失败: %v", i, err)
|
||||
}
|
||||
if q.Used != i || q.Remaining != 3-i {
|
||||
t.Errorf("第 %d 次: used=%d remaining=%d, want used=%d remaining=%d",
|
||||
i, q.Used, q.Remaining, i, 3-i)
|
||||
}
|
||||
}
|
||||
|
||||
q, err := ConsumeQuota(ctx, "bot")
|
||||
if !errors.Is(err, ErrQuotaExhausted) {
|
||||
t.Fatalf("第 4 次应耗尽,得到 err=%v", err)
|
||||
}
|
||||
// 耗尽时仍要给出快照,调用方才能在错误文案里写清 used/max
|
||||
if q.Used != 3 || q.Max != 3 {
|
||||
t.Errorf("耗尽时快照不对: %+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeQuotaUnlimited(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "free", 0) // 0 = 不限
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
q, err := ConsumeQuota(ctx, "free")
|
||||
if err != nil {
|
||||
t.Fatalf("不限额时不应失败: %v", err)
|
||||
}
|
||||
if !q.Unlimited || q.Remaining != -1 {
|
||||
t.Errorf("不限额快照不对: %+v", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配额的核心保证:并发发信不能把额度刷穿。
|
||||
// 判断与自增若分成两步(先 SELECT 再 UPDATE),并发下两个请求会双双通过检查。
|
||||
func TestConsumeQuotaConcurrentDoesNotOverdraw(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const limit = 10
|
||||
seedAgent(t, "racer", limit)
|
||||
ctx := context.Background()
|
||||
|
||||
const attempts = 40
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
granted int
|
||||
)
|
||||
for i := 0; i < attempts; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := ConsumeQuota(ctx, "racer"); err == nil {
|
||||
mu.Lock()
|
||||
granted++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if granted != limit {
|
||||
t.Errorf("并发 %d 次请求在上限 %d 下放行了 %d 次", attempts, limit, granted)
|
||||
}
|
||||
|
||||
q, err := GetQuota(ctx, "racer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Used != limit {
|
||||
t.Errorf("used_rounds = %d,应恰好等于上限 %d(未刷穿也未少记)", q.Used, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndResetQuota(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedAgent(t, "bot", 2)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := ConsumeQuota(ctx, "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q, err := SetQuota(ctx, "bot", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 调上限不应清掉已用次数
|
||||
if q.Max != 5 || q.Used != 1 || q.Remaining != 4 {
|
||||
t.Errorf("SetQuota 后 %+v,want max=5 used=1 remaining=4", q)
|
||||
}
|
||||
|
||||
q, err = ResetQuota(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Used != 0 || q.Remaining != 5 {
|
||||
t.Errorf("ResetQuota 后 %+v,want used=0 remaining=5", q)
|
||||
}
|
||||
|
||||
// 负数上限归一为 0(不限),而不是造出一个永远发不出信的 Agent
|
||||
if q, err = SetQuota(ctx, "bot", -3); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if !q.Unlimited {
|
||||
t.Errorf("负数上限应视为不限,得到 %+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaUnknownAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := GetQuota(ctx, "ghost"); err == nil {
|
||||
t.Error("不存在的 Agent 应报错")
|
||||
}
|
||||
if _, err := ConsumeQuota(ctx, "ghost"); err == nil {
|
||||
t.Error("不存在的 Agent 占用配额应报错")
|
||||
}
|
||||
if errors.Is(func() error { _, e := ConsumeQuota(ctx, "ghost"); return e }(), ErrQuotaExhausted) {
|
||||
t.Error("不存在的 Agent 不该被报成「配额耗尽」")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
87
gateway/internal/repo/relay.go
Normal file
87
gateway/internal/repo/relay.go
Normal file
@ -0,0 +1,87 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 插件自动转发(免配额通道) ----------
|
||||
//
|
||||
// **基本原则:配额约束的是模型的自主发信,不是 harness 的转发。**
|
||||
//
|
||||
// 配额存在的意义是防止 Agent 无限自我循环。而插件代劳搬运的两类消息不属于此列:
|
||||
// 1. 平台原生的权限询问(opencode 的 permission.updated)—— 不转给人,人就看不到,
|
||||
// Agent 卡在那里等一个永远不会来的回答
|
||||
// 2. 本轮的最终总结(session.idle 时最后一条 assistant 消息)—— 模型已经把话说完了,
|
||||
// 插件只是把它搬到邮件里;对它收费会导致配额用尽时 Agent 连交代都做不了
|
||||
//
|
||||
// 防滥用不靠计数,靠**幂等键**:relay_key 是上游那条消息的稳定标识
|
||||
// (permission id / assistant message id)。唯一约束让同一条上游消息只能转一次,
|
||||
// 于是插件重试与 SSE 重放不会产生第二封,想多转就得拿出不同的上游消息 id ——
|
||||
// 而那些 id 由平台生成,模型伪造不出来。
|
||||
|
||||
// ErrRelayDuplicate 表示这条上游消息已经转发过了。
|
||||
var ErrRelayDuplicate = errors.New("relay already recorded")
|
||||
|
||||
// ClaimRelay 占用一次免配额转发名额。
|
||||
//
|
||||
// 判断与占用在同一条 INSERT 里(靠主键唯一约束),并发重试下只有一个能成功 ——
|
||||
// 分成「先查有没有、再插入」两步的话,插件的两次重试会双双通过检查各插一条。
|
||||
//
|
||||
// 返回 ErrRelayDuplicate 表示重复,调用方应当据此跳过发信而不是报错:
|
||||
// 重复转发是插件重试的正常结果,不是故障。
|
||||
func ClaimRelay(ctx context.Context, agentName, relayKey, kind string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO relayed_mails (agent_name, relay_key, kind) VALUES ($1, $2, $3)`,
|
||||
agentName, relayKey, kind)
|
||||
if err != nil {
|
||||
if db.IsUniqueViolation(err) {
|
||||
return ErrRelayDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindRelayMail 把已占用的名额关联到真正发出的邮件,便于事后审计
|
||||
// 「这封免配额的信是从哪条上游消息来的」。
|
||||
//
|
||||
// 关联失败不该让发信失败:邮件已经入库,缺一条审计关联不影响功能。
|
||||
func BindRelayMail(ctx context.Context, agentName, relayKey string, mailID uuid.UUID) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE relayed_mails SET mail_id = $1 WHERE agent_name = $2 AND relay_key = $3`,
|
||||
mailID, agentName, relayKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReleaseRelay 撤销名额占用。
|
||||
//
|
||||
// 占用成功但发信失败时必须还回去,否则那条上游消息永远转不出来了 ——
|
||||
// 幂等键会一直认为它已经转过。
|
||||
func ReleaseRelay(ctx context.Context, agentName, relayKey string) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2 AND mail_id IS NULL`,
|
||||
agentName, relayKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// RelayKeyForMail 反查某封邮件对应的上游消息 id。
|
||||
//
|
||||
// 人类决策一条权限请求后,插件需要知道该回复 opencode 的哪个 permission ——
|
||||
// 光有 AgentMail 的 mail_id 是不够的,两边的 id 空间不同。
|
||||
// 插件重启后内存映射会丢,所以这个映射必须在服务端持久化。
|
||||
//
|
||||
// 无记录时返回空串(例如旧数据,或压根没走 relay 通道的请求)。
|
||||
func RelayKeyForMail(ctx context.Context, mailID uuid.UUID) (string, string) {
|
||||
var key, kind string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT relay_key, kind FROM relayed_mails WHERE mail_id = $1 LIMIT 1`,
|
||||
mailID).Scan(&key, &kind)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return key, kind
|
||||
}
|
||||
1046
gateway/internal/repo/repo.go
Normal file
1046
gateway/internal/repo/repo.go
Normal file
File diff suppressed because it is too large
Load Diff
188
gateway/internal/repo/thread.go
Normal file
188
gateway/internal/repo/thread.go
Normal file
@ -0,0 +1,188 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 对话树。
|
||||
//
|
||||
// **不另建 tree_nodes 表**:`mails.parent_mail_id` 已经完整编码了树结构 ——
|
||||
// 回复指向来信,转发指向被转发的原件。再维护一张 tree_nodes 就是第二份真相,
|
||||
// 两处不一致时无法判断谁对。这里直接用递归 CTE 在 mails 上查。
|
||||
//
|
||||
// 树可以跨会话:转发把线索引到新会话,但 parent 仍指向原件。这正是「对话树」比
|
||||
// 「会话内平铺」更有价值的地方 —— 能看出一条线索分叉去了哪里。
|
||||
// 也正因如此,读取时必须按会话逐个鉴权(见 handler):
|
||||
// A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。
|
||||
//
|
||||
// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||||
// 按方向分页 —— 祖先向上、子孙向下,各自带游标。
|
||||
//
|
||||
// 游标用「相对锚点的原始层号偏移」而不是 mail_id:
|
||||
// - 偏移量每次从锚点重走一遍,无状态、不可伪造,也不需要额外证明
|
||||
// 「这个 cursor 真的在这条线索上」
|
||||
// - 用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去),
|
||||
// 那就得单独校验它确实是锚点的祖先,反而更绕
|
||||
// - 祖先方向的层号天然稳定:新邮件只会追加成叶子,不会插进已有链条中间
|
||||
|
||||
// TreeMail 是树里的一个节点。正文只带预览:整棵线索带全文可能几百 KB,
|
||||
// 前端点开某封时再单取全文与附件清单。
|
||||
type TreeMail struct {
|
||||
models.Mail
|
||||
// Depth 是**相对锚点**的层级:0 = 锚点,-1 = 父,1 = 子。
|
||||
// 不用「距根深度」—— 分块加载时根可能还没取到,绝对深度无从得知。
|
||||
Depth int `json:"depth"`
|
||||
AttachmentCount int `json:"attachment_count"`
|
||||
}
|
||||
|
||||
// descendantDepthCap 只是数据损坏时的兜底。
|
||||
//
|
||||
// parent_mail_id 正常不成环(新邮件只能指向已存在的旧邮件),但一旦被外部工具改坏,
|
||||
// 无上限的递归 CTE 会把进程拖死。取得足够大,正常数据碰不到。
|
||||
const descendantDepthCap = 10000
|
||||
|
||||
const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
|
||||
m.cc_list, m.subject, m.body, m.mail_type,
|
||||
COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count`
|
||||
|
||||
// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。
|
||||
// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数。
|
||||
//
|
||||
// **不做可见性过滤** —— 不可见的中间段必须能穿过:转发把线索引进别人的会话,
|
||||
// 再往上却可能仍是自己参与的往来。过滤放在 handler 层(那里知道调用者是谁)。
|
||||
//
|
||||
// 第二个返回值表示 offset+limit 层之上还有节点。
|
||||
func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS (
|
||||
SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1
|
||||
UNION ALL
|
||||
SELECT m.mail_id, m.parent_mail_id, up.lvl + 1
|
||||
FROM mails m JOIN up ON m.mail_id = up.parent_mail_id
|
||||
WHERE up.lvl < $2
|
||||
)
|
||||
SELECT `+threadCols+`, u.lvl
|
||||
FROM up u
|
||||
JOIN mails m ON m.mail_id = u.mail_id
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE u.lvl > $3
|
||||
ORDER BY u.lvl ASC
|
||||
`, anchorID, offset+limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// 多取一层用来判断「上面还有没有」,不返回给调用方
|
||||
out, err := scanTreeRows(rows, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// DescendantsRaw 取锚点及其子孙,BFS 顺序(同层按时间),按节点数分页。
|
||||
//
|
||||
// offset = 0 时结果的第一个是锚点自己(Depth 0)。
|
||||
// 同样不做可见性过滤,理由同 AncestorsRaw:不可见的子节点下面可能挂着可见的孙节点
|
||||
// (别人把线索转走又转回来给我)。
|
||||
//
|
||||
// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一封邮件的子孙通常很少
|
||||
// (分支来自转发,不是回复),这个代价可以接受;真出现巨型子树时再加物化。
|
||||
func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE down(mail_id, lvl) AS (
|
||||
SELECT mail_id, 0 FROM mails WHERE mail_id = $1
|
||||
UNION ALL
|
||||
SELECT m.mail_id, down.lvl + 1
|
||||
FROM mails m JOIN down ON m.parent_mail_id = down.mail_id
|
||||
WHERE down.lvl < $2
|
||||
)
|
||||
SELECT `+threadCols+`, d.lvl
|
||||
FROM down d
|
||||
JOIN mails m ON m.mail_id = d.mail_id
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
ORDER BY d.lvl ASC, m.created_at ASC, m.mail_id ASC
|
||||
LIMIT $3 OFFSET $4
|
||||
`, anchorID, descendantDepthCap, limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out, err := scanTreeRows(rows, false)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// scanTreeRows 读出节点。negate 为真时把层号取负(祖先方向)。
|
||||
func scanTreeRows(rows interface {
|
||||
Next() bool
|
||||
Scan(...interface{}) error
|
||||
Err() error
|
||||
Close() error
|
||||
}, negate bool) ([]TreeMail, error) {
|
||||
defer rows.Close()
|
||||
|
||||
out := []TreeMail{}
|
||||
for rows.Next() {
|
||||
var t TreeMail
|
||||
var alias *string
|
||||
var ccJSON []byte
|
||||
var lvl int
|
||||
if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID,
|
||||
&t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace,
|
||||
&ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult,
|
||||
&t.Status, &t.CreatedAt, &alias, &t.AttachmentCount, &lvl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
json.Unmarshal(ccJSON, &t.CCList)
|
||||
}
|
||||
if t.CCList == nil {
|
||||
t.CCList = []models.Address{}
|
||||
}
|
||||
if alias != nil {
|
||||
t.SessionAlias = *alias
|
||||
}
|
||||
if negate {
|
||||
t.Depth = -lvl
|
||||
} else {
|
||||
t.Depth = lvl
|
||||
}
|
||||
t.BodyPreview = preview(t.Body, 240)
|
||||
t.Body = "" // 树视图只要预览,全文按需单取
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// preview 按 UTF-8 边界截断正文。
|
||||
// 直接切字节会把多字节字符切成半个,前端渲染出 U+FFFD 替换符。
|
||||
func preview(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8Start(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
|
||||
// utf8Start 判断某字节是否为一个 UTF-8 序列的首字节
|
||||
func utf8Start(b byte) bool { return b&0xC0 != 0x80 }
|
||||
23
gateway/internal/repo/thread_test.go
Normal file
23
gateway/internal/repo/thread_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package repo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) {
|
||||
// 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间
|
||||
s := "巡检报告"
|
||||
got := preview(s, 4)
|
||||
if got != "巡..." {
|
||||
t.Fatalf("按 UTF-8 边界截断失败:%q", got)
|
||||
}
|
||||
for i, r := range got {
|
||||
if r == 0xFFFD {
|
||||
t.Fatalf("位置 %d 出现替换符,说明切在了字符中间", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewKeepsShortBodyIntact(t *testing.T) {
|
||||
if got := preview("短正文", 240); got != "短正文" {
|
||||
t.Fatalf("未超长却被改动:%q", got)
|
||||
}
|
||||
}
|
||||
512
gateway/internal/repo/users.go
Normal file
512
gateway/internal/repo/users.go
Normal file
@ -0,0 +1,512 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
bcryptCost = 12
|
||||
sessionTTL = 7 * 24 * time.Hour
|
||||
userSelectCols = `user_id, username, display_name, password_hash, role, status, created_at, last_login, allowed_agents, allowed_paths`
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrBadCredentials = errors.New("invalid username or password")
|
||||
ErrUserDisabled = errors.New("user disabled")
|
||||
ErrNameTaken = errors.New("name already taken by an agent or user")
|
||||
ErrSessionInvalid = errors.New("session invalid or expired")
|
||||
ErrInvalidUsername = errors.New("username must be 2-64 chars of [a-z0-9._-]")
|
||||
ErrAlreadySetup = errors.New("system already initialized")
|
||||
)
|
||||
|
||||
// ---------- 命名空间校验 ----------
|
||||
|
||||
// 三维地址的 name 位由人类用户与 Agent 共用,因此必须全局唯一
|
||||
func nameTaken(ctx context.Context, name string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT (SELECT COUNT(*) FROM users WHERE username = $1)
|
||||
+ (SELECT COUNT(*) FROM agents WHERE agent_name = $1)
|
||||
`, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// AgentNameAvailable 供 Agent 注册前校验(不与人类用户重名)
|
||||
func AgentNameAvailable(ctx context.Context, agentName string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE username = $1`, agentName).Scan(&n)
|
||||
return n == 0, err
|
||||
}
|
||||
|
||||
func validUsername(name string) bool {
|
||||
if len(name) < 2 || len(name) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 保留字:human 是兼容别名,不能被真实用户占用
|
||||
return name != "human"
|
||||
}
|
||||
|
||||
// ---------- User CRUD ----------
|
||||
|
||||
func scanUser(row *sql.Row) (*models.User, error) {
|
||||
var u models.User
|
||||
var agentsJSON, pathsJSON []byte
|
||||
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
|
||||
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u.AllowedAgents = decodeStrList(agentsJSON)
|
||||
u.AllowedPaths = decodeStrList(pathsJSON)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func decodeStrList(raw []byte) []string {
|
||||
out := []string{}
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CreateUser(ctx context.Context, username, password, displayName, role string,
|
||||
allowedAgents, allowedPaths []string) (*models.User, error) {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if !validUsername(username) {
|
||||
return nil, ErrInvalidUsername
|
||||
}
|
||||
if role != "admin" {
|
||||
role = "user"
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
|
||||
taken, err := nameTaken(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, ErrNameTaken
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
agentsJSON, _ := json.Marshal(normalizeList(allowedAgents))
|
||||
pathsJSON, _ := json.Marshal(normalizeList(allowedPaths))
|
||||
|
||||
row := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO users (username, display_name, password_hash, role, allowed_agents, allowed_paths)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING `+userSelectCols,
|
||||
username, displayName, string(hash), role, agentsJSON, pathsJSON)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func GetUserByName(ctx context.Context, username string) (*models.User, error) {
|
||||
return scanUser(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users WHERE username = $1`,
|
||||
strings.ToLower(strings.TrimSpace(username))))
|
||||
}
|
||||
|
||||
func GetUserByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
||||
return scanUser(db.DB.QueryRowContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users WHERE user_id = $1`, id))
|
||||
}
|
||||
|
||||
func ListUsers(ctx context.Context) ([]models.User, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT `+userSelectCols+` FROM users ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := []models.User{}
|
||||
for rows.Next() {
|
||||
var u models.User
|
||||
var agentsJSON, pathsJSON []byte
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash,
|
||||
&u.Role, &u.Status, &u.CreatedAt, &u.LastLogin, &agentsJSON, &pathsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.AllowedAgents = decodeStrList(agentsJSON)
|
||||
u.AllowedPaths = decodeStrList(pathsJSON)
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UserUpdate 描述一次用户更新;nil 字段表示不改
|
||||
type UserUpdate struct {
|
||||
DisplayName *string
|
||||
Role *string
|
||||
Status *string
|
||||
AllowedAgents *[]string
|
||||
AllowedPaths *[]string
|
||||
}
|
||||
|
||||
func UpdateUser(ctx context.Context, id uuid.UUID, up UserUpdate) (*models.User, error) {
|
||||
var agentsJSON, pathsJSON *string
|
||||
if up.AllowedAgents != nil {
|
||||
b, _ := json.Marshal(normalizeList(*up.AllowedAgents))
|
||||
s := string(b)
|
||||
agentsJSON = &s
|
||||
}
|
||||
if up.AllowedPaths != nil {
|
||||
b, _ := json.Marshal(normalizeList(*up.AllowedPaths))
|
||||
s := string(b)
|
||||
pathsJSON = &s
|
||||
}
|
||||
|
||||
row := db.DB.QueryRowContext(ctx, `
|
||||
UPDATE users SET
|
||||
display_name = COALESCE($2, display_name),
|
||||
role = COALESCE($3, role),
|
||||
status = COALESCE($4, status),
|
||||
allowed_agents = COALESCE($5`+db.JSONCast()+`, allowed_agents),
|
||||
allowed_paths = COALESCE($6`+db.JSONCast()+`, allowed_paths)
|
||||
WHERE user_id = $1
|
||||
RETURNING `+userSelectCols,
|
||||
id, up.DisplayName, up.Role, up.Status, agentsJSON, pathsJSON)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// normalizeList 去空白、去空项、去重,保持顺序
|
||||
func normalizeList(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]bool{}
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE users SET password_hash = $2 WHERE user_id = $1`, id, string(hash))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
// 改密后踢掉该用户所有会话
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func DisableUser(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE users SET status = 'disabled' WHERE user_id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE user_id = $1`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CountAdmins(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// EnsureAdminUser 首次启动时创建默认管理员(幂等)
|
||||
func EnsureAdminUser(ctx context.Context, username, password string) (*models.User, bool, error) {
|
||||
if n, err := CountAdmins(ctx); err != nil {
|
||||
return nil, false, err
|
||||
} else if n > 0 {
|
||||
u, err := GetUserByName(ctx, username)
|
||||
if err != nil && !errors.Is(err, ErrUserNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
return u, false, nil
|
||||
}
|
||||
u, err := CreateUser(ctx, username, password, "管理员", "admin", nil, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
// ---------- 登录 / 会话令牌 ----------
|
||||
|
||||
func Authenticate(ctx context.Context, username, password string) (*models.User, error) {
|
||||
u, err := GetUserByName(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
// 统一错误,避免暴露用户是否存在
|
||||
return nil, ErrBadCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
return nil, ErrBadCredentials
|
||||
}
|
||||
_, _ = db.DB.ExecContext(ctx, `UPDATE users SET last_login = NOW() WHERE user_id = $1`, u.ID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func CreateUserSession(ctx context.Context, userID uuid.UUID, userAgent string) (string, time.Time, error) {
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
expires := time.Now().Add(sessionTTL)
|
||||
if len(userAgent) > 256 {
|
||||
userAgent = userAgent[:256]
|
||||
}
|
||||
_, err = db.DB.ExecContext(ctx, `
|
||||
INSERT INTO user_sessions (token, user_id, expires_at, user_agent)
|
||||
VALUES ($1, $2, $3, $4)`, token, userID, expires, userAgent)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
// 顺手清理过期令牌
|
||||
_, _ = db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE expires_at < NOW()`)
|
||||
return token, expires, nil
|
||||
}
|
||||
|
||||
// ResolveUserSession 校验令牌并滑动续期
|
||||
func ResolveUserSession(ctx context.Context, token string) (*models.User, error) {
|
||||
if token == "" {
|
||||
return nil, ErrSessionInvalid
|
||||
}
|
||||
var userID uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT user_id FROM user_sessions
|
||||
WHERE token = $1 AND expires_at > NOW()`, token).Scan(&userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSessionInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, err := GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Status != "active" {
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`UPDATE user_sessions SET expires_at = $2 WHERE token = $1`,
|
||||
token, time.Now().Add(sessionTTL))
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func DeleteUserSession(ctx context.Context, token string) error {
|
||||
_, err := db.DB.ExecContext(ctx, `DELETE FROM user_sessions WHERE token = $1`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 人类用户候选(供地址补全) ----------
|
||||
|
||||
func ListActiveUsernames(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT username FROM users WHERE status = 'active' ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err == nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- 会话归属 ----------
|
||||
|
||||
func SetSessionOwner(ctx context.Context, sessionID, userID uuid.UUID) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET owner_user_id = $2 WHERE session_id = $1 AND owner_user_id IS NULL`,
|
||||
sessionID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionOwnerUsername 返回会话归属人类用户名;无归属时返回空串
|
||||
func SessionOwnerUsername(ctx context.Context, sessionID uuid.UUID) (string, error) {
|
||||
var name *string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT u.username
|
||||
FROM sessions s LEFT JOIN users u ON u.user_id = s.owner_user_id
|
||||
WHERE s.session_id = $1`, sessionID).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if name == nil {
|
||||
return "", nil
|
||||
}
|
||||
return *name, nil
|
||||
}
|
||||
|
||||
// UserCanAccessSession 判断用户能否访问该会话:owner、或在邮件收发/抄送中出现,或 admin
|
||||
func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UUID) (bool, error) {
|
||||
if u.IsAdmin() {
|
||||
return true, nil
|
||||
}
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM sessions s
|
||||
WHERE s.session_id = $1
|
||||
AND (s.owner_user_id = $2
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.from_name = $3 OR m.to_name = $3
|
||||
OR `+db.CCHas("m.cc_list", 3)+`)
|
||||
))
|
||||
`, sessionID, u.ID, u.Username).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// FirstAdminUsername 返回最早创建的可用管理员用户名(用于无归属会话的兜底决策人)
|
||||
func FirstAdminUsername(ctx context.Context) (string, error) {
|
||||
var name string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT username FROM users
|
||||
WHERE role = 'admin' AND status = 'active'
|
||||
ORDER BY created_at ASC LIMIT 1`).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用)
|
||||
func RandomPassword(n int) string {
|
||||
const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "ChangeMe" + fmt.Sprint(time.Now().Unix())
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ---------- Setup(首次初始化管理员) ----------
|
||||
|
||||
// NeedsSetup 返回系统是否尚未初始化(没有任何用户)
|
||||
func NeedsSetup(ctx context.Context) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n == 0, err
|
||||
}
|
||||
|
||||
// SetupFirstAdmin 在系统尚无任何用户时创建首个管理员。
|
||||
// 已初始化时返回 ErrAlreadySetup,避免被用作后门。
|
||||
func SetupFirstAdmin(ctx context.Context, username, password, displayName string) (*models.User, error) {
|
||||
empty, err := NeedsSetup(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !empty {
|
||||
return nil, ErrAlreadySetup
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
return CreateUser(ctx, username, password, displayName, "admin", nil, nil)
|
||||
}
|
||||
|
||||
// ---------- 可选目录候选(供权限设置界面) ----------
|
||||
|
||||
// AllWorkspaceNames 汇总所有 Agent 注册过的工作区名,供管理员挑选可访问目录
|
||||
func AllWorkspaceNames(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT ws->>'name' AS name
|
||||
FROM agents, jsonb_array_elements(workspaces) AS ws
|
||||
WHERE COALESCE(ws->>'name', '') <> ''
|
||||
ORDER BY name`)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err == nil {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsHumanUser 判断某个三维地址 name 位是否为人类用户
|
||||
func IsHumanUser(ctx context.Context, name string) (bool, error) {
|
||||
var n int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE username = $1`, name).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
173
gateway/internal/sse/manager.go
Normal file
173
gateway/internal/sse/manager.go
Normal file
@ -0,0 +1,173 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Client 是一个 SSE 连接客户端
|
||||
type Client struct {
|
||||
ID string
|
||||
AgentName string // 非空 = Agent 侧连接
|
||||
UserName string // 非空 = 已登录人类用户的前端连接
|
||||
Res http.ResponseWriter
|
||||
Flusher http.Flusher
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Manager 管理所有 SSE 客户端连接
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*Client
|
||||
}
|
||||
|
||||
var Default = &Manager{
|
||||
clients: make(map[string]*Client),
|
||||
}
|
||||
|
||||
// AddClient 注册一个新 SSE 客户端(agentName 与 userName 二者恰其一)
|
||||
func (m *Manager) AddClient(res http.ResponseWriter, agentName, userName string) *Client {
|
||||
flusher, ok := res.(http.Flusher)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
id := uuid.New().String()[:8]
|
||||
client := &Client{
|
||||
ID: id,
|
||||
AgentName: agentName,
|
||||
UserName: userName,
|
||||
Res: res,
|
||||
Flusher: flusher,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.Header().Set("Content-Type", "text/event-stream")
|
||||
res.Header().Set("Cache-Control", "no-cache")
|
||||
res.Header().Set("Connection", "keep-alive")
|
||||
res.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
m.mu.Lock()
|
||||
m.clients[id] = client
|
||||
m.mu.Unlock()
|
||||
|
||||
// 发送连接确认
|
||||
client.Send("connected", map[string]string{"id": id})
|
||||
|
||||
// 启动心跳
|
||||
go m.heartbeat(client)
|
||||
|
||||
fmt.Printf("[SSE] Client connected: %s (agent=%q user=%q)\n", id, agentName, userName)
|
||||
return client
|
||||
}
|
||||
|
||||
// RemoveClient 移除一个客户端
|
||||
func (m *Manager) RemoveClient(id string) {
|
||||
m.mu.Lock()
|
||||
if c, ok := m.clients[id]; ok {
|
||||
close(c.done)
|
||||
delete(m.clients, id)
|
||||
fmt.Printf("[SSE] Client disconnected: %s\n", id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SendToAgent 向指定 Agent 名的所有客户端推送事件
|
||||
func (m *Manager) SendToAgent(agentName, eventType string, data interface{}) {
|
||||
if agentName == "" {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.AgentName == agentName {
|
||||
c.Send(eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser 向指定人类用户的所有前端连接推送事件
|
||||
func (m *Manager) SendToUser(userName, eventType string, data interface{}) {
|
||||
if userName == "" {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.UserName == userName {
|
||||
c.Send(eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToRecipient 根据收件人名同时尝试 Agent 通道与人类用户通道
|
||||
// (三维地址的 name 位共享命名空间,投递时不必先判断对方是人还是 Agent)
|
||||
func (m *Manager) SendToRecipient(name, eventType string, data interface{}) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
if c.AgentName == name || c.UserName == name {
|
||||
c.Send(eventType, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast 向所有客户端广播事件
|
||||
func (m *Manager) Broadcast(eventType string, data interface{}) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, c := range m.clients {
|
||||
c.Send(eventType, data)
|
||||
}
|
||||
}
|
||||
|
||||
// ClientCount 返回当前连接数
|
||||
func (m *Manager) ClientCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.clients)
|
||||
}
|
||||
|
||||
// Send 向单个客户端发送事件
|
||||
func (c *Client) Send(eventType string, data interface{}) {
|
||||
defer func() { recover() }() // 防止向已关闭的连接写入 panic
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
|
||||
c.Flusher.Flush()
|
||||
}
|
||||
|
||||
// heartbeat 定期发送心跳保活
|
||||
func (m *Manager) heartbeat(client *Client) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-client.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// SSE 注释行作为心跳
|
||||
defer func() { recover() }()
|
||||
fmt.Fprintf(client.Res, ": heartbeat\n\n")
|
||||
client.Flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
29
gateway/internal/static/static.go
Normal file
29
gateway/internal/static/static.go
Normal file
@ -0,0 +1,29 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
indexHTML []byte
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func GetIndex() []byte {
|
||||
once.Do(func() {
|
||||
data, _ := fs.ReadFile(staticFS, "static/index.html")
|
||||
indexHTML = data
|
||||
})
|
||||
return indexHTML
|
||||
}
|
||||
|
||||
func Handler() http.Handler {
|
||||
sub, _ := fs.Sub(staticFS, "static")
|
||||
return http.FileServer(http.FS(sub))
|
||||
}
|
||||
Reference in New Issue
Block a user