- admin_user.go:userForm 加 json tags,parseUserFormJSON(绑定+去白,
ID 取路由参数);UserCreate/Update/Delete 校验错误改 APIError
(400 校验、409 user_username_exists、403 自防/最后管理员、404
user_not_found、500),成功 {ok,redirect:?saved=1&msg=...}
- i18n:新增 user_not_found(中英)
- main.go:旧 POST /admin/users/new|:id/edit|:id/delete 移除,
改 POST/PUT/DELETE /api/admin/users[/:id](自防屏障保留)
- user_form.html:表单改 blogAPI(FormID 分流 POST/PUT),错误内联
- user_list.html:删除改 blogDelete 委托
- p2/security 测试:环境路由同步 + 用户校验/注入用例改 JSON 断言
(TestAdminUserPasswordAndEmailEnforcement、TestAdminUserRoutesRejectNonNumericIDs)
- main_test 冒烟补用户 API 断言;go build/vet/test 全绿
405 lines
11 KiB
Go
405 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go_blog/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// userForm 是后台用户创建/更新接口的 JSON 请求体,
|
|
// 同时供共享创建/编辑表单模板的渲染使用(IsEdit/Action/TitleText 非绑定字段)。
|
|
type userForm struct {
|
|
ID uint `json:"-"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
DisplayName string `json:"display_name"`
|
|
Email string `json:"email"`
|
|
Gender string `json:"gender"`
|
|
Birthday string `json:"birthday"` // 来自日期输入的 YYYY-MM-DD
|
|
Role string `json:"role"`
|
|
Status int `json:"status"`
|
|
IsEdit bool
|
|
Action string
|
|
TitleText string
|
|
}
|
|
|
|
// userListView 为 User 附加预渲染的标签/徽章,
|
|
// 使模板绝不调用包装为 interface{} 的结构体上的方法。
|
|
type userListView struct {
|
|
models.User
|
|
RoleLabel string
|
|
RoleBadge string
|
|
StatusLabel string
|
|
StatusBadge string
|
|
}
|
|
|
|
// parseUserFormJSON 绑定 JSON 请求体的用户字段并去除空白。
|
|
// 绑定失败时已写入 400 响应并返回 ok=false。ID 来自路由参数。
|
|
func parseUserFormJSON(c *gin.Context) (userForm, bool) {
|
|
var f userForm
|
|
if !bindJSON(c, &f) {
|
|
return f, false
|
|
}
|
|
f.ID = uintFormID(c.Param("id"))
|
|
f.Username = strings.TrimSpace(f.Username)
|
|
f.DisplayName = strings.TrimSpace(f.DisplayName)
|
|
f.Email = strings.TrimSpace(f.Email)
|
|
f.Gender = strings.TrimSpace(f.Gender)
|
|
f.Birthday = strings.TrimSpace(f.Birthday)
|
|
f.Role = strings.TrimSpace(f.Role)
|
|
return f, true
|
|
}
|
|
|
|
// uintFormID 将路由 :id 解析为 uint(不存在/非法时为 0)。
|
|
func uintFormID(s string) uint {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.ParseUint(s, 10, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return uint(n)
|
|
}
|
|
|
|
// applyUserFormToData 将表单值写入模板数据映射,
|
|
// 使渲染时表单被重新填充(初次加载或校验错误)。
|
|
func applyUserFormToData(data gin.H, f userForm) {
|
|
data["FormID"] = f.ID
|
|
data["FormUsername"] = f.Username
|
|
data["FormPassword"] = f.Password
|
|
data["FormDisplayName"] = f.DisplayName
|
|
data["FormEmail"] = f.Email
|
|
data["FormGender"] = f.Gender
|
|
data["FormBirthday"] = f.Birthday
|
|
data["FormRole"] = f.Role
|
|
data["FormStatus"] = f.Status
|
|
data["FormIsEdit"] = f.IsEdit
|
|
data["FormAction"] = f.Action
|
|
data["FormTitleText"] = f.TitleText
|
|
}
|
|
|
|
// renderUserForm 使用给定的表单值和可选的错误消息渲染共享的用户表单模板。
|
|
func renderUserForm(c *gin.Context, f userForm, errMsg string) {
|
|
tr := getTr(c)
|
|
data := DefaultData(c)
|
|
data["Title"] = f.TitleText
|
|
if errMsg != "" {
|
|
data["Error"] = errMsg
|
|
}
|
|
// <select> 元素的角色/状态选项。
|
|
data["RoleAdmin"] = models.RoleAdmin
|
|
data["RoleAuthor"] = models.RoleAuthor
|
|
data["StatusNormal"] = models.StatusNormal
|
|
data["StatusDisabled"] = models.StatusDisabled
|
|
data["StatusLocked"] = models.StatusLocked
|
|
data["StatusUnactivated"] = models.StatusUnactivated
|
|
data["TrGenderMale"] = tr["gender_male"]
|
|
data["TrGenderFemale"] = tr["gender_female"]
|
|
data["TrGenderOther"] = tr["gender_other"]
|
|
applyUserFormToData(data, f)
|
|
c.HTML(http.StatusOK, "user_form", data)
|
|
}
|
|
|
|
// UserListPage 渲染后台用户管理列表。
|
|
func UserListPage(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
tr := getTr(c)
|
|
var users []models.User
|
|
db.Order("created_at DESC").Find(&users)
|
|
|
|
views := make([]userListView, 0, len(users))
|
|
for _, u := range users {
|
|
v := userListView{User: u}
|
|
switch u.Role {
|
|
case models.RoleAdmin:
|
|
v.RoleLabel = tr["role_admin"]
|
|
v.RoleBadge = "bg-purple-100 text-purple-700"
|
|
default:
|
|
v.RoleLabel = tr["role_author"]
|
|
v.RoleBadge = "bg-blue-100 text-blue-700"
|
|
}
|
|
switch u.Status {
|
|
case models.StatusNormal:
|
|
v.StatusLabel = tr["status_normal"]
|
|
v.StatusBadge = "bg-green-100 text-green-700"
|
|
case models.StatusDisabled:
|
|
v.StatusLabel = tr["status_disabled"]
|
|
v.StatusBadge = "bg-gray-200 text-gray-600"
|
|
case models.StatusLocked:
|
|
v.StatusLabel = tr["status_locked"]
|
|
v.StatusBadge = "bg-yellow-100 text-yellow-700"
|
|
default:
|
|
v.StatusLabel = tr["status_unactivated"]
|
|
v.StatusBadge = "bg-red-100 text-red-700"
|
|
}
|
|
views = append(views, v)
|
|
}
|
|
|
|
data := DefaultData(c)
|
|
data["Title"] = tr["admin_users_title"]
|
|
data["Users"] = views
|
|
if msg := c.Query("saved"); msg == "1" {
|
|
switch c.Query("msg") {
|
|
case "created":
|
|
data["Success"] = tr["user_created"]
|
|
case "updated":
|
|
data["Success"] = tr["user_updated"]
|
|
case "deleted":
|
|
data["Success"] = tr["user_deleted"]
|
|
default:
|
|
data["Success"] = tr["settings_saved"]
|
|
}
|
|
}
|
|
if e := c.Query("error"); e != "" {
|
|
switch e {
|
|
case "self_disable":
|
|
data["Error"] = tr["user_cannot_disable_self"]
|
|
case "last_admin":
|
|
data["Error"] = tr["user_cannot_remove_last_admin"]
|
|
case "exists":
|
|
data["Error"] = tr["user_username_exists"]
|
|
}
|
|
}
|
|
c.HTML(http.StatusOK, "user_list", data)
|
|
}
|
|
}
|
|
|
|
// UserCreatePage 渲染空白用户创建表单。
|
|
func UserCreatePage(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
tr := getTr(c)
|
|
renderUserForm(c, userForm{
|
|
Action: "/admin/users/new",
|
|
TitleText: tr["user_create_title"],
|
|
Role: models.RoleAuthor,
|
|
Status: models.StatusNormal,
|
|
IsEdit: false,
|
|
}, "")
|
|
}
|
|
}
|
|
|
|
// UserCreate 处理 POST 创建新用户。
|
|
func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
f, ok := parseUserFormJSON(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if f.Username == "" {
|
|
APIError(c, http.StatusBadRequest, "user_username_required")
|
|
return
|
|
}
|
|
if f.Password == "" {
|
|
APIError(c, http.StatusBadRequest, "user_password_required")
|
|
return
|
|
}
|
|
// SECURITY (#23):执行平台最小密码长度。
|
|
if !validatePassword(f.Password) {
|
|
APIError(c, http.StatusBadRequest, "user_password_short")
|
|
return
|
|
}
|
|
// SECURITY (#24):拒绝格式非法的邮箱地址。
|
|
if !validateEmail(f.Email) {
|
|
APIError(c, http.StatusBadRequest, "user_email_invalid")
|
|
return
|
|
}
|
|
if f.Role == "" {
|
|
f.Role = models.RoleAuthor
|
|
}
|
|
|
|
// 用户名必须唯一。
|
|
var exists int64
|
|
db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists)
|
|
if exists > 0 {
|
|
APIError(c, http.StatusConflict, "user_username_exists")
|
|
return
|
|
}
|
|
|
|
user := models.User{
|
|
Username: f.Username,
|
|
DisplayName: f.DisplayName,
|
|
Email: f.Email,
|
|
Gender: f.Gender,
|
|
Role: f.Role,
|
|
Status: f.Status,
|
|
}
|
|
if t, err := time.Parse("2006-01-02", f.Birthday); err == nil {
|
|
user.Birthday = &t
|
|
}
|
|
if err := user.SetPassword(f.Password); err != nil {
|
|
APIError(c, http.StatusInternalServerError, "article_error")
|
|
return
|
|
}
|
|
if err := db.Create(&user).Error; err != nil {
|
|
APIError(c, http.StatusInternalServerError, "article_error")
|
|
return
|
|
}
|
|
APIOK(c, "/admin/users?saved=1&msg=created", gin.H{"user_id": user.ID})
|
|
}
|
|
}
|
|
|
|
// UserEditPage 渲染预填现有用户的编辑表单。
|
|
func UserEditPage(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
tr := getTr(c)
|
|
// SECURITY (#19):路由参数必须先解析为数值 id 再交给 GORM——
|
|
// 原始字符串作为单一条件传给 First() 时会按 SQL WHERE 子句拼接。
|
|
id := uintFormID(c.Param("id"))
|
|
if id == 0 {
|
|
c.Redirect(http.StatusFound, "/admin/users")
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := db.First(&user, id).Error; err != nil {
|
|
c.Redirect(http.StatusFound, "/admin/users")
|
|
return
|
|
}
|
|
birthday := ""
|
|
if user.Birthday != nil {
|
|
birthday = user.Birthday.Format("2006-01-02")
|
|
}
|
|
renderUserForm(c, userForm{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
DisplayName: user.DisplayName,
|
|
Email: user.Email,
|
|
Gender: user.Gender,
|
|
Birthday: birthday,
|
|
Role: user.Role,
|
|
Status: user.Status,
|
|
IsEdit: true,
|
|
Action: fmt.Sprintf("/admin/users/%d/edit", user.ID),
|
|
TitleText: tr["user_edit_title"],
|
|
}, "")
|
|
}
|
|
}
|
|
|
|
// UserUpdate 处理 POST 更新现有用户。
|
|
func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
f, ok := parseUserFormJSON(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
|
|
if f.ID == 0 {
|
|
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := db.First(&user, f.ID).Error; err != nil {
|
|
APIError(c, http.StatusNotFound, "user_not_found")
|
|
return
|
|
}
|
|
|
|
// 保留原用户名(它是登录键 + 文章外键的来源)。
|
|
f.Username = user.Username
|
|
|
|
// SECURITY (#23/#24):在进行任何其他修改前校验提交的密码/邮箱——
|
|
// 密码重置或个人资料编辑必须遵守与注册相同的规则。
|
|
if f.Password != "" && !validatePassword(f.Password) {
|
|
APIError(c, http.StatusBadRequest, "user_password_short")
|
|
return
|
|
}
|
|
if !validateEmail(f.Email) {
|
|
APIError(c, http.StatusBadRequest, "user_email_invalid")
|
|
return
|
|
}
|
|
|
|
currentID := userIDFromSession(c)
|
|
isSelf := user.ID == currentID
|
|
|
|
// 自我保护:不能禁用/锁定自己的账户。
|
|
if isSelf && f.Status != models.StatusNormal {
|
|
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
|
|
return
|
|
}
|
|
|
|
// 自我保护:不能降级最后一位管理员。
|
|
if user.Role == models.RoleAdmin && f.Role != models.RoleAdmin {
|
|
var adminCount int64
|
|
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
|
if adminCount <= 1 {
|
|
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
|
|
return
|
|
}
|
|
}
|
|
if f.Role == "" {
|
|
f.Role = user.Role
|
|
}
|
|
|
|
user.DisplayName = f.DisplayName
|
|
user.Email = f.Email
|
|
user.Gender = f.Gender
|
|
user.Role = f.Role
|
|
user.Status = f.Status
|
|
if t, err := time.Parse("2006-01-02", f.Birthday); err == nil {
|
|
user.Birthday = &t
|
|
} else {
|
|
user.Birthday = nil
|
|
}
|
|
|
|
// 可选密码重置(留空表示保持当前密码)。
|
|
if f.Password != "" {
|
|
if err := user.SetPassword(f.Password); err != nil {
|
|
APIError(c, http.StatusInternalServerError, "article_error")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := db.Save(&user).Error; err != nil {
|
|
APIError(c, http.StatusInternalServerError, "article_error")
|
|
return
|
|
}
|
|
APIOK(c, "/admin/users?saved=1&msg=updated", nil)
|
|
}
|
|
}
|
|
|
|
// UserDelete 软删除用户,带自我保护和最后管理员防线。
|
|
func UserDelete(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
|
|
targetID := uintFormID(c.Param("id"))
|
|
if targetID == 0 {
|
|
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
|
return
|
|
}
|
|
currentID := userIDFromSession(c)
|
|
|
|
var user models.User
|
|
if err := db.First(&user, targetID).Error; err != nil {
|
|
APIError(c, http.StatusNotFound, "user_not_found")
|
|
return
|
|
}
|
|
|
|
// 不能删除自己。
|
|
if targetID == currentID {
|
|
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
|
|
return
|
|
}
|
|
// 不能删除最后一位管理员。
|
|
if user.Role == models.RoleAdmin {
|
|
var adminCount int64
|
|
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
|
if adminCount <= 1 {
|
|
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
|
|
return
|
|
}
|
|
}
|
|
|
|
db.Delete(&user)
|
|
APIOK(c, "/admin/users?saved=1&msg=deleted", nil)
|
|
}
|
|
}
|