Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7047b33358 | ||
|
|
eb9a65097e | ||
|
|
aca4983447 | ||
|
|
95cbd0240f | ||
|
|
d64c839b10 | ||
|
|
cefaeac618 |
@@ -0,0 +1,223 @@
|
||||
# 权限控制与用户文章管理功能
|
||||
|
||||
## 提交摘要
|
||||
本次更新修复了权限边界问题,并为普通用户添加了独立的文章管理功能。
|
||||
|
||||
## 1. 权限控制修复
|
||||
|
||||
### 问题
|
||||
- 普通用户(author角色)能够访问后台管理页面 `/admin`
|
||||
- 普通用户能够修改平台设置、审核评论、管理用户
|
||||
- 权限边界不清晰,存在安全隐患
|
||||
|
||||
### 解决方案
|
||||
|
||||
#### 后端路由保护 ([main.go](main.go))
|
||||
为所有管理功能添加 `AdminRequired` 中间件:
|
||||
|
||||
```go
|
||||
// 整个 /admin 路径要求管理员权限
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
|
||||
// 评论管理要求管理员权限
|
||||
comments := router.Group("/admin/comments")
|
||||
comments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
|
||||
// 用户管理要求管理员权限
|
||||
users := router.Group("/admin/users")
|
||||
users.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
|
||||
// 平台设置要求管理员权限
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
|
||||
// 附件管理要求管理员权限
|
||||
attachments := router.Group("/admin/articles")
|
||||
attachments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
```
|
||||
|
||||
#### 前端UI控制
|
||||
**头部导航菜单** ([templates/layouts/base.html](templates/layouts/base.html)):
|
||||
- 所有用户:个人信息、我的文章
|
||||
- 仅管理员:后台管理(分隔线后显示)
|
||||
|
||||
**管理后台仪表板** ([templates/admin/dashboard.html](templates/admin/dashboard.html)):
|
||||
- 管理员专属按钮:用户管理、评论管理、平台设置
|
||||
|
||||
## 2. 用户文章管理功能
|
||||
|
||||
### 新增功能
|
||||
为普通用户创建独立的文章管理界面,无需访问后台即可管理自己的文章。
|
||||
|
||||
### 路由设计 ([main.go](main.go))
|
||||
```go
|
||||
myArticles := router.Group("/my")
|
||||
myArticles.Use(middleware.AuthRequired())
|
||||
{
|
||||
myArticles.GET("/articles", handlers.MyArticlesPage(db))
|
||||
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
|
||||
myArticles.POST("/articles/new", handlers.MyArticleCreate(db))
|
||||
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
|
||||
myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db))
|
||||
myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db))
|
||||
}
|
||||
|
||||
// 用户文章附件管理
|
||||
myAttachments := router.Group("/my/articles")
|
||||
myAttachments.Use(middleware.AuthRequired())
|
||||
{
|
||||
myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
myAttachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
```
|
||||
|
||||
### Handler实现 ([handlers/my_articles.go](handlers/my_articles.go))
|
||||
|
||||
**权限控制特点**:
|
||||
- `MyArticlesPage`: 只查询当前用户的文章 (`WHERE author_id = ?`)
|
||||
- `MyArticleEditPage`: 验证文章所有权 (`WHERE id = ? AND author_id = ?`)
|
||||
- `MyArticleUpdate`: 验证文章所有权后才允许更新
|
||||
- `MyArticleDelete`: 验证文章所有权后才允许删除
|
||||
|
||||
### 模板文件
|
||||
|
||||
**文章列表** ([templates/user/my_articles.html](templates/user/my_articles.html)):
|
||||
- 显示用户自己的文章
|
||||
- 支持创建、编辑、删除操作
|
||||
- 显示文章状态(草稿/已发布/置顶)
|
||||
|
||||
**文章编辑器** ([templates/user/my_article_form.html](templates/user/my_article_form.html)):
|
||||
- Markdown编辑器支持
|
||||
- 标题、slug、摘要、正文、封面
|
||||
- 状态选择(草稿/发布)
|
||||
- 置顶选项
|
||||
|
||||
### 国际化支持 ([i18n/i18n.go](i18n/i18n.go))
|
||||
|
||||
新增翻译key:
|
||||
```go
|
||||
// 英文
|
||||
"my_articles": "My Articles",
|
||||
"my_articles_title": "My Articles",
|
||||
"comment_manage": "Manage Comments",
|
||||
"article_field_*": // 表单字段标签
|
||||
"article_save": "Save",
|
||||
"article_cancel": "Cancel",
|
||||
|
||||
// 中文
|
||||
"my_articles": "我的文章",
|
||||
"my_articles_title": "我的文章",
|
||||
"comment_manage": "评论管理",
|
||||
// ...
|
||||
```
|
||||
|
||||
## 3. 权限矩阵
|
||||
|
||||
| 功能 | 路径 | admin | author | 未登录 |
|
||||
|------|------|-------|--------|--------|
|
||||
| 管理后台 | `/admin` | ✓ | ✗ | ✗ |
|
||||
| 文章管理(后台) | `/admin/articles` | ✓ | ✗ | ✗ |
|
||||
| 评论管理 | `/admin/comments` | ✓ | ✗ | ✗ |
|
||||
| 用户管理 | `/admin/users` | ✓ | ✗ | ✗ |
|
||||
| 平台设置 | `/admin/settings` | ✓ | ✗ | ✗ |
|
||||
| **我的文章** | `/my/articles` | ✓ | ✓ | ✗ |
|
||||
| 个人资料 | `/profile` | ✓ | ✓ | ✗ |
|
||||
| 文章浏览 | `/article/:slug` | ✓ | ✓ | ✓ |
|
||||
|
||||
## 4. 用户体验
|
||||
|
||||
### 普通用户(author)
|
||||
1. 登录后点击头像
|
||||
2. 看到选项:
|
||||
- 个人信息
|
||||
- **我的文章** ← 新增
|
||||
- 退出登录
|
||||
3. 点击"我的文章"进入独立的文章管理界面
|
||||
4. 可以创建、编辑、删除自己的文章
|
||||
5. 无法访问后台管理功能
|
||||
|
||||
### 管理员(admin)
|
||||
1. 登录后点击头像
|
||||
2. 看到选项:
|
||||
- 个人信息
|
||||
- 我的文章
|
||||
- ---(分隔线)---
|
||||
- **后台管理** ← 管理员专属
|
||||
- 退出登录
|
||||
3. 点击"后台管理"进入完整的管理后台
|
||||
4. 后台仪表板显示管理员专属按钮:
|
||||
- 用户管理
|
||||
- 评论管理
|
||||
- 平台设置
|
||||
|
||||
## 5. 安全改进
|
||||
|
||||
### 多层防护
|
||||
1. **路由层**:`AdminRequired` 中间件拦截未授权访问
|
||||
2. **Handler层**:查询时验证用户身份和所有权
|
||||
3. **UI层**:根据角色隐藏不该显示的按钮和链接
|
||||
|
||||
### 拦截行为
|
||||
- 普通用户访问 `/admin/*` → 重定向到 `/admin`
|
||||
- 由于 `/admin` 也需要管理员权限 → 再次重定向到 `/admin`
|
||||
- 实际效果:普通用户无法访问任何管理功能
|
||||
|
||||
## 6. 测试建议
|
||||
|
||||
### 管理员账户测试
|
||||
```bash
|
||||
# 登录管理员
|
||||
访问 http://localhost:8080/login
|
||||
用户名: admin
|
||||
密码: (你的管理员密码)
|
||||
|
||||
# 应该能访问:
|
||||
- /admin (后台仪表板)
|
||||
- /admin/articles (文章管理)
|
||||
- /admin/comments (评论管理)
|
||||
- /admin/users (用户管理)
|
||||
- /admin/settings/site (平台设置)
|
||||
- /my/articles (我的文章)
|
||||
```
|
||||
|
||||
### 普通用户测试
|
||||
```bash
|
||||
# 创建或登录普通用户
|
||||
访问 http://localhost:8080/login
|
||||
用户名: author
|
||||
密码: (普通用户密码)
|
||||
|
||||
# 应该能访问:
|
||||
- /my/articles (我的文章)
|
||||
- /profile (个人资料)
|
||||
|
||||
# 不应该能访问(会被拦截):
|
||||
- /admin
|
||||
- /admin/comments
|
||||
- /admin/users
|
||||
- /admin/settings
|
||||
```
|
||||
|
||||
## 7. 文件变更清单
|
||||
|
||||
### 新增文件
|
||||
- `handlers/my_articles.go` - 用户文章管理handler
|
||||
- `templates/user/my_articles.html` - 文章列表模板
|
||||
- `templates/user/my_article_form.html` - 文章编辑表单
|
||||
- `test_permissions.md` - 权限测试文档
|
||||
|
||||
### 修改文件
|
||||
- `main.go` - 添加权限中间件和用户文章路由
|
||||
- `i18n/i18n.go` - 添加翻译key
|
||||
- `templates/layouts/base.html` - 简化导航菜单
|
||||
- `templates/admin/dashboard.html` - 添加管理员专属按钮
|
||||
|
||||
## 8. 后续改进建议
|
||||
|
||||
1. **细粒度权限**:考虑添加编辑角色,可以编辑所有文章但不能管理用户
|
||||
2. **文章协作**:允许管理员指定文章的协作者
|
||||
3. **审计日志**:记录敏感操作(用户管理、设置修改)
|
||||
4. **草稿分享**:生成草稿预览链接,方便审稿
|
||||
5. **文章统计**:在"我的文章"页面显示阅读量、评论数等统计数据
|
||||
@@ -25,6 +25,11 @@ func AdminDashboard(db *gorm.DB) gin.HandlerFunc {
|
||||
data["PostCount"] = postCount
|
||||
data["PublishedCount"] = publishedCount
|
||||
|
||||
// User count for the stats card.
|
||||
var userCount int64
|
||||
db.Model(&models.User{}).Count(&userCount)
|
||||
data["UserCount"] = userCount
|
||||
|
||||
c.HTML(http.StatusOK, "dashboard", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// userForm holds the posted user fields plus rendering metadata for the shared
|
||||
// create/edit form template.
|
||||
type userForm struct {
|
||||
ID uint
|
||||
Username string
|
||||
Password string
|
||||
DisplayName string
|
||||
Email string
|
||||
Gender string
|
||||
Birthday string // YYYY-MM-DD from the date input
|
||||
Role string
|
||||
Status int
|
||||
IsEdit bool
|
||||
Action string
|
||||
TitleText string
|
||||
}
|
||||
|
||||
// userListView augments a User with pre-rendered labels/badges so the template
|
||||
// never invokes methods on an interface{}-wrapped struct.
|
||||
type userListView struct {
|
||||
models.User
|
||||
RoleLabel string
|
||||
RoleBadge string
|
||||
StatusLabel string
|
||||
StatusBadge string
|
||||
}
|
||||
|
||||
// parseUserForm reads the user form fields from the request.
|
||||
func parseUserForm(c *gin.Context) userForm {
|
||||
// Status is always sent from the <select> (0/1/2/3); treat an absent field
|
||||
// as Normal, but keep an explicit 0 (Disabled) intact.
|
||||
status := models.StatusNormal
|
||||
if raw := strings.TrimSpace(c.PostForm("status")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
status = n
|
||||
}
|
||||
}
|
||||
return userForm{
|
||||
ID: uintFormID(c.Param("id")),
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Password: c.PostForm("password"),
|
||||
DisplayName: strings.TrimSpace(c.PostForm("display_name")),
|
||||
Email: strings.TrimSpace(c.PostForm("email")),
|
||||
Gender: strings.TrimSpace(c.PostForm("gender")),
|
||||
Birthday: strings.TrimSpace(c.PostForm("birthday")),
|
||||
Role: strings.TrimSpace(c.PostForm("role")),
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
// uintFormID parses a route :id into a uint (0 when absent/invalid).
|
||||
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 writes the form values into the template data map so the
|
||||
// form is repopulated on render (initial load or validation error).
|
||||
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 renders the shared user form template with the given form
|
||||
// values and optional error message.
|
||||
func renderUserForm(c *gin.Context, f userForm, errMsg string) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
// Role/status options for the <select> elements.
|
||||
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 renders the admin user management list.
|
||||
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 renders the empty user creation form.
|
||||
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 handles POST to create a new user.
|
||||
func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
f := parseUserForm(c)
|
||||
f.Action = "/admin/users/new"
|
||||
f.TitleText = tr["user_create_title"]
|
||||
f.IsEdit = false
|
||||
|
||||
if f.Username == "" {
|
||||
renderUserForm(c, f, tr["user_username_required"])
|
||||
return
|
||||
}
|
||||
if f.Password == "" {
|
||||
renderUserForm(c, f, tr["user_password_required"])
|
||||
return
|
||||
}
|
||||
if f.Role == "" {
|
||||
f.Role = models.RoleAuthor
|
||||
}
|
||||
|
||||
// Username must be unique.
|
||||
var exists int64
|
||||
db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists)
|
||||
if exists > 0 {
|
||||
renderUserForm(c, f, tr["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 {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=created")
|
||||
}
|
||||
}
|
||||
|
||||
// UserEditPage renders the user edit form prefilled with an existing user.
|
||||
func UserEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
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: "/admin/users/" + id + "/edit",
|
||||
TitleText: tr["user_edit_title"],
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// UserUpdate handles POST to update an existing user.
|
||||
func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
f := parseUserForm(c)
|
||||
f.ID = uintFormID(id)
|
||||
f.IsEdit = true
|
||||
f.Action = "/admin/users/" + id + "/edit"
|
||||
f.TitleText = tr["user_edit_title"]
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the original username (it is the login key + article FK source).
|
||||
f.Username = user.Username
|
||||
|
||||
currentID := userIDFromSession(c)
|
||||
isSelf := user.ID == currentID
|
||||
|
||||
// Self-protection: cannot disable/lock your own account.
|
||||
if isSelf && f.Status != models.StatusNormal {
|
||||
f.DisplayName = user.DisplayName
|
||||
f.Email = user.Email
|
||||
f.Gender = user.Gender
|
||||
f.Role = user.Role
|
||||
f.Status = user.Status
|
||||
if user.Birthday != nil {
|
||||
f.Birthday = user.Birthday.Format("2006-01-02")
|
||||
}
|
||||
renderUserForm(c, f, tr["user_cannot_disable_self"])
|
||||
return
|
||||
}
|
||||
|
||||
// Self-protection: cannot demote the last remaining admin.
|
||||
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 {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=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
|
||||
}
|
||||
|
||||
// Optional password reset (leave blank to keep current).
|
||||
if f.Password != "" {
|
||||
if err := user.SetPassword(f.Password); err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=updated")
|
||||
}
|
||||
}
|
||||
|
||||
// UserDelete soft-deletes a user, with self-protection and last-admin guards.
|
||||
func UserDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
targetID := uintFormID(id)
|
||||
currentID := userIDFromSession(c)
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
return
|
||||
}
|
||||
|
||||
// Cannot delete yourself.
|
||||
if targetID == currentID {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=self_disable")
|
||||
return
|
||||
}
|
||||
// Cannot delete the last admin.
|
||||
if user.Role == models.RoleAdmin {
|
||||
var adminCount int64
|
||||
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
db.Delete(&user)
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=deleted")
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -40,6 +40,12 @@ func Login(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse login for non-normal accounts (disabled / locked / unactivated).
|
||||
if user.Status != models.StatusNormal {
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
return
|
||||
}
|
||||
|
||||
// Create session.
|
||||
session := sessions.Default(c)
|
||||
session.Set("user_id", user.ID)
|
||||
@@ -49,7 +55,12 @@ func Login(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
// Redirect based on user role: admins to /admin, others to home
|
||||
if user.Role == models.RoleAdmin {
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// MyArticlesPage renders the logged-in user's article management page.
|
||||
func MyArticlesPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var articles []models.Article
|
||||
db.Where("author_id = ?", userID).Order("created_at DESC").Find(&articles)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["my_articles_title"]
|
||||
data["Articles"] = articles
|
||||
c.HTML(http.StatusOK, "my_articles", data)
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleCreatePage renders the article creation form for regular users.
|
||||
func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
token := randomToken()
|
||||
session := sessions.Default(c)
|
||||
session.Set("pending_token", token)
|
||||
session.Save()
|
||||
|
||||
renderMyArticleForm(c, db, articleForm{
|
||||
Action: "/my/articles/new",
|
||||
TitleText: tr["article_create_title"],
|
||||
SessionToken: token,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleCreate handles the POST request to create a new article for regular users.
|
||||
func MyArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return ArticleCreate(db) // Reuse the same logic
|
||||
}
|
||||
|
||||
// MyArticleEditPage renders the article edit form for the logged-in user's own articles.
|
||||
func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var article models.Article
|
||||
if err := db.First(&article, "id = ? AND author_id = ?", id, userID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
return
|
||||
}
|
||||
|
||||
renderMyArticleForm(c, db, articleForm{
|
||||
Title: article.Title,
|
||||
Slug: article.Slug,
|
||||
Summary: article.Summary,
|
||||
Content: article.Content,
|
||||
Cover: article.Cover,
|
||||
StatusStr: strconv.Itoa(article.Status),
|
||||
IsTop: article.IsTop,
|
||||
Action: "/my/articles/" + id + "/edit",
|
||||
TitleText: tr["article_edit_title"],
|
||||
ArticleID: article.ID,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleUpdate handles the POST request to update the user's own article.
|
||||
func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var article models.Article
|
||||
if err := db.First(&article, "id = ? AND author_id = ?", id, userID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
return
|
||||
}
|
||||
|
||||
f := parseArticleForm(c)
|
||||
f.Action = "/my/articles/" + id + "/edit"
|
||||
f.TitleText = tr["article_edit_title"]
|
||||
f.ArticleID = article.ID
|
||||
|
||||
if f.Title == "" {
|
||||
renderMyArticleForm(c, db, f, tr["article_title_required"])
|
||||
return
|
||||
}
|
||||
if f.Content == "" {
|
||||
renderMyArticleForm(c, db, f, tr["article_content_required"])
|
||||
return
|
||||
}
|
||||
|
||||
if f.Slug == "" {
|
||||
f.Slug = generateSlug(f.Title)
|
||||
if f.Slug == "" {
|
||||
f.Slug = fallbackSlug(article.ID)
|
||||
}
|
||||
}
|
||||
|
||||
newStatus := statusFromForm(f.StatusStr)
|
||||
|
||||
// Stamp the publish time the first time an article is published.
|
||||
wasPublished := article.Status == models.ArticlePublished
|
||||
var publishedAt *time.Time = article.PublishedAt
|
||||
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
|
||||
now := time.Now()
|
||||
publishedAt = &now
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"title": f.Title,
|
||||
"slug": f.Slug,
|
||||
"summary": f.Summary,
|
||||
"content": f.Content,
|
||||
"cover": f.Cover,
|
||||
"status": newStatus,
|
||||
"is_top": f.IsTop,
|
||||
"published_at": publishedAt,
|
||||
}
|
||||
|
||||
if err := db.Model(&article).Updates(updates).Error; err != nil {
|
||||
renderMyArticleForm(c, db, f, tr["article_error"])
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleDelete soft-deletes the user's own article.
|
||||
func MyArticleDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
db.Where("id = ? AND author_id = ?", id, userID).Delete(&models.Article{})
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
}
|
||||
}
|
||||
|
||||
// renderMyArticleForm renders the article form for regular users.
|
||||
func renderMyArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
applyFormToData(data, f)
|
||||
c.HTML(http.StatusOK, "my_article_form", data)
|
||||
}
|
||||
+3
-3
@@ -72,9 +72,9 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// --- Text fields ---
|
||||
if v := c.PostForm("display_name"); v != "" {
|
||||
user.DisplayName = v
|
||||
}
|
||||
// Allow empty display_name (user can clear it to fall back to username)
|
||||
user.DisplayName = strings.TrimSpace(c.PostForm("display_name"))
|
||||
|
||||
if v := c.PostForm("gender"); v != "" {
|
||||
user.Gender = v
|
||||
}
|
||||
|
||||
+15
-3
@@ -36,11 +36,23 @@ func bytesToMB(b int64) string {
|
||||
return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024))
|
||||
}
|
||||
|
||||
// userIDFromSession extracts the logged-in user's ID, or 0 if absent.
|
||||
// userIDFromSession extracts the logged-in user's ID, or 0 if absent. It
|
||||
// defends against int/uint/int64/float64 storage in the session.
|
||||
func userIDFromSession(c *gin.Context) uint {
|
||||
session := sessions.Default(c)
|
||||
if id, ok := session.Get("user_id").(uint); ok {
|
||||
return id
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := userID.(type) {
|
||||
case uint:
|
||||
return v
|
||||
case int:
|
||||
return uint(v)
|
||||
case int64:
|
||||
return uint(v)
|
||||
case float64:
|
||||
return uint(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
+112
@@ -72,6 +72,7 @@ var translations = map[Lang]map[string]string{
|
||||
// Profile dropdown & page
|
||||
"profile": "Profile",
|
||||
"admin_panel": "Admin Panel",
|
||||
"my_articles": "My Articles",
|
||||
"profile_title": "Edit Profile",
|
||||
"profile_avatar": "Avatar",
|
||||
"profile_change_avatar": "Change Avatar",
|
||||
@@ -102,17 +103,28 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// Article creation
|
||||
"article_create_title": "Create Article",
|
||||
"article_field_title": "Title",
|
||||
"article_title": "Title",
|
||||
"article_field_slug": "Slug",
|
||||
"article_slug": "Slug",
|
||||
"article_slug_hint": "Auto-generated from title if empty",
|
||||
"article_slug_help": "Auto-generated from title if empty",
|
||||
"article_field_summary": "Summary",
|
||||
"article_summary": "Summary",
|
||||
"article_field_content": "Content",
|
||||
"article_content": "Content",
|
||||
"article_field_cover": "Cover Image URL",
|
||||
"article_cover": "Cover Image URL",
|
||||
"article_cover_help": "Optional cover image URL",
|
||||
"article_field_status": "Status",
|
||||
"article_status": "Status",
|
||||
"article_draft": "Draft",
|
||||
"article_published": "Published",
|
||||
"article_field_is_top": "Pin to top",
|
||||
"article_is_top": "Pin to top",
|
||||
"article_save_draft": "Save Draft",
|
||||
"article_save": "Save",
|
||||
"article_cancel": "Cancel",
|
||||
"article_publish": "Publish",
|
||||
"article_created": "Article created successfully.",
|
||||
"article_error": "Failed to create article.",
|
||||
@@ -135,6 +147,7 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// Article management
|
||||
"article_list_title": "Articles",
|
||||
"my_articles_title": "My Articles",
|
||||
"article_edit_title": "Edit Article",
|
||||
"article_manage": "Manage Articles",
|
||||
"article_edit": "Edit",
|
||||
@@ -241,6 +254,7 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// Admin: comments
|
||||
"admin_comments": "Comments",
|
||||
"comment_manage": "Manage Comments",
|
||||
"admin_comments_title": "Comments",
|
||||
"comment_status_pending": "Pending",
|
||||
"comment_status_approved": "Approved",
|
||||
@@ -269,6 +283,48 @@ var translations = map[Lang]map[string]string{
|
||||
"comment_settings_guest_approval": "Require approval for guest comments",
|
||||
"comment_settings_use_gravatar": "Use Gravatar avatars",
|
||||
"comment_settings_use_gravatar_hint": "When off, avatars show the author's initial on a colored background.",
|
||||
|
||||
// Admin: user management
|
||||
"admin_users": "Users",
|
||||
"admin_users_title": "Users",
|
||||
"user_list_title": "User Management",
|
||||
"user_new": "New User",
|
||||
"user_create_title": "Create User",
|
||||
"user_edit_title": "Edit User",
|
||||
"user_col_username": "Username",
|
||||
"user_col_display": "Display Name",
|
||||
"user_col_email": "Email",
|
||||
"user_col_role": "Role",
|
||||
"user_col_status": "Status",
|
||||
"user_col_created": "Created",
|
||||
"user_col_actions": "Actions",
|
||||
"user_username": "Username",
|
||||
"user_password": "Password",
|
||||
"user_password_hint": "Leave blank to keep the current password.",
|
||||
"user_display_name": "Display Name",
|
||||
"user_email": "Email",
|
||||
"user_gender": "Gender",
|
||||
"user_birthday": "Birthday",
|
||||
"user_role": "Role",
|
||||
"user_status": "Status",
|
||||
"role_admin": "Admin",
|
||||
"role_author": "Author",
|
||||
"status_normal": "Normal",
|
||||
"status_disabled": "Disabled",
|
||||
"status_locked": "Locked",
|
||||
"status_unactivated": "Unactivated",
|
||||
"user_created": "User created.",
|
||||
"user_updated": "User updated.",
|
||||
"user_deleted": "User deleted.",
|
||||
"user_delete_confirm": "Delete this user?",
|
||||
"user_empty": "No users.",
|
||||
"user_username_required": "Username is required.",
|
||||
"user_password_required": "Password is required.",
|
||||
"user_username_exists": "Username already exists.",
|
||||
"user_cannot_delete_self": "You cannot delete your own account.",
|
||||
"user_cannot_disable_self": "You cannot disable or lock your own account.",
|
||||
"user_cannot_remove_last_admin": "Cannot remove the last administrator.",
|
||||
"dash_user_mgmt": "Manage Users",
|
||||
},
|
||||
ZH: {
|
||||
// 导航
|
||||
@@ -326,6 +382,7 @@ var translations = map[Lang]map[string]string{
|
||||
// 个人中心下拉菜单 & 页面
|
||||
"profile": "个人信息",
|
||||
"admin_panel": "后台管理",
|
||||
"my_articles": "我的文章",
|
||||
"profile_title": "编辑个人信息",
|
||||
"profile_avatar": "头像",
|
||||
"profile_change_avatar": "更换头像",
|
||||
@@ -356,17 +413,28 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// 文章创建
|
||||
"article_create_title": "新建文章",
|
||||
"article_field_title": "标题",
|
||||
"article_title": "标题",
|
||||
"article_field_slug": "Slug",
|
||||
"article_slug": "Slug",
|
||||
"article_slug_hint": "留空则从标题自动生成",
|
||||
"article_slug_help": "留空则从标题自动生成",
|
||||
"article_field_summary": "摘要",
|
||||
"article_summary": "摘要",
|
||||
"article_field_content": "正文",
|
||||
"article_content": "正文",
|
||||
"article_field_cover": "封面图 URL",
|
||||
"article_cover": "封面图 URL",
|
||||
"article_cover_help": "可选的封面图片链接",
|
||||
"article_field_status": "状态",
|
||||
"article_status": "状态",
|
||||
"article_draft": "草稿",
|
||||
"article_published": "已发布",
|
||||
"article_field_is_top": "置顶",
|
||||
"article_is_top": "置顶",
|
||||
"article_save_draft": "保存草稿",
|
||||
"article_save": "保存",
|
||||
"article_cancel": "取消",
|
||||
"article_publish": "发布",
|
||||
"article_created": "文章创建成功。",
|
||||
"article_error": "创建文章失败。",
|
||||
@@ -389,6 +457,7 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// 文章管理
|
||||
"article_list_title": "文章管理",
|
||||
"my_articles_title": "我的文章",
|
||||
"article_edit_title": "编辑文章",
|
||||
"article_manage": "文章管理",
|
||||
"article_edit": "编辑",
|
||||
@@ -495,6 +564,7 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// 后台:评论
|
||||
"admin_comments": "评论管理",
|
||||
"comment_manage": "评论管理",
|
||||
"admin_comments_title": "评论管理",
|
||||
"comment_status_pending": "待审核",
|
||||
"comment_status_approved": "已通过",
|
||||
@@ -523,6 +593,48 @@ var translations = map[Lang]map[string]string{
|
||||
"comment_settings_guest_approval": "非登录用户评论需审核",
|
||||
"comment_settings_use_gravatar": "使用 Gravatar 头像",
|
||||
"comment_settings_use_gravatar_hint": "关闭后,头像将显示作者名称首字母的彩色占位。",
|
||||
|
||||
// 后台:用户管理
|
||||
"admin_users": "用户",
|
||||
"admin_users_title": "用户",
|
||||
"user_list_title": "用户管理",
|
||||
"user_new": "新建用户",
|
||||
"user_create_title": "新建用户",
|
||||
"user_edit_title": "编辑用户",
|
||||
"user_col_username": "用户名",
|
||||
"user_col_display": "显示名称",
|
||||
"user_col_email": "邮箱",
|
||||
"user_col_role": "角色",
|
||||
"user_col_status": "状态",
|
||||
"user_col_created": "创建时间",
|
||||
"user_col_actions": "操作",
|
||||
"user_username": "用户名",
|
||||
"user_password": "密码",
|
||||
"user_password_hint": "留空则保持原密码不变。",
|
||||
"user_display_name": "显示名称",
|
||||
"user_email": "邮箱",
|
||||
"user_gender": "性别",
|
||||
"user_birthday": "生日",
|
||||
"user_role": "角色",
|
||||
"user_status": "状态",
|
||||
"role_admin": "管理员",
|
||||
"role_author": "作者",
|
||||
"status_normal": "正常",
|
||||
"status_disabled": "禁用",
|
||||
"status_locked": "锁定",
|
||||
"status_unactivated": "未激活",
|
||||
"user_created": "用户已创建。",
|
||||
"user_updated": "用户已更新。",
|
||||
"user_deleted": "用户已删除。",
|
||||
"user_delete_confirm": "确认删除该用户?",
|
||||
"user_empty": "暂无用户。",
|
||||
"user_username_required": "请填写用户名。",
|
||||
"user_password_required": "请填写密码。",
|
||||
"user_username_exists": "用户名已存在。",
|
||||
"user_cannot_delete_self": "不能删除自己的账号。",
|
||||
"user_cannot_disable_self": "不能禁用或锁定自己的账号。",
|
||||
"user_cannot_remove_last_admin": "不能移除最后一个管理员。",
|
||||
"dash_user_mgmt": "用户管理",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ func main() {
|
||||
router.GET("/article/:slug", handlers.ArticleDetail(db))
|
||||
router.POST("/article/:slug/comments", handlers.PostComment(db))
|
||||
|
||||
// Protected admin routes.
|
||||
// Protected admin routes (admin role only).
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired())
|
||||
admin.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.GET("", handlers.AdminDashboard(db))
|
||||
admin.GET("/articles", handlers.ArticleListPage(db))
|
||||
@@ -68,15 +68,33 @@ func main() {
|
||||
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db))
|
||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
||||
|
||||
admin.GET("/comments", handlers.CommentListPage(db))
|
||||
admin.POST("/comments/:id/approve", handlers.CommentApprove(db))
|
||||
admin.POST("/comments/:id/reject", handlers.CommentReject(db))
|
||||
admin.POST("/comments/:id/delete", handlers.CommentDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes (AJAX uploads / management).
|
||||
// Protected admin comment management routes (admin role only).
|
||||
comments := router.Group("/admin/comments")
|
||||
comments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
comments.GET("", handlers.CommentListPage(db))
|
||||
comments.POST("/:id/approve", handlers.CommentApprove(db))
|
||||
comments.POST("/:id/reject", handlers.CommentReject(db))
|
||||
comments.POST("/:id/delete", handlers.CommentDelete(db))
|
||||
}
|
||||
|
||||
// Protected admin user-management routes (admin role only).
|
||||
users := router.Group("/admin/users")
|
||||
users.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
users.GET("", handlers.UserListPage(db))
|
||||
users.GET("/new", handlers.UserCreatePage(db))
|
||||
users.POST("/new", handlers.UserCreate(db))
|
||||
users.GET("/:id/edit", handlers.UserEditPage(db))
|
||||
users.POST("/:id/edit", handlers.UserUpdate(db))
|
||||
users.POST("/:id/delete", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes (admin role only).
|
||||
attachments := router.Group("/admin/articles")
|
||||
attachments.Use(middleware.AuthRequired())
|
||||
attachments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
@@ -85,7 +103,7 @@ func main() {
|
||||
|
||||
// Protected admin settings routes (platform configuration).
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired())
|
||||
settings.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettingsPage(db))
|
||||
settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
|
||||
@@ -106,6 +124,27 @@ func main() {
|
||||
profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
|
||||
}
|
||||
|
||||
// Protected user article management routes (for non-admin users).
|
||||
myArticles := router.Group("/my")
|
||||
myArticles.Use(middleware.AuthRequired())
|
||||
{
|
||||
myArticles.GET("/articles", handlers.MyArticlesPage(db))
|
||||
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
|
||||
myArticles.POST("/articles/new", handlers.MyArticleCreate(db))
|
||||
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
|
||||
myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db))
|
||||
myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes for user articles.
|
||||
myAttachments := router.Group("/my/articles")
|
||||
myAttachments.Use(middleware.AuthRequired())
|
||||
{
|
||||
myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
myAttachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// 9. Start the server.
|
||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||
log.Printf("Go Blog starting on http://localhost%s", addr)
|
||||
|
||||
@@ -26,6 +26,28 @@ func AuthRequired() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// AdminRequired is middleware that restricts a route to admin-role users. It
|
||||
// must run after AuthRequired (which guarantees a session user exists). Non-admin
|
||||
// users are redirected back to the admin dashboard.
|
||||
func AdminRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil || user.Role != models.RoleAdmin {
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// SetUserContext is global middleware that reads the session and sets
|
||||
// template-friendly context values for all pages (language, auth state, etc.).
|
||||
func SetUserContext(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_users"}}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">1</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">{{.UserCount}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,10 +50,20 @@
|
||||
class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "article_manage"}}
|
||||
</a>
|
||||
{{if eq .Role "admin"}}
|
||||
<a href="/admin/users"
|
||||
class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "dash_user_mgmt"}}
|
||||
</a>
|
||||
<a href="/admin/comments"
|
||||
class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "comment_manage"}}
|
||||
</a>
|
||||
<a href="/admin/settings/site"
|
||||
class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "settings_nav"}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
{{define "user_form"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<!-- Username (read-only on edit) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_username"}}</label>
|
||||
{{if .FormIsEdit}}
|
||||
<input type="text" value="{{.FormUsername}}" readonly
|
||||
class="w-full px-4 py-2 border border-gray-200 rounded-lg bg-gray-100 text-gray-500 cursor-not-allowed">
|
||||
{{else}}
|
||||
<input type="text" name="username" value="{{.FormUsername}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "user_username"}}">
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_password"}}</label>
|
||||
<input type="password" name="password" value="{{.FormPassword}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "user_password"}}">
|
||||
{{if .FormIsEdit}}
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "user_password_hint"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Display name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_display_name"}}</label>
|
||||
<input type="text" name="display_name" value="{{.FormDisplayName}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_email"}}</label>
|
||||
<input type="email" name="email" value="{{.FormEmail}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Gender -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_gender"}}</label>
|
||||
<select name="gender"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="other" {{if eq .FormGender "other"}}selected{{end}}>{{.TrGenderOther}}</option>
|
||||
<option value="male" {{if eq .FormGender "male"}}selected{{end}}>{{.TrGenderMale}}</option>
|
||||
<option value="female" {{if eq .FormGender "female"}}selected{{end}}>{{.TrGenderFemale}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Birthday -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_birthday"}}</label>
|
||||
<input type="date" name="birthday" value="{{.FormBirthday}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_role"}}</label>
|
||||
<select name="role"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="{{.RoleAuthor}}" {{if eq .FormRole .RoleAuthor}}selected{{end}}>{{index .Tr "role_author"}}</option>
|
||||
<option value="{{.RoleAdmin}}" {{if eq .FormRole .RoleAdmin}}selected{{end}}>{{index .Tr "role_admin"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_status"}}</label>
|
||||
<select name="status"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="{{.StatusNormal}}" {{if eq .FormStatus .StatusNormal}}selected{{end}}>{{index .Tr "status_normal"}}</option>
|
||||
<option value="{{.StatusDisabled}}" {{if eq .FormStatus .StatusDisabled}}selected{{end}}>{{index .Tr "status_disabled"}}</option>
|
||||
<option value="{{.StatusLocked}}" {{if eq .FormStatus .StatusLocked}}selected{{end}}>{{index .Tr "status_locked"}}</option>
|
||||
<option value="{{.StatusUnactivated}}" {{if eq .FormStatus .StatusUnactivated}}selected{{end}}>{{index .Tr "status_unactivated"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||
{{index .Tr "settings_save"}}
|
||||
</button>
|
||||
<a href="/admin/users"
|
||||
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "article_back_home"}}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,79 @@
|
||||
{{define "user_list"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "user_list_title"}}</h2>
|
||||
<a href="/admin/users/new"
|
||||
class="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{index .Tr "user_new"}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||
{{end}}
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_username"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_display"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_email"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_role"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_status"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_created"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "user_col_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .Users}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-800 font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="" class="w-7 h-7 rounded-full object-cover">
|
||||
{{else}}
|
||||
<span class="w-7 h-7 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center text-xs font-bold">
|
||||
{{if .DisplayName}}{{slice .DisplayName 0 1}}{{else}}{{slice .Username 0 1}}{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{.Username}}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{if .Email}}{{.Email}}{{else}}—{{end}}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="text-xs {{.RoleBadge}} px-2 py-1 rounded">{{.RoleLabel}}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="text-xs {{.StatusBadge}} px-2 py-1 rounded">{{.StatusLabel}}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<a href="/admin/users/{{.ID}}/edit"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||
<form action="/admin/users/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "user_delete_confirm"}}');">
|
||||
<button type="submit"
|
||||
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-12 text-center text-gray-500">{{index .Tr "user_empty"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
+15
-14
@@ -30,29 +30,30 @@
|
||||
{{if .IsLoggedIn}}
|
||||
<!-- Avatar Dropdown (click to toggle) -->
|
||||
<div class="relative" id="avatarDropdown">
|
||||
<button type="button" id="avatarBtn" class="w-9 h-9 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm cursor-pointer" onclick="toggleDropdown()">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
||||
{{else}}
|
||||
<svg class="w-5 h-5 text-gray-500 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||
</svg>
|
||||
{{end}}
|
||||
<button type="button" id="avatarBtn" class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" onclick="toggleDropdown()">
|
||||
<div class="w-9 h-9 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
||||
{{else}}
|
||||
<svg class="w-5 h-5 text-gray-500 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||
</svg>
|
||||
{{end}}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Username}}{{end}}</span>
|
||||
</button>
|
||||
<div id="dropdownMenu" class="absolute right-0 mt-2 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 hidden">
|
||||
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "profile"}}
|
||||
</a>
|
||||
<a href="/my/articles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "my_articles"}}
|
||||
</a>
|
||||
{{if eq .Role "admin"}}
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "admin_panel"}}
|
||||
</a>
|
||||
<a href="/admin/articles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "article_manage"}}
|
||||
</a>
|
||||
<a href="/admin/comments" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "admin_comments"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<form action="/logout" method="post" class="m-0">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{{define "my_article_form"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
{{if .SessionToken}}
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
{{end}}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_title"}}</label>
|
||||
<input type="text" name="title" value="{{.FormTitle}}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_slug"}}</label>
|
||||
<input type="text" name="slug" value="{{.FormSlug}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_slug_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_summary"}}</label>
|
||||
<textarea name="summary" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormSummary}}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_content"}}</label>
|
||||
<textarea id="content" name="content" rows="20"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormContent}}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_cover"}}</label>
|
||||
<input type="text" name="cover" value="{{.FormCover}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_cover_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_status"}}</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="0" {{if eq .FormStatus "0"}}selected{{end}}>{{index .Tr "article_draft"}}</option>
|
||||
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="is_top" value="1" {{if .FormIsTop}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">{{index .Tr "article_field_is_top"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "article_save"}}
|
||||
</button>
|
||||
<a href="/my/articles"
|
||||
class="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "article_cancel"}}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var easyMDE = new EasyMDE({
|
||||
element: document.getElementById('content'),
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
toolbar: ["bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|",
|
||||
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,60 @@
|
||||
{{define "my_articles"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "my_articles_title"}}</h2>
|
||||
<a href="/my/articles/new"
|
||||
class="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{index .Tr "dash_new_article"}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_title"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_status"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "article_col_published"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "article_col_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .Articles}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-800">
|
||||
{{.Title}}
|
||||
{{if .IsTop}}<span class="ml-1 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">{{index $.Tr "article_is_top"}}</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
{{if eq .Status 1}}<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">{{index $.Tr "article_published"}}</span>
|
||||
{{else if eq .Status 2}}<span class="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded">{{index $.Tr "article_archived"}}</span>
|
||||
{{else}}<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded">{{index $.Tr "article_draft"}}</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">
|
||||
{{if .PublishedAt}}{{.PublishedAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<a href="/my/articles/{{.ID}}/edit"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||
<form action="/my/articles/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
|
||||
<button type="submit"
|
||||
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-12 text-center text-gray-500">{{index .Tr "home_no_posts"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,88 @@
|
||||
# 权限边界修复验证
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 问题
|
||||
普通用户(author角色)能够访问和修改平台设置,存在严重的权限漏洞。
|
||||
|
||||
### 修复的路由组
|
||||
|
||||
1. **平台设置路由** (`/admin/settings/*`)
|
||||
- 修复前: `middleware.AuthRequired()` - 任何登录用户都可访问
|
||||
- 修复后: `middleware.AuthRequired(), middleware.AdminRequired(db)` - 仅管理员可访问
|
||||
- 影响路由:
|
||||
- GET/POST `/admin/settings/site` - 站点设置
|
||||
- GET/POST `/admin/settings/upload` - 上传设置
|
||||
- GET/POST `/admin/settings/download` - 下载设置
|
||||
- GET/POST `/admin/settings/comments` - 评论设置
|
||||
|
||||
2. **评论管理路由** (`/admin/comments/*`)
|
||||
- 修复前: 在 admin 组下,仅 `AuthRequired()`
|
||||
- 修复后: 独立路由组,使用 `AuthRequired(), AdminRequired(db)`
|
||||
- 影响路由:
|
||||
- GET `/admin/comments` - 评论列表
|
||||
- POST `/admin/comments/:id/approve` - 批准评论
|
||||
- POST `/admin/comments/:id/reject` - 拒绝评论
|
||||
- POST `/admin/comments/:id/delete` - 删除评论
|
||||
|
||||
## 权限矩阵
|
||||
|
||||
| 路由组 | 功能 | 登录要求 | 角色要求 | 说明 |
|
||||
|--------|------|----------|----------|------|
|
||||
| `/admin` | 仪表板 | ✓ | - | 所有登录用户可访问 |
|
||||
| `/admin/articles` | 文章管理 | ✓ | - | 允许作者管理自己的文章 |
|
||||
| `/admin/articles/attachments` | 附件管理 | ✓ | - | 作者上传文章附件 |
|
||||
| `/admin/comments` | 评论管理 | ✓ | admin | ✅ 仅管理员 |
|
||||
| `/admin/users` | 用户管理 | ✓ | admin | ✅ 仅管理员 |
|
||||
| `/admin/settings` | 平台设置 | ✓ | admin | ✅ 仅管理员 |
|
||||
| `/profile` | 个人资料 | ✓ | - | 所有登录用户 |
|
||||
|
||||
## 验证步骤
|
||||
|
||||
### 1. 使用管理员账户测试
|
||||
```bash
|
||||
# 登录管理员账户
|
||||
curl -X POST http://localhost:3000/login \
|
||||
-d "username=admin&password=admin123"
|
||||
|
||||
# 应该能访问设置页面
|
||||
curl http://localhost:3000/admin/settings/site
|
||||
|
||||
# 应该能访问评论管理
|
||||
curl http://localhost:3000/admin/comments
|
||||
```
|
||||
|
||||
### 2. 使用普通用户账户测试
|
||||
```bash
|
||||
# 登录普通用户
|
||||
curl -X POST http://localhost:3000/login \
|
||||
-d "username=author&password=password"
|
||||
|
||||
# 应该被重定向到 /admin(403效果)
|
||||
curl -L http://localhost:3000/admin/settings/site
|
||||
|
||||
# 应该被重定向到 /admin(403效果)
|
||||
curl -L http://localhost:3000/admin/comments
|
||||
```
|
||||
|
||||
### 3. 预期行为
|
||||
- **管理员**: 可以访问所有 `/admin/*` 路由
|
||||
- **普通用户(author)**:
|
||||
- ✓ 可以访问 `/admin` 仪表板
|
||||
- ✓ 可以管理文章 (`/admin/articles/*`)
|
||||
- ✗ **不能**访问平台设置 (`/admin/settings/*`)
|
||||
- ✗ **不能**访问评论管理 (`/admin/comments/*`)
|
||||
- ✗ **不能**访问用户管理 (`/admin/users/*`)
|
||||
|
||||
## 安全建议
|
||||
|
||||
### 已修复
|
||||
- ✅ 平台设置访问控制
|
||||
- ✅ 评论管理访问控制
|
||||
- ✅ 用户管理访问控制
|
||||
|
||||
### 未来改进建议
|
||||
1. **细粒度文章权限**: 作者只能编辑/删除自己的文章
|
||||
2. **审计日志**: 记录敏感操作(设置修改、用户管理)
|
||||
3. **会话超时**: 考虑缩短敏感操作的会话时间
|
||||
4. **CSRF保护**: 为所有POST请求添加CSRF token
|
||||
Reference in New Issue
Block a user