diff --git a/cmd/server/main.go b/cmd/server/main.go index 374c9a5..c571ebd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -63,6 +63,7 @@ func main() { r.Static("/admin/static", "./templates/admin/static") r.Static("/shared/static", "./templates/shared/static") r.Static("/uploads/avatars", "./storage/uploads/avatars") + r.Static("/uploads/posts", "./storage/uploads/posts") // 站点设置管理器(DB 持久化 + 内存缓存) siteSettings, err := config.NewSiteSettings(db) diff --git a/internal/controller/admin/admin_post_controller.go b/internal/controller/admin/admin_post_controller.go index 22bf7e8..64ae5f9 100644 --- a/internal/controller/admin/admin_post_controller.go +++ b/internal/controller/admin/admin_post_controller.go @@ -49,16 +49,17 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) { if nextPage > totalPages { nextPage = totalPages } c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{ - "Title": "内容管理", - "Posts": posts, - "Total": total, - "Page": p.Page, - "TotalPages": totalPages, - "PrevPage": prevPage, - "NextPage": nextPage, - "Keyword": keyword, - "Status": status, - "ExtraCSS": "/admin/static/css/posts.css", + "Title": "内容管理", + "Posts": posts, + "Total": total, + "Page": p.Page, + "TotalPages": totalPages, + "PrevPage": prevPage, + "NextPage": nextPage, + "Keyword": keyword, + "Status": status, + "StatusNames": model.PostStatusDisplayNames, + "ExtraCSS": "/admin/static/css/posts.css", })) } diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index 1fbd409..21e4f78 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -1,9 +1,15 @@ package controller import ( + "fmt" "html/template" + "mime/multipart" "net/http" + "os" + "path/filepath" "strconv" + "strings" + "time" "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" @@ -107,6 +113,7 @@ func (ctrl *PostController) ShowPage(c *gin.Context) { "Post": post, "PostBodyHTML": template.HTML(post.BodyHTML), "UID": uid, + "StatusNames": model.PostStatusDisplayNames, "ExtraCSS": "/static/css/posts.css", })) } @@ -319,3 +326,83 @@ func (ctrl *PostController) PreviewAPI(c *gin.Context) { common.Ok(c, gin.H{"html": html}) } + +// allowedImageExt 允许的图片扩展名 +var allowedImageExt = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, +} + +// UploadImage 上传帖子图片(需登录,multipart/form-data) +func (ctrl *PostController) UploadImage(c *gin.Context) { + uidObj, _ := c.Get("uid") + uid, ok := uidObj.(uint) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + common.Error(c, http.StatusBadRequest, "请选择文件") + return + } + defer file.Close() + + // 检查扩展名 + ext := strings.ToLower(filepath.Ext(header.Filename)) + if !allowedImageExt[ext] { + common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式") + return + } + + // 限制 5MB + maxSize := int64(5 << 20) + if header.Size > maxSize { + common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB") + return + } + + // 存储路径 + storageDir := "storage/uploads/posts" + if err := os.MkdirAll(storageDir, 0755); err != nil { + common.Error(c, http.StatusInternalServerError, "存储初始化失败") + return + } + + // 唯一文件名:uid_timestamp.ext + ts := time.Now().UnixMilli() + filename := fmt.Sprintf("%d_%d%s", uid, ts, ext) + savePath := filepath.Join(storageDir, filename) + + if err := saveUploadedFile(file, savePath); err != nil { + common.Error(c, http.StatusInternalServerError, "保存图片失败") + return + } + + url := "/uploads/posts/" + filename + common.Ok(c, gin.H{"url": url}) +} + +// saveUploadedFile 将 multipart.File 写入目标路径 +func saveUploadedFile(file multipart.File, dst string) error { + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + // 限制读取 5MB 防止内存放大 + buf := make([]byte, 32*1024) + for { + n, err := file.Read(buf) + if n > 0 { + if _, writeErr := out.Write(buf[:n]); writeErr != nil { + return writeErr + } + } + if err != nil { + break + } + } + return nil +} diff --git a/internal/model/post.go b/internal/model/post.go index 7c0de38..8960a72 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -21,7 +21,9 @@ type Post struct { UpdatedAt time.Time `json:"updated_at"` // 非数据库字段(联表查询填充) - AuthorName string `gorm:"-:all" json:"author_name,omitempty"` + // gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许 + // 从 JOIN 查询的 users.username AS author_name 别名中读取 + AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"` } // 帖子状态常量 diff --git a/internal/router/api.go b/internal/router/api.go index 9fa5f90..08a75bb 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -57,5 +57,6 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { postsAPI.DELETE("/:id", d.postCtrl.Delete) postsAPI.POST("/:id/submit", d.postCtrl.Submit) postsAPI.POST("/preview", d.postCtrl.PreviewAPI) + postsAPI.POST("/upload-image", d.postCtrl.UploadImage) } } diff --git a/internal/service/post_service.go b/internal/service/post_service.go index fc0929f..13c6bb9 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -13,8 +13,12 @@ import ( ) // ucgPolicy 对 Goldmark 输出的 HTML 做安全消毒 -// 移除 script / iframe / javscript: / data: 等危险内容 -var ucgPolicy = bluemonday.UGCPolicy() +// UGC 默认策略会剥离 code/pre 的 class 属性,需要显式放行以保留 language-xxx +var ucgPolicy = func() *bluemonday.Policy { + p := bluemonday.UGCPolicy() + p.AllowAttrs("class").OnElements("code", "pre") + return p +}() // postStore 帖子服务所需的最小仓储接口(ISP) type postStore interface { diff --git a/templates/MetaLab-2026/html/posts/index.html b/templates/MetaLab-2026/html/posts/index.html index 6842b66..ffe1c5d 100644 --- a/templates/MetaLab-2026/html/posts/index.html +++ b/templates/MetaLab-2026/html/posts/index.html @@ -29,7 +29,7 @@ {{.Title}}
{{printf "%.200s" .Body}}
diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html index 554a0bf..cffc210 100644 --- a/templates/MetaLab-2026/html/posts/new.html +++ b/templates/MetaLab-2026/html/posts/new.html @@ -77,6 +77,9 @@ + + {{/* 隐藏的图片上传控件 */}} + {{template "layout/footer.html" .}} diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index 557184b..885200a 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -8,10 +8,10 @@