feat: add role-based access control and user article management
- Add AdminRequired middleware to all /admin routes for proper authorization - Restrict admin panel, comments, users, settings to admin role only - Create separate /my/articles routes for regular users to manage their own articles - Add MyArticlesPage handler for users to view/edit/delete their own content - Update navigation menus with role-based visibility - Admin dropdown shows only 'Admin Panel' link, other features in dashboard - Regular users see 'My Articles' link in profile dropdown - Add i18n translations for 'my_articles' and 'comment_manage' keys - Fix permission boundary issues where author role could access admin settings Security improvements: - Platform settings now require admin role - Comment moderation requires admin role - User management requires admin role - Regular users can only manage their own articles Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
@@ -368,6 +382,7 @@ var translations = map[Lang]map[string]string{
|
||||
// 个人中心下拉菜单 & 页面
|
||||
"profile": "个人信息",
|
||||
"admin_panel": "后台管理",
|
||||
"my_articles": "我的文章",
|
||||
"profile_title": "编辑个人信息",
|
||||
"profile_avatar": "头像",
|
||||
"profile_change_avatar": "更换头像",
|
||||
@@ -398,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": "创建文章失败。",
|
||||
@@ -431,6 +457,7 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// 文章管理
|
||||
"article_list_title": "文章管理",
|
||||
"my_articles_title": "我的文章",
|
||||
"article_edit_title": "编辑文章",
|
||||
"article_manage": "文章管理",
|
||||
"article_edit": "编辑",
|
||||
@@ -537,6 +564,7 @@ var translations = map[Lang]map[string]string{
|
||||
|
||||
// 后台:评论
|
||||
"admin_comments": "评论管理",
|
||||
"comment_manage": "评论管理",
|
||||
"admin_comments_title": "评论管理",
|
||||
"comment_status_pending": "待审核",
|
||||
"comment_status_approved": "已通过",
|
||||
|
||||
@@ -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,10 +68,16 @@ 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 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).
|
||||
@@ -86,9 +92,9 @@ func main() {
|
||||
users.POST("/:id/delete", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes (AJAX uploads / management).
|
||||
// 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))
|
||||
@@ -97,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))
|
||||
@@ -118,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)
|
||||
|
||||
@@ -55,11 +55,15 @@
|
||||
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>
|
||||
{{end}}
|
||||
<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>
|
||||
|
||||
@@ -43,19 +43,14 @@
|
||||
<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>
|
||||
<a href="/admin/users" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "admin_users"}}
|
||||
</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