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 时转发本轮总结)
162 lines
4.8 KiB
Go
162 lines
4.8 KiB
Go
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
|
||
}
|