409 lines
11 KiB
Go
409 lines
11 KiB
Go
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 !DecodeBody(w, r, &req) {
|
|
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 !DecodeBody(w, r, &req) {
|
|
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(r.Context(), 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(r.Context(), name)
|
|
Error(w, http.StatusUnauthorized, "用户名或密码错误")
|
|
case errors.Is(err, repo.ErrUserDisabled):
|
|
Error(w, http.StatusForbidden, "账号已被禁用")
|
|
default:
|
|
Error(w, http.StatusInternalServerError, "登录失败")
|
|
}
|
|
return
|
|
}
|
|
limiter.Reset(r.Context(), 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 !DecodeBody(w, r, &req) {
|
|
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 !DecodeBody(w, r, &req) {
|
|
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 !DecodeBody(w, r, &req) {
|
|
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 !DecodeBody(w, r, &req) {
|
|
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
|
|
}
|