diff --git a/goblog.db b/goblog.db index 9a211df..f6c3ad9 100644 Binary files a/goblog.db and b/goblog.db differ diff --git a/handlers/upload.go b/handlers/upload.go new file mode 100644 index 0000000..37eadbe --- /dev/null +++ b/handlers/upload.go @@ -0,0 +1,217 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + // uploadDir 上传文件保存目录 + uploadDir = "./static/uploads" + // uploadURLPrefix 上传文件对外访问的 URL 前缀 + uploadURLPrefix = "/static/uploads/" + // maxUploadSize 单个文件最大大小(5MB) + maxUploadSize = 5 << 20 +) + +// allowedImageExts 允许上传的图片扩展名 +var allowedImageExts = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".webp": true, +} + +// allowedImageMIMEs 允许上传的图片真实 MIME 类型 +var allowedImageMIMEs = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, +} + +// UploadedFile 上传文件信息 +type UploadedFile struct { + Name string `json:"name"` + URL string `json:"url"` + Size int64 `json:"size"` + ModTime string `json:"mod_time"` +} + +// sanitizeBaseName 清理文件名(去除扩展名和不安全字符) +func sanitizeBaseName(filename string) string { + base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) + base = strings.ReplaceAll(base, " ", "-") + + var b strings.Builder + for _, r := range base { + if r == '-' || r == '_' || + (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + + res := b.String() + if res == "" { + res = "image" + } + if len(res) > 40 { + res = res[:40] + } + return res +} + +// UploadImage 上传图片(校验格式与大小) +func UploadImage(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"}) + return + } + + // 校验文件大小 + if file.Size > maxUploadSize { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("文件大小超过限制(最大 %d MB)", maxUploadSize>>20), + }) + return + } + + // 校验扩展名 + ext := strings.ToLower(filepath.Ext(file.Filename)) + if !allowedImageExts[ext] { + c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的图片格式,仅支持 jpg/jpeg/png/gif/webp"}) + return + } + + // 打开文件并检测真实 MIME 类型(防止伪造扩展名) + src, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"}) + return + } + defer src.Close() + + head := make([]byte, 512) + n, _ := src.Read(head) + contentType := http.DetectContentType(head[:n]) + if !allowedImageMIMEs[contentType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容不是有效的图片"}) + return + } + + // 重置读取位置,准备写入 + if _, err := src.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"}) + return + } + + // 确保上传目录存在 + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"}) + return + } + + // 生成唯一文件名:时间戳_原始名.扩展名 + filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitizeBaseName(file.Filename), ext) + dstPath := filepath.Join(uploadDir, filename) + + dst, err := os.Create(dstPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) + return + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "url": uploadURLPrefix + filename, + "name": filename, + "size": file.Size, + }) +} + +// ListUploads 列出已上传的图片 +func ListUploads(c *gin.Context) { + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"}) + return + } + + entries, err := os.ReadDir(uploadDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"}) + return + } + + files := make([]UploadedFile, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if !allowedImageExts[ext] { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, UploadedFile{ + Name: e.Name(), + URL: uploadURLPrefix + e.Name(), + Size: info.Size(), + ModTime: info.ModTime().Format("2006-01-02 15:04:05"), + }) + } + + // 按修改时间倒序(最新的在前) + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime > files[j].ModTime + }) + + c.JSON(http.StatusOK, gin.H{"data": files}) +} + +// DeleteUpload 删除已上传的图片 +func DeleteUpload(c *gin.Context) { + name := c.Param("name") + + // 防止路径穿越 + if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { + c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件名"}) + return + } + + ext := strings.ToLower(filepath.Ext(name)) + if !allowedImageExts[ext] { + c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件类型"}) + return + } + + path := filepath.Join(uploadDir, name) + if _, err := os.Stat(path); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"}) + return + } + + if err := os.Remove(path); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/routers/router.go b/routers/router.go index a27c64a..925dd4d 100644 --- a/routers/router.go +++ b/routers/router.go @@ -96,6 +96,11 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) { admin.PUT("/comments/:id/approve", handlers.ApproveComment) admin.PUT("/comments/:id/spam", handlers.MarkSpamComment) admin.DELETE("/comments/:id", handlers.DeleteComment) + + // 文件管理(图片上传) + admin.POST("/upload", handlers.UploadImage) + admin.GET("/uploads", handlers.ListUploads) + admin.DELETE("/uploads/:name", handlers.DeleteUpload) } } } diff --git a/static/admin/app.js b/static/admin/app.js index 63233b0..b9e8c3c 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -42,6 +42,18 @@ document.addEventListener('DOMContentLoaded', () => { // 模态框表单 document.getElementById('modal-form').addEventListener('submit', handleModalSubmit); + + // 封面图上传 + const coverFileInput = document.getElementById('post-cover-file'); + if (coverFileInput) { + coverFileInput.addEventListener('change', handleCoverFileChange); + } + + // 文件管理上传 + const fileUploadInput = document.getElementById('file-upload-input'); + if (fileUploadInput) { + fileUploadInput.addEventListener('change', handleFileUpload); + } }); // 显示登录页 @@ -92,6 +104,9 @@ function showPage(pageName) { case 'comments': loadComments(); break; + case 'files': + loadFiles(); + break; case 'themes': loadThemes(); break; @@ -196,6 +211,7 @@ async function showPostForm(isEdit = false) { document.getElementById('post-form-title').textContent = isEdit ? '编辑文章' : '新建文章'; document.getElementById('post-form').reset(); document.getElementById('post-id').value = ''; + setCover(''); // 加载分类选项 const res = await fetch(`${API_BASE}/categories`); @@ -217,7 +233,7 @@ async function editPost(id) { document.getElementById('post-title').value = post.title; document.getElementById('post-content').value = post.content; document.getElementById('post-summary').value = post.summary || ''; - document.getElementById('post-cover').value = post.cover || ''; + setCover(post.cover || ''); document.getElementById('post-status').value = post.status; document.getElementById('post-istop').checked = post.is_top; document.getElementById('post-tags').value = post.tags?.map(t => t.name).join(', ') || ''; @@ -986,3 +1002,175 @@ async function saveCurrentFile() { showThemeMessage('保存文件失败', 'error'); } } + +// ==================== 文件上传与管理 ==================== + +// 允许的图片类型与大小限制(与后端保持一致) +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB + +// 校验图片文件,通过返回 null,否则返回错误信息 +function validateImageFile(file) { + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + return '不支持的图片格式,仅支持 jpg/png/gif/webp'; + } + if (file.size > MAX_IMAGE_SIZE) { + return '图片大小超过限制(最大 5MB)'; + } + return null; +} + +// 上传图片到服务器,返回 { url, name, size } +async function uploadImageFile(file) { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch(`${API_BASE}/admin/upload`, { + method: 'POST', + // 注意:上传 FormData 时不能手动设置 Content-Type,浏览器会自动处理 boundary + headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }, + body: formData + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || '上传失败'); + } + return data; +} + +// 封面图:选择文件后上传 +async function handleCoverFileChange(e) { + const file = e.target.files[0]; + if (!file) return; + + const err = validateImageFile(file); + if (err) { + alert(err); + e.target.value = ''; + return; + } + + try { + const data = await uploadImageFile(file); + setCover(data.url); + } catch (uErr) { + alert(uErr.message); + } + e.target.value = ''; +} + +// 设置封面图 URL 并更新预览 +function setCover(url) { + document.getElementById('post-cover').value = url || ''; + const preview = document.getElementById('post-cover-preview'); + const removeBtn = document.getElementById('post-cover-remove'); + if (!preview || !removeBtn) return; + + if (url) { + preview.src = url; + preview.classList.remove('hidden'); + removeBtn.classList.remove('hidden'); + } else { + preview.src = ''; + preview.classList.add('hidden'); + removeBtn.classList.add('hidden'); + } +} + +// 移除封面图 +function removeCover() { + setCover(''); +} + +// 加载已上传文件列表 +async function loadFiles() { + try { + const res = await fetch(`${API_BASE}/admin/uploads`, { headers: getHeaders() }); + const data = await res.json(); + const grid = document.getElementById('file-grid'); + + if (!data.data || data.data.length === 0) { + grid.innerHTML = '
暂无上传文件
'; + return; + } + + grid.innerHTML = data.data.map(f => ` +