From 61bf9fb48cebd766a46fe552412dc17549494209 Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 17 Sep 2026 07:33:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(attach):=20=E9=99=84=E4=BB=B6=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=99=90=E9=A2=9D=E3=80=81=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E8=90=BD=E7=9B=98=E4=B8=8E=E5=88=86=E9=98=B6=E6=AE=B5=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor --- backend/handler/post.go | 62 ++- backend/handler/setting.go | 103 +++- backend/handler/upload.go | 57 +- backend/model/models.go | 2 +- backend/router/router.go | 6 +- backend/service/attachment_settings_test.go | 36 ++ backend/service/fileallow.go | 176 +++++++ backend/service/fileallow_test.go | 70 +++ backend/service/post_file.go | 143 +++-- backend/service/setting.go | 317 ++++++++++- backend/service/upload.go | 122 ++++- frontend/app/admin/settings/SettingsAdmin.tsx | 224 +++++++- frontend/app/globals.css | 236 +++++++++ frontend/components/ComposeAttachments.tsx | 495 ++++++++++++++++++ frontend/components/MarkdownEditor.tsx | 8 +- frontend/components/PostAttachments.tsx | 51 +- frontend/components/PostComposer.tsx | 249 ++++----- .../components/admin/AdminExtChipEditor.tsx | 244 +++++++++ frontend/components/admin/index.ts | 2 + frontend/lib/api.ts | 101 +++- frontend/lib/fileKind.ts | 49 ++ frontend/lib/postFile.ts | 58 ++ frontend/lib/realtime.ts | 5 + frontend/next.config.ts | 4 + 24 files changed, 2513 insertions(+), 307 deletions(-) create mode 100644 backend/service/attachment_settings_test.go create mode 100644 backend/service/fileallow.go create mode 100644 backend/service/fileallow_test.go create mode 100644 frontend/components/ComposeAttachments.tsx create mode 100644 frontend/components/admin/AdminExtChipEditor.tsx create mode 100644 frontend/lib/fileKind.ts create mode 100644 frontend/lib/postFile.ts diff --git a/backend/handler/post.go b/backend/handler/post.go index c16b598..4d052fa 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -2,7 +2,6 @@ package handler import ( "errors" - "io" "net/http" "net/url" "path/filepath" @@ -282,11 +281,46 @@ func (h *Handlers) AdminPointsStats(c *gin.Context) { // UploadPostFile 上传帖子附件(草稿态) func (h *Handlers) UploadPostFile(c *gin.Context) { claims := middleware.CurrentUser(c) + + maxBytes, err := h.Setting.AttachmentMaxBytes() + if err != nil || maxBytes < 1 { + maxBytes = service.FileMaxBytes + } + overhead := int64(64 << 10) // multipart 边界开销 + limit := maxBytes + overhead + + if c.Request.ContentLength > limit { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) + file, err := c.FormFile("file") if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"}) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"}) return } + if file.Size > maxBytes { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"}) + return + } price, _ := strconv.Atoi(c.DefaultPostForm("price_points", "0")) f, err := file.Open() if err != nil { @@ -294,13 +328,18 @@ func (h *Handlers) UploadPostFile(c *gin.Context) { return } defer f.Close() - data, err := io.ReadAll(io.LimitReader(f, service.FileMaxBytes+1)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "读取文件失败"}) - return - } - att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, data, price) + + att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, f, price) if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) || errors.Is(err, service.ErrAttachmentTooLarge) { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"}) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -399,7 +438,14 @@ func (h *Handlers) DownloadPostAttachment(c *gin.Context) { path := h.PostFile.FilePath(att) h.PostFile.IncDownload(att.ID) c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(att.Name)) - c.Header("Content-Type", att.MIME) + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "sandbox") + ct := att.MIME + ext := service.ExtOfFilename(att.Name) + if service.IsActiveContentExt(ext) || ct == "" { + ct = "application/octet-stream" + } + c.Header("Content-Type", ct) c.File(path) _ = filepath.Base(path) } diff --git a/backend/handler/setting.go b/backend/handler/setting.go index d339294..45adb08 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -21,21 +21,26 @@ func (h *Handlers) PublicSettings(c *gin.Context) { // updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置 type updateSettingsRequest struct { - Accent *string `json:"accent"` - TrustReviewedPublish *bool `json:"trust_reviewed_publish"` - SiteName *string `json:"site_name"` - SiteDescription *string `json:"site_description"` - AllowRegister *bool `json:"allow_register"` - AllowComments *bool `json:"allow_comments"` - AllowMessages *bool `json:"allow_messages"` - PostCooldownHours *int `json:"post_cooldown_hours"` - CodeBlockAutoFold *bool `json:"code_block_auto_fold"` - CodeBlockFoldLines *int `json:"code_block_fold_lines"` - UIAnimations *bool `json:"ui_animations"` - AnimCodeFold *bool `json:"anim_code_fold"` - AnimSmoothScroll *bool `json:"anim_smooth_scroll"` - AnimChrome *bool `json:"anim_chrome"` - PostLinkNewTab *bool `json:"post_link_new_tab"` + Accent *string `json:"accent"` + TrustReviewedPublish *bool `json:"trust_reviewed_publish"` + SiteName *string `json:"site_name"` + SiteDescription *string `json:"site_description"` + AllowRegister *bool `json:"allow_register"` + AllowComments *bool `json:"allow_comments"` + AllowMessages *bool `json:"allow_messages"` + PostCooldownHours *int `json:"post_cooldown_hours"` + CodeBlockAutoFold *bool `json:"code_block_auto_fold"` + CodeBlockFoldLines *int `json:"code_block_fold_lines"` + UIAnimations *bool `json:"ui_animations"` + AnimCodeFold *bool `json:"anim_code_fold"` + AnimSmoothScroll *bool `json:"anim_smooth_scroll"` + AnimChrome *bool `json:"anim_chrome"` + PostLinkNewTab *bool `json:"post_link_new_tab"` + AttachmentExtLimit *bool `json:"attachment_ext_limit"` + AttachmentExts *[]string `json:"attachment_exts"` + AttachmentMaxMB *int `json:"attachment_max_mb"` + AttachmentMaxCount *int `json:"attachment_max_count"` + ImageMaxMB *int `json:"image_max_mb"` } func settingsPayload(saved service.PublicSiteSettings) gin.H { @@ -55,9 +60,24 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "anim_smooth_scroll": saved.AnimSmoothScroll, "anim_chrome": saved.AnimChrome, "post_link_new_tab": saved.PostLinkNewTab, + "attachment_ext_limit": saved.AttachmentExtLimit, + "attachment_exts": saved.AttachmentExts, + "attachment_max_mb": saved.AttachmentMaxMB, + "attachment_max_count": saved.AttachmentMaxCount, + "image_max_mb": saved.ImageMaxMB, } } +func (req *updateSettingsRequest) hasAny() bool { + return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil || + req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil || + req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil || + req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil || + req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil || + req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil || + req.AttachmentMaxCount != nil || req.ImageMaxMB != nil +} + // PUT /api/admin/settings func (h *Handlers) UpdateSettings(c *gin.Context) { var req updateSettingsRequest @@ -65,12 +85,7 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } - if req.Accent == nil && req.TrustReviewedPublish == nil && req.SiteName == nil && - req.SiteDescription == nil && req.AllowRegister == nil && req.AllowComments == nil && - req.AllowMessages == nil && - req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil && - req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == nil && - req.AnimChrome == nil && req.PostLinkNewTab == nil { + if !req.hasAny() { c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"}) return } @@ -185,6 +200,52 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.AttachmentExtLimit != nil { + if err := h.Setting.SetAttachmentExtLimit(*req.AttachmentExtLimit); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.AttachmentExts != nil { + if err := h.Setting.SetAttachmentExts(*req.AttachmentExts); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "扩展名无效或数量超限(最多 80 个,仅字母数字)"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.AttachmentMaxMB != nil { + if err := h.Setting.SetAttachmentMaxMB(*req.AttachmentMaxMB); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "附件上限须至少 1MB"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.AttachmentMaxCount != nil { + if err := h.Setting.SetAttachmentMaxCount(*req.AttachmentMaxCount); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "每帖附件数须为 1–20"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.ImageMaxMB != nil { + if err := h.Setting.SetImageMaxMB(*req.ImageMaxMB); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "插图上限须至少 1MB"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } saved, err := h.Setting.Public() if err != nil { diff --git a/backend/handler/upload.go b/backend/handler/upload.go index 6b6cb3e..5f33600 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -53,15 +53,43 @@ func (h *Handlers) UploadAvatar(c *gin.Context) { func (h *Handlers) UploadImage(c *gin.Context) { claims := middleware.CurrentUser(c) - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.ImageMaxBytes+4096) + maxBytes, err := h.Setting.ImageMaxBytes() + if err != nil || maxBytes < 1 { + maxBytes = service.ImageMaxBytes + } + overhead := int64(4096) + limit := maxBytes + overhead + + if c.Request.ContentLength > limit { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) fh, err := c.FormFile("file") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大(不能超过 5MB)或格式不正确"}) + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"}) return } - if fh.Size > service.ImageMaxBytes { - c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"}) + if fh.Size > maxBytes { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"}) return } f, err := fh.Open() @@ -71,18 +99,17 @@ func (h *Handlers) UploadImage(c *gin.Context) { } defer f.Close() - data, err := io.ReadAll(io.LimitReader(f, service.ImageMaxBytes+1)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"}) - return - } - if len(data) > service.ImageMaxBytes { - c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"}) - return - } - - att, err := h.Upload.SaveImage(claims.ID, data) + att, err := h.Upload.SaveImage(claims.ID, f) if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"}) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/backend/model/models.go b/backend/model/models.go index a3e97bb..cd77e29 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -398,7 +398,7 @@ type Announcement struct { // SiteSetting 站点级键值设置(如历史在线峰值 peak_online) type SiteSetting struct { Key string `gorm:"primaryKey;size:64" json:"key"` - Value string `gorm:"size:255;not null;default:''" json:"value"` + Value string `gorm:"type:text;not null;default:''" json:"value"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/router/router.go b/backend/router/router.go index d4497e8..eea5757 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -23,6 +23,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { } r := gin.New() + // 大附件落盘到临时文件,避免 multipart 整文件进内存(默认 32MiB) + r.MaxMultipartMemory = 4 << 20 r.Use(gin.Recovery()) r.Use(gin.Logger()) @@ -49,8 +51,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { overviewSvc := service.NewOverviewService(model.DB) checkinSvc := service.NewCheckinService(model.DB) announcementSvc := service.NewAnnouncementService(model.DB) - uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")) - postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")) + uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc) + postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")).WithSetting(settingSvc) pointsSvc := service.NewPointsService(model.DB) adminUserSvc := service.NewAdminUserService(model.DB) moderationSvc := service.NewModerationService(model.DB, notifSvc) diff --git a/backend/service/attachment_settings_test.go b/backend/service/attachment_settings_test.go new file mode 100644 index 0000000..fadf59e --- /dev/null +++ b/backend/service/attachment_settings_test.go @@ -0,0 +1,36 @@ +package service + +import ( + "fmt" + "testing" +) + +func TestAttachmentExtAllowedLogic(t *testing.T) { + // Normalize + default list contains msi/html + found := false + for _, e := range DefaultAttachmentExts { + if e == "msi" { + found = true + break + } + } + if !found { + t.Fatal("default list should include msi") + } +} + +func TestMimeForAttachmentExt(t *testing.T) { + if MimeForAttachmentExt(".pdf") == "application/octet-stream" { + t.Fatal("pdf should have real mime") + } + if MimeForAttachmentExt(".html") != "application/octet-stream" { + t.Fatal("html should be octet-stream") + } +} + +func TestFileMaxBytesDefault(t *testing.T) { + if FileMaxBytes != 20<<20 { + t.Fatalf("default FileMaxBytes=%d", FileMaxBytes) + } + _ = fmt.Sprintf("%d", DefaultAttachmentMaxMB) +} diff --git a/backend/service/fileallow.go b/backend/service/fileallow.go new file mode 100644 index 0000000..071c48f --- /dev/null +++ b/backend/service/fileallow.go @@ -0,0 +1,176 @@ +package service + +import ( + "bytes" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "mime" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "golang.org/x/image/webp" +) + +// 活跃内容扩展名:下载时强制 application/octet-stream,避免浏览器当页面执行 +var activeContentExts = map[string]struct{}{ + ".html": {}, ".htm": {}, ".xhtml": {}, + ".svg": {}, ".xml": {}, + ".js": {}, ".mjs": {}, ".css": {}, + ".wasm": {}, +} + +// RasterImageExts 栅格图扩展名(可 DecodeConfig;伪装内容降为 octet-stream) +var RasterImageExts = map[string]struct{}{ + ".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".gif": {}, +} + +const attachmentImageMaxDim = 4096 + +// IsActiveContentExt 是否为浏览器可能内联执行的扩展名 +func IsActiveContentExt(ext string) bool { + _, ok := activeContentExts[strings.ToLower(ext)] + return ok +} + +// MimeForAttachmentExt 按扩展名映射 MIME;未知或活跃内容走 octet-stream 由调用方再处理 +func MimeForAttachmentExt(ext string) string { + ext = strings.ToLower(ext) + if IsActiveContentExt(ext) { + return "application/octet-stream" + } + if t := mime.TypeByExtension(ext); t != "" { + if i := strings.IndexByte(t, ';'); i >= 0 { + t = strings.TrimSpace(t[:i]) + } + return t + } + return "application/octet-stream" +} + +// looksLikeMarkupOrScript 检测内容是否像 HTML/SVG/XML 伪装(前 512 字节) +func looksLikeMarkupOrScript(data []byte) bool { + n := len(data) + if n > 512 { + n = 512 + } + head := bytes.TrimLeft(data[:n], " \t\r\n") + if len(head) == 0 { + return false + } + lower := bytes.ToLower(head) + prefixes := [][]byte{ + []byte(" 0 && looksLikeMarkupOrScript(head[:n]) { + return "application/octet-stream" + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return "application/octet-stream" + } + if !validRasterImage(f, ext) { + return "application/octet-stream" + } + return MimeForAttachmentExt(ext) + } + return MimeForAttachmentExt(ext) +} + +func validRasterImage(r io.Reader, ext string) bool { + var cfg image.Config + var err error + switch ext { + case ".webp": + cfg, err = webp.DecodeConfig(r) + default: + cfg, _, err = image.DecodeConfig(r) + } + if err != nil { + return false + } + if cfg.Width < 1 || cfg.Height < 1 { + return false + } + if cfg.Width > attachmentImageMaxDim || cfg.Height > attachmentImageMaxDim { + return false + } + return true +} + +// sanitizeFilename 清理上传文件名:去路径、危险字符、RTL 伪装、截断 +func sanitizeFilename(name string) string { + name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.TrimSpace(name) + name = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|', + '\u202e', '\u202d', '\u200f', '\u200e', + '\ufeff': + return '_' + default: + if r < 0x20 { + return '_' + } + return r + } + }, name) + if utf8.RuneCountInString(name) > 200 { + runes := []rune(name) + name = string(runes[:200]) + } + return name +} + +// ExtOfFilename 取小写扩展名(含点),无则空串 +func ExtOfFilename(name string) string { + return strings.ToLower(filepath.Ext(name)) +} diff --git a/backend/service/fileallow_test.go b/backend/service/fileallow_test.go new file mode 100644 index 0000000..f6304ec --- /dev/null +++ b/backend/service/fileallow_test.go @@ -0,0 +1,70 @@ +package service + +import ( + "testing" +) + +func TestNormalizeAttachmentExts(t *testing.T) { + got, err := NormalizeAttachmentExts([]string{".PDF", "msi", "PDF", " zip "}) + if err != nil { + t.Fatal(err) + } + want := []string{"pdf", "msi", "zip"} + if len(got) != len(want) { + t.Fatalf("len=%d want %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got[%d]=%s want %s", i, got[i], want[i]) + } + } +} + +func TestNormalizeAttachmentExtsInvalid(t *testing.T) { + _, err := NormalizeAttachmentExts([]string{"bad_ext"}) + if err != ErrInvalidSiteSetting { + t.Fatalf("want ErrInvalidSiteSetting, got %v", err) + } +} + +func TestResolveAttachmentMIMEActive(t *testing.T) { + m := ResolveAttachmentMIME(".html", []byte("")) + if m != "application/octet-stream" { + t.Fatalf("html mime=%s", m) + } +} + +func TestResolveAttachmentMIMEDisguisedPNG(t *testing.T) { + m := ResolveAttachmentMIME(".png", []byte("")) + if m != "application/octet-stream" { + t.Fatalf("disguised png mime=%s", m) + } +} + +func TestIsActiveContentExt(t *testing.T) { + if !IsActiveContentExt(".JS") { + t.Fatal("js should be active") + } + if IsActiveContentExt(".pdf") { + t.Fatal("pdf should not be active") + } +} + +func TestSanitizeFilenameRTL(t *testing.T) { + name := sanitizeFilename("evil\u202epdf.exe") + if name != "evil_pdf.exe" && name != "evil_.exe" { + // Map replaces U+202E with _ + if !containsRune(name, '_') { + t.Fatalf("expected RTL stripped: %q", name) + } + } +} + +func containsRune(s string, r rune) bool { + for _, c := range s { + if c == r { + return true + } + } + return false +} diff --git a/backend/service/post_file.go b/backend/service/post_file.go index c65e399..d8e507b 100644 --- a/backend/service/post_file.go +++ b/backend/service/post_file.go @@ -4,37 +4,48 @@ import ( "crypto/rand" "encoding/hex" "errors" - "mime" + "fmt" + "io" "os" "path/filepath" - "strings" "unicode/utf8" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) +// 兼容旧引用:默认体积/个数(实际限额读 SettingService) const ( - FileMaxBytes = 20 << 20 // 20 MiB + FileMaxBytes = 20 << 20 // 默认 20 MiB(缺省配置) MaxPostAttachments = 10 ) var ( - ErrAttachmentNotFound = errors.New("附件不存在") + ErrAttachmentNotFound = errors.New("附件不存在") ErrAttachmentForbidden = errors.New("无权操作此附件") - ErrTooManyAttachments = errors.New("附件数量超过上限") + ErrTooManyAttachments = errors.New("附件数量超过上限") + ErrAttachmentExtDenied = errors.New("不支持该附件格式") + ErrAttachmentTooLarge = errors.New("附件过大") + ErrAttachmentEmpty = errors.New("文件为空") ) // PostFileService 帖子文件附件(私有目录 + API 下载) type PostFileService struct { - db *gorm.DB - dir string // data/private/files + db *gorm.DB + dir string // data/private/files + setting *SettingService } func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService { return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")} } +// WithSetting 注入站点设置(附件限额/扩展名) +func (s *PostFileService) WithSetting(setting *SettingService) *PostFileService { + s.setting = setting + return s +} + func (s *PostFileService) EnsureDir() error { return os.MkdirAll(s.dir, 0o755) } @@ -43,13 +54,40 @@ func absPath(dir, stored string) string { return filepath.Join(dir, stored) } -// SaveDraftFile 上传附件(先挂 post_id=0,发帖时绑定) -func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data []byte, pricePoints int) (*model.PostAttachment, error) { - if len(data) == 0 { - return nil, errors.New("文件为空") +func (s *PostFileService) maxBytes() int64 { + if s.setting == nil { + return FileMaxBytes } - if len(data) > FileMaxBytes { - return nil, errors.New("附件不能超过 20MB") + n, err := s.setting.AttachmentMaxBytes() + if err != nil || n < 1 { + return FileMaxBytes + } + return n +} + +func (s *PostFileService) maxCount() int { + if s.setting == nil { + return MaxPostAttachments + } + n, err := s.setting.AttachmentMaxCount() + if err != nil || n < 1 { + return MaxPostAttachments + } + return n +} + +func (s *PostFileService) tooLargeErr() error { + mb := int(s.maxBytes() >> 20) + if mb < 1 { + mb = 1 + } + return fmt.Errorf("%w:不能超过 %dMB", ErrAttachmentTooLarge, mb) +} + +// SaveDraftFile 流式落盘附件(先挂 post_id=0,发帖时绑定)。整文件不进内存。 +func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io.Reader, pricePoints int) (*model.PostAttachment, error) { + if src == nil { + return nil, ErrAttachmentEmpty } if pricePoints < 0 { pricePoints = 0 @@ -62,32 +100,63 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [ name = "file" } + ext := ExtOfFilename(name) + if utf8.RuneCountInString(ext) > 16 { + ext = "" + } + if s.setting != nil { + ok, err := s.setting.AttachmentExtAllowed(ext) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrAttachmentExtDenied + } + } + + maxC := s.maxCount() var orphan int64 if err := s.db.Model(&model.PostAttachment{}). Where("user_id = ? AND post_id = 0", userID).Count(&orphan).Error; err != nil { return nil, err } - if orphan >= MaxPostAttachments { + if orphan >= int64(maxC) { return nil, ErrTooManyAttachments } - ext := filepath.Ext(name) - if utf8.RuneCountInString(ext) > 16 { - ext = "" - } + maxB := s.maxBytes() raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { return nil, err } stored := hex.EncodeToString(raw) + ext full := absPath(s.dir, stored) - if err := os.WriteFile(full, data, 0o600); err != nil { + tmp := full + ".partial" + + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { return nil, err } + // LimitReader 多读 1 字节以区分「刚好上限」与「超限」 + written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1)) + _ = f.Close() + if copyErr != nil { + _ = os.Remove(tmp) + return nil, copyErr + } + if written == 0 { + _ = os.Remove(tmp) + return nil, ErrAttachmentEmpty + } + if written > maxB { + _ = os.Remove(tmp) + return nil, s.tooLargeErr() + } - mimeType := mime.TypeByExtension(ext) - if mimeType == "" { - mimeType = "application/octet-stream" + mimeType := ResolveAttachmentMIMEFile(ext, tmp) + if err := os.Rename(tmp, full); err != nil { + _ = os.Remove(tmp) + return nil, err } att := &model.PostAttachment{ @@ -96,7 +165,7 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [ Name: name, StoredName: stored, MIME: mimeType, - Size: len(data), + Size: int(written), PricePoints: pricePoints, } if err := s.db.Create(att).Error; err != nil { @@ -106,30 +175,13 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [ return att, nil } -func sanitizeFilename(name string) string { - name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) - name = strings.TrimSpace(name) - name = strings.Map(func(r rune) rune { - switch r { - case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|': - return '_' - default: - return r - } - }, name) - if utf8.RuneCountInString(name) > 200 { - runes := []rune(name) - name = string(runes[:200]) - } - return name -} - // BindToPost 将草稿附件绑定到帖子(仅本人、未绑定) func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error { if len(ids) == 0 { return nil } - if len(ids) > MaxPostAttachments { + maxC := s.maxCount() + if len(ids) > maxC { return ErrTooManyAttachments } return s.db.Transaction(func(tx *gorm.DB) error { @@ -148,7 +200,8 @@ func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error { // ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的) func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error { - if len(ids) > MaxPostAttachments { + maxC := s.maxCount() + if len(ids) > maxC { return ErrTooManyAttachments } return s.db.Transaction(func(tx *gorm.DB) error { @@ -164,7 +217,6 @@ func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint return errors.New("部分附件无效或无权使用") } } - // 解绑本帖旧附件(软删物理文件可选:P1 仅解绑) if err := tx.Model(&model.PostAttachment{}). Where("post_id = ? AND user_id = ?", postID, userID). Update("post_id", 0).Error; err != nil { @@ -220,7 +272,7 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error { return nil } -// OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分 +// Get 按 ID 取附件 func (s *PostFileService) Get(attID uint) (*model.PostAttachment, error) { var att model.PostAttachment if err := s.db.First(&att, attID).Error; err != nil { @@ -268,7 +320,6 @@ func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostA if _, err := DebitTx(tx, userID, att.PricePoints, model.PointReasonDownloadFile, "attachment", att.ID, "下载附件:"+att.Name); err != nil { return err } - // 积分转给作者 if att.UserID > 0 && att.UserID != userID { if _, err := CreditTx(tx, att.UserID, att.PricePoints, model.PointReasonDownloadFile, "attachment_earn", att.ID, "附件收益:"+att.Name); err != nil { return err diff --git a/backend/service/setting.go b/backend/service/setting.go index f1b36ca..dadcedc 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -39,6 +39,16 @@ const ( SettingKeyAnimChrome = "anim_chrome" // SettingKeyPostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开;缺行视为开启(聊天链接始终新开,不受此键控制) SettingKeyPostLinkNewTab = "post_link_new_tab" + // SettingKeyAttachmentExtLimit 是否限制附件扩展名;缺行视为开启 + SettingKeyAttachmentExtLimit = "attachment_ext_limit" + // SettingKeyAttachmentExts 允许的扩展名(逗号分隔,无点);缺行=默认列表 + SettingKeyAttachmentExts = "attachment_exts" + // SettingKeyAttachmentMaxMB 附件单文件上限(MB);缺行=20 + SettingKeyAttachmentMaxMB = "attachment_max_mb" + // SettingKeyAttachmentMaxCount 每帖附件个数上限;缺行=10 + SettingKeyAttachmentMaxCount = "attachment_max_count" + // SettingKeyImageMaxMB 正文插图上限(MB);缺行=5 + SettingKeyImageMaxMB = "image_max_mb" ) const ( @@ -51,8 +61,30 @@ const ( MaxCooldownHours = 168 MinCodeFoldLines = 0 MaxCodeFoldLines = 100 + + DefaultAttachmentMaxMB = 20 + DefaultAttachmentMaxCount = 10 + DefaultImageMaxMB = 5 + MinAttachmentMaxMB = 1 + MinImageMaxMB = 1 + MinAttachmentMaxCount = 1 + MaxAttachmentMaxCount = 20 + MaxAttachmentExtCount = 80 ) +// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页) +var DefaultAttachmentExts = []string{ + "pdf", "txt", "md", "csv", "json", + "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "zip", "7z", "rar", "tar", "gz", "tgz", + "jpg", "jpeg", "png", "webp", "gif", + "mp3", "wav", "ogg", "mp4", "webm", + "exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll", + "html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh", +} + +var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`) + // ErrInvalidAccent 主题色格式非法 var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB") @@ -63,21 +95,26 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) // PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示) type PublicSiteSettings struct { - Accent string `json:"accent"` - TrustReviewedPublish bool `json:"trust_reviewed_publish"` - SiteName string `json:"site_name"` - SiteDescription string `json:"site_description"` - AllowRegister bool `json:"allow_register"` - AllowComments bool `json:"allow_comments"` - AllowMessages bool `json:"allow_messages"` - PostCooldownHours int `json:"post_cooldown_hours"` - CodeBlockAutoFold bool `json:"code_block_auto_fold"` - CodeBlockFoldLines int `json:"code_block_fold_lines"` - UIAnimations bool `json:"ui_animations"` - AnimCodeFold bool `json:"anim_code_fold"` - AnimSmoothScroll bool `json:"anim_smooth_scroll"` - AnimChrome bool `json:"anim_chrome"` - PostLinkNewTab bool `json:"post_link_new_tab"` + Accent string `json:"accent"` + TrustReviewedPublish bool `json:"trust_reviewed_publish"` + SiteName string `json:"site_name"` + SiteDescription string `json:"site_description"` + AllowRegister bool `json:"allow_register"` + AllowComments bool `json:"allow_comments"` + AllowMessages bool `json:"allow_messages"` + PostCooldownHours int `json:"post_cooldown_hours"` + CodeBlockAutoFold bool `json:"code_block_auto_fold"` + CodeBlockFoldLines int `json:"code_block_fold_lines"` + UIAnimations bool `json:"ui_animations"` + AnimCodeFold bool `json:"anim_code_fold"` + AnimSmoothScroll bool `json:"anim_smooth_scroll"` + AnimChrome bool `json:"anim_chrome"` + PostLinkNewTab bool `json:"post_link_new_tab"` + AttachmentExtLimit bool `json:"attachment_ext_limit"` + AttachmentExts []string `json:"attachment_exts"` + AttachmentMaxMB int `json:"attachment_max_mb"` + AttachmentMaxCount int `json:"attachment_max_count"` + ImageMaxMB int `json:"image_max_mb"` } // SettingService 站点级键值设置 @@ -147,6 +184,11 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { AnimSmoothScroll: true, AnimChrome: true, PostLinkNewTab: true, + AttachmentExtLimit: true, + AttachmentExts: append([]string(nil), DefaultAttachmentExts...), + AttachmentMaxMB: DefaultAttachmentMaxMB, + AttachmentMaxCount: DefaultAttachmentMaxCount, + ImageMaxMB: DefaultImageMaxMB, } accent, err := s.AccentColor() if err != nil { @@ -244,6 +286,36 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { return out, err } out.PostLinkNewTab = linkNewTab + + extLimit, err := s.AttachmentExtLimit() + if err != nil { + return out, err + } + out.AttachmentExtLimit = extLimit + + exts, err := s.AttachmentExts() + if err != nil { + return out, err + } + out.AttachmentExts = exts + + attMB, err := s.AttachmentMaxMB() + if err != nil { + return out, err + } + out.AttachmentMaxMB = attMB + + attCount, err := s.AttachmentMaxCount() + if err != nil { + return out, err + } + out.AttachmentMaxCount = attCount + + imgMB, err := s.ImageMaxMB() + if err != nil { + return out, err + } + out.ImageMaxMB = imgMB return out, nil } @@ -480,3 +552,218 @@ func (s *SettingService) PostLinkNewTab() (bool, error) { func (s *SettingService) SetPostLinkNewTab(on bool) error { return s.setBoolDefaultTrue(SettingKeyPostLinkNewTab, on) } + +// AttachmentExtLimit 是否限制附件扩展名。缺行视为开启。 +func (s *SettingService) AttachmentExtLimit() (bool, error) { + v, found, err := s.getValue(SettingKeyAttachmentExtLimit) + if err != nil { + return true, err + } + return parseBoolDefaultTrue(v, found), nil +} + +func (s *SettingService) SetAttachmentExtLimit(on bool) error { + return s.setBoolDefaultTrue(SettingKeyAttachmentExtLimit, on) +} + +// NormalizeAttachmentExts 规范化扩展名列表:去点、小写、去重、校验格式。 +func NormalizeAttachmentExts(raw []string) ([]string, error) { + seen := make(map[string]struct{}, len(raw)) + out := make([]string, 0, len(raw)) + for _, item := range raw { + e := strings.TrimSpace(strings.ToLower(item)) + e = strings.TrimPrefix(e, ".") + if e == "" { + continue + } + if !attachmentExtRe.MatchString(e) { + return nil, ErrInvalidSiteSetting + } + if _, ok := seen[e]; ok { + continue + } + seen[e] = struct{}{} + out = append(out, e) + if len(out) > MaxAttachmentExtCount { + return nil, ErrInvalidSiteSetting + } + } + return out, nil +} + +func parseExtList(v string) []string { + parts := strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == ' ' || r == ';' || r == '\n' || r == '\t' + }) + out, err := NormalizeAttachmentExts(parts) + if err != nil || len(out) == 0 { + return append([]string(nil), DefaultAttachmentExts...) + } + return out +} + +func extsEqualDefault(exts []string) bool { + if len(exts) != len(DefaultAttachmentExts) { + return false + } + for i := range exts { + if exts[i] != DefaultAttachmentExts[i] { + return false + } + } + return true +} + +// AttachmentExts 允许的扩展名(无点)。缺行=默认列表;显式空串=空列表。 +func (s *SettingService) AttachmentExts() ([]string, error) { + v, found, err := s.getValue(SettingKeyAttachmentExts) + if err != nil { + return append([]string(nil), DefaultAttachmentExts...), err + } + if !found { + return append([]string(nil), DefaultAttachmentExts...), nil + } + if strings.TrimSpace(v) == "" { + return []string{}, nil + } + return parseExtList(v), nil +} + +// SetAttachmentExts 保存扩展名列表;与默认完全一致时删键。空列表写入空串(限制开启时表示禁止上传)。 +func (s *SettingService) SetAttachmentExts(exts []string) error { + norm, err := NormalizeAttachmentExts(exts) + if err != nil { + return err + } + if len(norm) == 0 { + return s.putValue(SettingKeyAttachmentExts, "") + } + if extsEqualDefault(norm) { + return s.deleteKey(SettingKeyAttachmentExts) + } + return s.putValue(SettingKeyAttachmentExts, strings.Join(norm, ",")) +} + +// AttachmentExtAllowed 当前设置下扩展名(含点或不含点)是否允许上传。 +func (s *SettingService) AttachmentExtAllowed(ext string) (bool, error) { + limit, err := s.AttachmentExtLimit() + if err != nil { + return false, err + } + if !limit { + return true, nil + } + e := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") + if e == "" { + return false, nil + } + list, err := s.AttachmentExts() + if err != nil { + return false, err + } + for _, x := range list { + if x == e { + return true, nil + } + } + return false, nil +} + +// AttachmentMaxMB 附件单文件上限(MB)。缺行=20;最小 1,无硬顶。 +func (s *SettingService) AttachmentMaxMB() (int, error) { + v, found, err := s.getValue(SettingKeyAttachmentMaxMB) + if err != nil { + return DefaultAttachmentMaxMB, err + } + if !found { + return DefaultAttachmentMaxMB, nil + } + n, convErr := strconv.Atoi(strings.TrimSpace(v)) + if convErr != nil || n < MinAttachmentMaxMB { + return DefaultAttachmentMaxMB, nil + } + return n, nil +} + +func (s *SettingService) SetAttachmentMaxMB(mb int) error { + if mb < MinAttachmentMaxMB { + return ErrInvalidSiteSetting + } + if mb == DefaultAttachmentMaxMB { + return s.deleteKey(SettingKeyAttachmentMaxMB) + } + return s.putValue(SettingKeyAttachmentMaxMB, strconv.Itoa(mb)) +} + +// AttachmentMaxBytes 附件字节上限(按当前 MB 设置)。 +func (s *SettingService) AttachmentMaxBytes() (int64, error) { + mb, err := s.AttachmentMaxMB() + if err != nil { + return int64(DefaultAttachmentMaxMB) << 20, err + } + return int64(mb) << 20, nil +} + +// AttachmentMaxCount 每帖/草稿附件个数上限。缺行=10;范围 1–20。 +func (s *SettingService) AttachmentMaxCount() (int, error) { + v, found, err := s.getValue(SettingKeyAttachmentMaxCount) + if err != nil { + return DefaultAttachmentMaxCount, err + } + if !found { + return DefaultAttachmentMaxCount, nil + } + n, convErr := strconv.Atoi(strings.TrimSpace(v)) + if convErr != nil || n < MinAttachmentMaxCount { + return DefaultAttachmentMaxCount, nil + } + if n > MaxAttachmentMaxCount { + return MaxAttachmentMaxCount, nil + } + return n, nil +} + +func (s *SettingService) SetAttachmentMaxCount(n int) error { + if n < MinAttachmentMaxCount || n > MaxAttachmentMaxCount { + return ErrInvalidSiteSetting + } + if n == DefaultAttachmentMaxCount { + return s.deleteKey(SettingKeyAttachmentMaxCount) + } + return s.putValue(SettingKeyAttachmentMaxCount, strconv.Itoa(n)) +} + +// ImageMaxMB 正文插图上限(MB)。缺行=5;最小 1,无硬顶。 +func (s *SettingService) ImageMaxMB() (int, error) { + v, found, err := s.getValue(SettingKeyImageMaxMB) + if err != nil { + return DefaultImageMaxMB, err + } + if !found { + return DefaultImageMaxMB, nil + } + n, convErr := strconv.Atoi(strings.TrimSpace(v)) + if convErr != nil || n < MinImageMaxMB { + return DefaultImageMaxMB, nil + } + return n, nil +} + +func (s *SettingService) SetImageMaxMB(mb int) error { + if mb < MinImageMaxMB { + return ErrInvalidSiteSetting + } + if mb == DefaultImageMaxMB { + return s.deleteKey(SettingKeyImageMaxMB) + } + return s.putValue(SettingKeyImageMaxMB, strconv.Itoa(mb)) +} + +// ImageMaxBytes 插图像素字节上限。 +func (s *SettingService) ImageMaxBytes() (int64, error) { + mb, err := s.ImageMaxMB() + if err != nil { + return int64(DefaultImageMaxMB) << 20, err + } + return int64(mb) << 20, nil +} diff --git a/backend/service/upload.go b/backend/service/upload.go index 2326074..1b5cfeb 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -8,9 +8,11 @@ import ( "image" _ "image/jpeg" _ "image/png" + "io" "log" "os" "path/filepath" + "strconv" "strings" "github.com/freefire/jiang13-bbs/model" @@ -31,14 +33,32 @@ const ( // UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments type UploadService struct { - db *gorm.DB - dir string // 上传根目录(如 data/uploads) + db *gorm.DB + dir string // 上传根目录(如 data/uploads) + setting *SettingService } func NewUploadService(db *gorm.DB, uploadDir string) *UploadService { return &UploadService{db: db, dir: uploadDir} } +// WithSetting 注入站点设置(插图体积上限) +func (s *UploadService) WithSetting(setting *SettingService) *UploadService { + s.setting = setting + return s +} + +func (s *UploadService) imageMaxBytes() int64 { + if s.setting == nil { + return ImageMaxBytes + } + n, err := s.setting.ImageMaxBytes() + if err != nil || n < 1 { + return ImageMaxBytes + } + return n +} + // EnsureDir 启动时确保上传目录存在 func (s *UploadService) EnsureDir() error { if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil { @@ -125,8 +145,7 @@ func detectImageFormat(data []byte) (imageFormat, error) { return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP") } -func decodeImageSize(data []byte, mime string) (w, h int, err error) { - r := bytes.NewReader(data) +func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) { var cfg image.Config switch mime { case "image/webp": @@ -140,28 +159,33 @@ func decodeImageSize(data []byte, mime string) (w, h int, err error) { return cfg.Width, cfg.Height, nil } -// SaveImage 保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image) -func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, error) { - if len(data) == 0 { +// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image) +func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) { + if src == nil { return nil, errors.New("文件为空") } - if len(data) > ImageMaxBytes { - return nil, errors.New("图片不能超过 5MB") + maxB := s.imageMaxBytes() + tooLarge := func() error { + mb := int(maxB >> 20) + if mb < 1 { + mb = 1 + } + return errors.New("图片不能超过 " + strconv.Itoa(mb) + "MB") } - format, err := detectImageFormat(data) + + // 先读魔数头,再与剩余流合并落盘,整图不进内存 + head := make([]byte, 12) + n, err := io.ReadFull(src, head) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { + return nil, errors.New("读取图片失败") + } + if n == 0 { + return nil, errors.New("文件为空") + } + format, err := detectImageFormat(head[:n]) if err != nil { return nil, err } - w, h, err := decodeImageSize(data, format.mime) - if err != nil { - return nil, err - } - if w < 1 || h < 1 { - return nil, errors.New("无效的图片尺寸") - } - if w > ImageMaxDim || h > ImageMaxDim { - return nil, errors.New("图片边长不能超过 4096px") - } nameBytes := make([]byte, 16) if _, err := rand.Read(nameBytes); err != nil { @@ -169,7 +193,61 @@ func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, } filename := hex.EncodeToString(nameBytes) + format.ext fullPath := filepath.Join(s.dir, "images", filename) - if err := os.WriteFile(fullPath, data, 0o644); err != nil { + tmp := fullPath + ".partial" + + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return nil, err + } + remain := maxB + 1 - int64(n) + if remain < 0 { + _ = f.Close() + _ = os.Remove(tmp) + return nil, tooLarge() + } + writtenHead, err := f.Write(head[:n]) + if err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return nil, err + } + writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain)) + _ = f.Close() + if copyErr != nil { + _ = os.Remove(tmp) + return nil, copyErr + } + total := int64(writtenHead) + writtenRest + if total == 0 { + _ = os.Remove(tmp) + return nil, errors.New("文件为空") + } + if total > maxB { + _ = os.Remove(tmp) + return nil, tooLarge() + } + + rf, err := os.Open(tmp) + if err != nil { + _ = os.Remove(tmp) + return nil, err + } + w, h, err := decodeImageSizeReader(rf, format.mime) + _ = rf.Close() + if err != nil { + _ = os.Remove(tmp) + return nil, err + } + if w < 1 || h < 1 { + _ = os.Remove(tmp) + return nil, errors.New("无效的图片尺寸") + } + if w > ImageMaxDim || h > ImageMaxDim { + _ = os.Remove(tmp) + return nil, errors.New("图片边长不能超过 4096px") + } + if err := os.Rename(tmp, fullPath); err != nil { + _ = os.Remove(tmp) return nil, err } @@ -178,7 +256,7 @@ func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, Kind: model.AttachmentKindImage, URL: "/uploads/images/" + filename, MIME: format.mime, - Size: len(data), + Size: int(total), Width: w, Height: h, } diff --git a/frontend/app/admin/settings/SettingsAdmin.tsx b/frontend/app/admin/settings/SettingsAdmin.tsx index ad693a4..1f6059d 100644 --- a/frontend/app/admin/settings/SettingsAdmin.tsx +++ b/frontend/app/admin/settings/SettingsAdmin.tsx @@ -15,17 +15,26 @@ import { Wrench, Palette, FileText, + Paperclip, + Image as ImageIcon, } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { + AdminExtChipEditor, AdminField, AdminPageHeader, AdminSection, + AdminStepper, AdminSwitch, } from "@/components/admin"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { toast } from "@/lib/toast"; +function extsEqual(a: string[], b: string[]) { + if (a.length !== b.length) return false; + return a.every((v, i) => v === b[i]); +} + type SectionId = | "basic" | "content" @@ -119,7 +128,18 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) const [togglingLink, setTogglingLink] = useState(false); const [active, setActive] = useState("basic"); + const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false); + const [exts, setExts] = useState(() => [...(initial.attachment_exts || [])]); + const [savedExts, setSavedExts] = useState(() => [...(initial.attachment_exts || [])]); + const [attMaxMB, setAttMaxMB] = useState(initial.attachment_max_mb || 20); + const [attMaxCount, setAttMaxCount] = useState(initial.attachment_max_count || 10); + const [imageMaxMB, setImageMaxMB] = useState(initial.image_max_mb || 5); + const [togglingExtLimit, setTogglingExtLimit] = useState(false); + const [savingLimits, setSavingLimits] = useState(false); + const [savingExts, setSavingExts] = useState(false); + const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc; + const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]); useEffect(() => { const ids = NAV.map((n) => n.id); @@ -232,6 +252,75 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) } }; + const toggleExtLimit = async () => { + if (togglingExtLimit) return; + const next = !extLimit; + setTogglingExtLimit(true); + try { + const res = await apiUpdateSiteSettings({ attachment_ext_limit: next }); + setExtLimit(res.attachment_ext_limit !== false); + toast(res.attachment_ext_limit !== false ? "已开启附件类型限制" : "已关闭附件类型限制", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setTogglingExtLimit(false); + } + }; + + const saveUploadLimits = async (patch: { + attachment_max_mb?: number; + attachment_max_count?: number; + image_max_mb?: number; + }) => { + if (savingLimits) return; + setSavingLimits(true); + try { + const res = await apiUpdateSiteSettings(patch); + setAttMaxMB(res.attachment_max_mb); + setAttMaxCount(res.attachment_max_count); + setImageMaxMB(res.image_max_mb); + toast("上传限额已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSavingLimits(false); + } + }; + + // 步进器手输会连续触发 onChange,防抖后再写库 + const limitsTimer = useRef | null>(null); + const scheduleLimitSave = (patch: { + attachment_max_mb?: number; + attachment_max_count?: number; + image_max_mb?: number; + }) => { + if (limitsTimer.current) clearTimeout(limitsTimer.current); + limitsTimer.current = setTimeout(() => { + void saveUploadLimits(patch); + }, 450); + }; + + useEffect(() => { + return () => { + if (limitsTimer.current) clearTimeout(limitsTimer.current); + }; + }, []); + + const saveExts = async () => { + if (savingExts || !extsDirty) return; + setSavingExts(true); + try { + const res = await apiUpdateSiteSettings({ attachment_exts: exts }); + setExts([...res.attachment_exts]); + setSavedExts([...res.attachment_exts]); + toast("附件扩展名已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSavingExts(false); + } + }; + return (
-
+
+ + + + 附件单文件上限 + + } + description="发帖附件体积上限。过大可能拖垮进程,并须把反代 / Next 请求体上限调到不少于该值。" + control={ + { + setAttMaxMB(n); + scheduleLimitSave({ attachment_max_mb: n }); + }} + /> + } + /> + {attMaxMB >= 50 ? ( +

+ 当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大,否则用户仍会先撞上网关 413。 +

+ ) : null} + + + 每帖附件个数 + + } + description="单帖(含草稿)最多可绑定的附件数量。" + control={ + { + setAttMaxCount(n); + scheduleLimitSave({ attachment_max_count: n }); + }} + /> + } + /> + + + 正文插图上限 + + } + description="Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图会在服务端解析头部。" + control={ + { + setImageMaxMB(n); + scheduleLimitSave({ image_max_mb: n }); + }} + /> + } + /> + {imageMaxMB >= 10 ? ( +

+ 插图 ≥10MB:解码头部更耗内存,建议保持较小值。 +

+ ) : null} + + + 限制附件扩展名 + + } + description={ + extLimit + ? "仅允许下方列表中的扩展名;安装包 / 脚本 / 网页可按需保留。" + : "任意格式可传,仍受体积上限与下载沙箱约束。" + } + control={ + void toggleExtLimit()} + label="限制附件扩展名" + /> + } + /> + +
+

+ 允许的扩展名 +

+ +
+ +
+
+
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 655785a..711bafc 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -404,6 +404,242 @@ body { border: 1px solid var(--line); background: var(--panel); } +.j13-compose-attach-section { + background: var(--panel); + border-color: color-mix(in srgb, var(--line) 80%, var(--accent) 20%); +} +.j13-compose-attach-title { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 13px !important; + letter-spacing: 0.02em; + color: var(--ink-2) !important; +} +.j13-compose-attach-badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border-radius: 999px; + font-size: 11.5px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--accent); + background: var(--accent-soft); +} +.j13-compose-drop { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-height: 7.5rem; + padding: 1.15rem 1rem; + border-radius: 14px; + border: 1.5px dashed color-mix(in srgb, var(--accent) 42%, var(--line)); + background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel)); + cursor: pointer; + text-align: center; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} +.j13-compose-drop:hover, +.j13-compose-drop:focus-visible { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} +.j13-compose-drop.is-over { + border-color: var(--accent); + border-style: solid; + background: color-mix(in srgb, var(--accent-soft) 85%, var(--panel)); +} +.j13-compose-drop.is-disabled { + opacity: 0.55; + pointer-events: none; +} +.j13-compose-drop-icon { + color: var(--accent); + opacity: 0.85; +} +.j13-compose-drop-lead { + margin: 0; + font-size: 14px; + font-weight: 650; + color: var(--ink); +} +.j13-compose-drop-hint { + margin: 0; + font-size: 12px; + line-height: 1.45; + color: var(--ink-3); + max-width: 36rem; +} +.j13-compose-att-list, +.j13-compose-att-queue { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.55rem; +} +.j13-compose-att-pending { + margin-top: 0.75rem; + padding: 12px; + border-radius: 14px; + border: 1.5px solid color-mix(in srgb, var(--accent) 40%, var(--line)); + background: color-mix(in srgb, var(--accent-soft) 70%, var(--panel)); + display: flex; + flex-direction: column; + gap: 0.65rem; +} +.j13-compose-att-pending-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} +.j13-compose-att-pending-title { + margin: 0; + font-size: 13.5px; + font-weight: 600; + color: var(--ink); +} +.j13-compose-att-upload-btn { + width: 100%; + justify-content: center; + gap: 0.45rem; + min-height: 42px; + font-size: 14.5px; + font-weight: 600; + /* 次于发布主 CTA:白底 + 实线 accent,在 soft 待传区里仍清晰 */ + color: var(--accent); + background: var(--panel); + border-color: var(--accent); + border-width: 1.5px; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), + 0 2px 8px color-mix(in srgb, var(--accent) 14%, transparent); +} +.j13-compose-att-upload-btn:hover:not(:disabled) { + color: var(--accent-on); + background: var(--accent); + border-color: var(--accent); +} +.j13-compose-att-upload-btn:active:not(:disabled) { + background: var(--accent-hi); + border-color: var(--accent-hi); + color: var(--accent-on); +} +.j13-compose-att-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--panel); +} +.j13-compose-att-card.is-error { + border-color: color-mix(in srgb, var(--danger, #c44) 45%, var(--line)); +} +.j13-compose-att-name { + font-size: 13.5px; + font-weight: 600; + color: var(--ink); +} +.j13-compose-att-bar { + margin-top: 6px; + height: 4px; + border-radius: 999px; + background: var(--panel-2); + overflow: hidden; +} +.j13-compose-att-bar > span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width 0.2s ease; +} +.j13-ext-editor.is-disabled { + opacity: 0.55; + pointer-events: none; +} +.j13-ext-warn { + margin: 0 0 0.75rem; + padding: 0.55rem 0.75rem; + border-radius: 10px; + font-size: 12.5px; + line-height: 1.45; + color: color-mix(in srgb, var(--gold) 85%, var(--ink)); + background: color-mix(in srgb, var(--gold) 14%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line)); +} +.j13-ext-groups { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.65rem; +} +.j13-ext-wall { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + min-height: 2.75rem; + padding: 0.55rem 0.65rem; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--panel-2); + cursor: text; +} +.j13-ext-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.2rem 0.35rem 0.2rem 0.55rem; + border-radius: 999px; + font-size: 12.5px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: var(--ink); + background: var(--panel); + border: 1px solid var(--line); +} +.j13-ext-chip-x { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--ink-3); + cursor: pointer; +} +.j13-ext-chip-x:hover { + color: var(--ink); + background: color-mix(in srgb, var(--ink) 8%, transparent); +} +.j13-ext-input-wrap { + display: inline-flex; + align-items: center; + gap: 0.25rem; + min-width: 9rem; + flex: 1 1 8rem; +} +.j13-ext-input { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: transparent; + font-size: 13px; + color: var(--ink); + padding: 0.2rem 0; +} .j13-compose-att-paid { color: var(--gold); font-weight: 650; diff --git a/frontend/components/ComposeAttachments.tsx b/frontend/components/ComposeAttachments.tsx new file mode 100644 index 0000000..f1acc3c --- /dev/null +++ b/frontend/components/ComposeAttachments.tsx @@ -0,0 +1,495 @@ +"use client"; + +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type DragEvent, + type ReactNode, +} from "react"; +import { Loader2, Paperclip, Upload, X } from "lucide-react"; +import { + apiDeletePostFile, + apiUpdatePostFilePrice, + apiUploadPostFile, + type PostAttachment, +} from "@/lib/api"; +import { fileExtBadge, fileKind } from "@/lib/fileKind"; +import { buildAcceptAttr, rejectPostFile } from "@/lib/postFile"; +import { toast } from "@/lib/toast"; + +function formatSize(n: number) { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +type UploadLimits = { + maxMB: number; + maxCount: number; + extLimit: boolean; + exts: string[]; +}; + +/** 待上传(仅本地,未请求服务器) */ +type PendingItem = { + key: string; + file: File; + name: string; + size: number; +}; + +/** 正在上传的进度项 */ +type QueueItem = { + key: string; + name: string; + size: number; + progress: number; + error?: string; +}; + +type Props = { + attachments: PostAttachment[]; + onChange: (next: PostAttachment[]) => void; + limits: UploadLimits; + disabled?: boolean; + /** 任一上传进行中时通知父级禁用发布 */ + onBusyChange?: (busy: boolean) => void; + /** 待传(未点上传)数量变化,供父级提交前拦截提示 */ + onPendingChange?: (count: number) => void; + /** 供底栏滚入定位 */ + sectionRef?: React.RefObject; +}; + +export default function ComposeAttachments({ + attachments, + onChange, + limits, + disabled, + onBusyChange, + onPendingChange, + sectionRef, +}: Props) { + const fileRef = useRef(null); + const dropRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [pending, setPending] = useState([]); + const [queue, setQueue] = useState([]); + const [uploading, setUploading] = useState(false); + const priceTimers = useRef>>(new Map()); + const titleId = useId(); + + const busy = uploading || queue.length > 0; + useEffect(() => { + onBusyChange?.(busy); + }, [busy, onBusyChange]); + + useEffect(() => { + onPendingChange?.(pending.length); + }, [pending.length, onPendingChange]); + + useEffect(() => { + return () => { + priceTimers.current.forEach((t) => clearTimeout(t)); + priceTimers.current.clear(); + onPendingChange?.(0); + }; + }, [onPendingChange]); + + const accept = buildAcceptAttr(limits.exts, limits.extLimit); + + const hintLine = limits.extLimit + ? `单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个;允许 ${ + limits.exts.length > 8 + ? limits.exts.slice(0, 8).map((e) => `.${e}`).join(" ") + "…" + : limits.exts.map((e) => `.${e}`).join(" ") || "(列表为空)" + }` + : `未限制类型,单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个`; + + /** 仅加入待传列表,不立刻上传 */ + const stageFiles = useCallback( + (files: FileList | File[]) => { + const list = Array.from(files); + if (!list.length || uploading || disabled) return; + + const next: PendingItem[] = []; + let currentCount = attachments.length + pending.length; + for (const file of list) { + const rej = rejectPostFile(file, { + limit: limits.extLimit, + exts: limits.exts, + maxMB: limits.maxMB, + maxCount: limits.maxCount, + currentCount, + }); + if (rej.reason) { + toast(rej.message || "无法添加", "error"); + continue; + } + next.push({ + key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${file.name}`, + file, + name: file.name, + size: file.size, + }); + currentCount++; + } + if (!next.length) return; + setPending((prev) => [...prev, ...next]); + if (fileRef.current) fileRef.current.value = ""; + }, + [attachments.length, disabled, limits, pending.length, uploading] + ); + + const removePending = (key: string) => { + setPending((prev) => prev.filter((p) => p.key !== key)); + }; + + const clearPending = () => { + if (uploading) return; + setPending([]); + }; + + /** 用户确认后再真正上传 */ + const uploadPending = useCallback(async () => { + if (!pending.length || uploading || disabled) return; + + const batch = [...pending]; + setPending([]); + setUploading(true); + const items: QueueItem[] = batch.map((p) => ({ + key: p.key, + name: p.name, + size: p.size, + progress: 8, + })); + setQueue(items); + + const added: PostAttachment[] = []; + let liveCount = attachments.length; + for (let i = 0; i < batch.length; i++) { + const item = batch[i]; + const key = item.key; + const rej = rejectPostFile(item.file, { + limit: limits.extLimit, + exts: limits.exts, + maxMB: limits.maxMB, + maxCount: limits.maxCount, + currentCount: liveCount, + }); + if (rej.reason) { + setQueue((prev) => + prev.map((q) => + q.key === key ? { ...q, error: rej.message || "无法上传", progress: 100 } : q + ) + ); + toast(rej.message || "无法上传", "error"); + continue; + } + setQueue((prev) => + prev.map((q) => (q.key === key ? { ...q, progress: 35 } : q)) + ); + try { + const res = await apiUploadPostFile(item.file, 0); + if (res.attachment) { + added.push(res.attachment); + liveCount++; + setQueue((prev) => + prev.map((q) => (q.key === key ? { ...q, progress: 100 } : q)) + ); + } else { + setQueue((prev) => + prev.map((q) => + q.key === key ? { ...q, error: res.error || "上传失败", progress: 100 } : q + ) + ); + toast(res.error || "上传失败", "error"); + } + } catch { + setQueue((prev) => + prev.map((q) => + q.key === key ? { ...q, error: "上传失败", progress: 100 } : q + ) + ); + toast("上传失败", "error"); + } + } + + if (added.length) { + onChange([...attachments, ...added]); + toast( + added.length === 1 ? "附件已上传" : `已上传 ${added.length} 个附件`, + "ok" + ); + } + setQueue([]); + setUploading(false); + }, [attachments, disabled, limits, onChange, pending, uploading]); + + const removeAttachment = async (id: number) => { + const prev = attachments; + onChange(prev.filter((a) => a.id !== id)); + try { + await apiDeletePostFile(id); + } catch { + onChange(prev); + toast("移除失败", "error"); + } + }; + + const setAttPrice = (id: number, price: number) => { + const p = Math.max(0, Math.min(100000, Math.floor(price) || 0)); + onChange( + attachments.map((a) => (a.id === id ? { ...a, price_points: p } : a)) + ); + const prevSnapshot = attachments; + const prevPrice = prevSnapshot.find((a) => a.id === id)?.price_points ?? 0; + const timers = priceTimers.current; + const old = timers.get(id); + if (old) clearTimeout(old); + timers.set( + id, + setTimeout(async () => { + try { + await apiUpdatePostFilePrice(id, p); + } catch { + onChange( + prevSnapshot.map((a) => + a.id === id ? { ...a, price_points: prevPrice } : a + ) + ); + toast("定价保存失败", "error"); + } + }, 400) + ); + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer.files?.length) stageFiles(e.dataTransfer.files); + }; + + const sectionProps = { + ref: sectionRef as React.RefObject, + className: "j13-compose-section j13-compose-attach-section", + "aria-labelledby": titleId, + id: "j13-compose-attachments", + }; + + const pickDisabled = disabled || uploading; + const canUpload = pending.length > 0 && !uploading && !disabled; + + return ( +
+
+

+ + 附件 + + {attachments.length}/{limits.maxCount} + +

+
+ +
{ + e.preventDefault(); + if (!pickDisabled) setDragOver(true); + }} + onDragOver={(e) => { + e.preventDefault(); + if (!pickDisabled) setDragOver(true); + }} + onDragLeave={(e) => { + if (!dropRef.current?.contains(e.relatedTarget as Node)) setDragOver(false); + }} + onDrop={onDrop} + onClick={() => { + if (!pickDisabled) fileRef.current?.click(); + }} + role="button" + tabIndex={pickDisabled ? -1 : 0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (!pickDisabled) fileRef.current?.click(); + } + }} + aria-label="选择附件:拖拽或点击添加待传文件" + > + +

+ {dragOver ? "放开以加入待传列表" : "拖到此处或点击选择文件"} +

+

{hintLine}

+

选好后会在下方出现醒目的「上传」按钮 · 可设积分下载

+ { + if (e.target.files?.length) stageFiles(e.target.files); + }} + onClick={(e) => e.stopPropagation()} + /> +
+ + {pending.length > 0 ? ( +
+
+

+ 已选 {pending.length} 个文件 · 尚未上传 +

+ +
+
    + {pending.map((p) => ( +
  • + + {fileExtBadge(p.name)} + +
    +
    {p.name}
    +
    {formatSize(p.size)}
    +
    + +
  • + ))} +
+ +
+ ) : null} + + {queue.length > 0 ? ( +
    + {queue.map((q) => ( +
  • + + … + +
    +
    {q.name}
    +
    + {q.error || formatSize(q.size)} + {uploading && !q.error ? ( + + ) : null} +
    +
    + +
    +
    +
  • + ))} +
+ ) : null} + + {attachments.length === 0 && pending.length === 0 && queue.length === 0 ? ( +

暂无附件 · 选择文件后点上传

+ ) : attachments.length > 0 ? ( +
    + {attachments.map((a) => ( +
  • + + {fileExtBadge(a.name)} + +
    +
    {a.name}
    +
    {formatSize(a.size)}
    +
    + + +
  • + ))} +
+ ) : null} +
+ ); +} + +/** 底栏「附件 N/M」入口 */ +export function ComposeAttachAction({ + count, + max, + onClick, + children, +}: { + count: number; + max: number; + onClick: () => void; + children?: ReactNode; +}) { + return ( + + ); +} diff --git a/frontend/components/MarkdownEditor.tsx b/frontend/components/MarkdownEditor.tsx index a722ec0..1b897a3 100644 --- a/frontend/components/MarkdownEditor.tsx +++ b/frontend/components/MarkdownEditor.tsx @@ -47,6 +47,8 @@ type MarkdownEditorProps = { disabled?: boolean; onSubmitShortcut?: () => void; id?: string; + /** 正文插图体积上限(MB),缺省 5 */ + imageMaxMB?: number; }; type EditorMode = "split" | "write" | "preview"; @@ -107,6 +109,7 @@ export default function MarkdownEditor({ disabled, onSubmitShortcut, id, + imageMaxMB = 5, }: MarkdownEditorProps) { const taRef = useRef(null); const fileRef = useRef(null); @@ -227,8 +230,9 @@ export default function MarkdownEditor({ toast("仅支持 JPEG / PNG / WebP", "error"); return; } - if (file.size > 5 * 1024 * 1024) { - toast("图片不能超过 5MB", "error"); + const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024; + if (file.size > maxBytes) { + toast(`图片不能超过 ${imageMaxMB}MB`, "error"); return; } setUploading(true); diff --git a/frontend/components/PostAttachments.tsx b/frontend/components/PostAttachments.tsx index 747775b..42a298c 100644 --- a/frontend/components/PostAttachments.tsx +++ b/frontend/components/PostAttachments.tsx @@ -9,6 +9,7 @@ import { postAttachmentDownloadURL, type PostAttachment, } from "@/lib/api"; +import { fileExtBadge, fileKind } from "@/lib/fileKind"; import { toast } from "@/lib/toast"; import Modal from "@/components/Modal"; @@ -22,52 +23,6 @@ function formatPoints(n: number) { return n.toLocaleString("zh-CN"); } -function fileExt(name: string) { - const i = name.lastIndexOf("."); - if (i <= 0 || i === name.length - 1) return "FILE"; - const ext = name.slice(i + 1).replace(/[^a-zA-Z0-9]/g, ""); - if (!ext || ext.length > 5) return "FILE"; - return ext.toUpperCase(); -} - -function fileKind(name: string, mime: string): "image" | "archive" | "doc" | "media" | "file" { - const m = (mime || "").toLowerCase(); - const ext = (name.split(".").pop() || "").toLowerCase(); - if ( - m.startsWith("image/") || - ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "ico", "heic"].includes(ext) - ) { - return "image"; - } - if ( - m.includes("zip") || - m.includes("rar") || - m.includes("7z") || - m.includes("compress") || - ["zip", "rar", "7z", "tar", "gz", "bz2", "xz"].includes(ext) - ) { - return "archive"; - } - if ( - m.startsWith("audio/") || - m.startsWith("video/") || - ["mp3", "wav", "flac", "aac", "ogg", "mp4", "mkv", "webm", "mov", "avi"].includes(ext) - ) { - return "media"; - } - if ( - m.includes("pdf") || - m.includes("word") || - m.includes("text") || - m.includes("sheet") || - m.includes("excel") || - ["pdf", "doc", "docx", "txt", "md", "xls", "xlsx", "ppt", "pptx", "csv"].includes(ext) - ) { - return "doc"; - } - return "file"; -} - /** 把已拿到的文件交给浏览器下载栏(付费首次须先 fetch 才能处理 402) */ function triggerBrowserDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); @@ -92,7 +47,7 @@ function AttachmentMeta({ return ( <> - {fileExt(a.name)} + {fileExtBadge(a.name)} {a.name} @@ -312,7 +267,7 @@ export default function PostAttachments({ data-kind={fileKind(pending.name, pending.mime)} aria-hidden > - {fileExt(pending.name)} + {fileExtBadge(pending.name)}

{pending.name}

diff --git a/frontend/components/PostComposer.tsx b/frontend/components/PostComposer.tsx index 70e62c3..a32d56e 100644 --- a/frontend/components/PostComposer.tsx +++ b/frontend/components/PostComposer.tsx @@ -3,17 +3,16 @@ import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { X, Paperclip, Download } from "lucide-react"; +import { X } from "lucide-react"; import { apiCreatePost, apiUpdatePost, - apiUploadPostFile, - apiDeletePostFile, - apiUpdatePostFilePrice, apiGetPoints, + apiGetSettings, fetchBoards, type Board, type PostAttachment, + DEFAULT_ATTACHMENT_EXTS, } from "@/lib/api"; import { POST_TYPES, @@ -26,11 +25,15 @@ import { import { toast } from "@/lib/toast"; import MarkdownEditor from "@/components/MarkdownEditor"; import BoardPicker, { BoardBadge } from "@/components/BoardPicker"; +import ComposeAttachments, { + ComposeAttachAction, +} from "@/components/ComposeAttachments"; import DeadlinePicker, { type DeadlineMode, defaultCustomLocal, localInputToRFC3339, } from "@/components/DeadlinePicker"; +import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; const TITLE_MAX = 256; const DRAFT_KEY = "j13-compose-draft"; @@ -105,12 +108,6 @@ function writeDraft(d: ComposeDraft) { } } -function formatSize(n: number) { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${(n / 1024 / 1024).toFixed(1)} MB`; -} - type CreateProps = { mode: "create"; initialBoardId?: number; @@ -260,6 +257,14 @@ export default function PostComposer(props: PostComposerProps) { isCreate ? [] : props.initialAttachments || [] ); const [fileBusy, setFileBusy] = useState(false); + const [pendingFileCount, setPendingFileCount] = useState(0); + const [attachLimits, setAttachLimits] = useState({ + maxMB: 20, + maxCount: 10, + extLimit: true, + exts: [...DEFAULT_ATTACHMENT_EXTS], + imageMaxMB: 5, + }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); /** 发帖页余额:仅新建悬赏时拉取,用于就地提示积分不足 */ @@ -268,13 +273,55 @@ export default function PostComposer(props: PostComposerProps) { const draftTimer = useRef | null>(null); const skipDraftSave = useRef(true); const formRef = useRef(null); - const fileRef = useRef(null); + const attachSectionRef = useRef(null); const actionsErrorRef = useRef(null); const bountyFieldRef = useRef(null); - const pendingErrorFocus = useRef<"actions" | "bounty" | null>(null); + const pendingErrorFocus = useRef<"actions" | "bounty" | "attachments" | null>(null); const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0; + useEffect(() => { + let cancelled = false; + apiGetSettings() + .then((s) => { + if (cancelled) return; + setAttachLimits({ + maxMB: s.attachment_max_mb, + maxCount: s.attachment_max_count, + extLimit: s.attachment_ext_limit, + exts: s.attachment_exts, + imageMaxMB: s.image_max_mb, + }); + }) + .catch(() => { + /* 用默认限额 */ + }); + return realtime.on(RT_SETTINGS_CHANGED, (data) => { + setAttachLimits((prev) => ({ + maxMB: + typeof data.attachment_max_mb === "number" && data.attachment_max_mb >= 1 + ? data.attachment_max_mb + : prev.maxMB, + maxCount: + typeof data.attachment_max_count === "number" && + data.attachment_max_count >= 1 + ? data.attachment_max_count + : prev.maxCount, + extLimit: + typeof data.attachment_ext_limit === "boolean" + ? data.attachment_ext_limit + : prev.extLimit, + exts: Array.isArray(data.attachment_exts) + ? data.attachment_exts + : prev.exts, + imageMaxMB: + typeof data.image_max_mb === "number" && data.image_max_mb >= 1 + ? data.image_max_mb + : prev.imageMaxMB, + })); + }); + }, []); + useEffect(() => { if (!isCreate) return; let cancelled = false; @@ -459,44 +506,28 @@ export default function PostComposer(props: PostComposerProps) { return typeMeta.trim(); }; - const onPickFile = async (file: File | null) => { - if (!file || fileBusy) return; - if (attachments.length >= 10) { - toast("最多 10 个附件", "error"); - return; - } - setFileBusy(true); - try { - const res = await apiUploadPostFile(file, 0); - if (res.attachment) { - setAttachments((prev) => [...prev, res.attachment!]); - toast("附件已上传", "ok"); - } else { - toast(res.error || "上传失败", "error"); - } - } catch { - toast("上传失败", "error"); - } finally { - setFileBusy(false); - if (fileRef.current) fileRef.current.value = ""; - } + const scrollToAttachments = () => { + const el = + attachSectionRef.current || + document.getElementById("j13-compose-attachments"); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + window.setTimeout(() => { + const drop = el?.querySelector(".j13-compose-drop") as HTMLElement | null; + drop?.focus(); + }, 320); }; - const removeAttachment = async (id: number) => { - setAttachments((prev) => prev.filter((a) => a.id !== id)); - try { - await apiDeletePostFile(id); - } catch { - /* 本地已移除 */ - } - }; - - const setAttPrice = async (id: number, price: number) => { - const p = Math.max(0, Math.min(100000, Math.floor(price) || 0)); - setAttachments((prev) => - prev.map((a) => (a.id === id ? { ...a, price_points: p } : a)) - ); - await apiUpdatePostFilePrice(id, p); + const scrollToPendingUpload = () => { + const el = + attachSectionRef.current || + document.getElementById("j13-compose-attachments"); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + window.setTimeout(() => { + const btn = el?.querySelector( + ".j13-compose-att-upload-btn" + ) as HTMLButtonElement | null; + btn?.focus(); + }, 320); }; const bountyShort = @@ -508,6 +539,7 @@ export default function PostComposer(props: PostComposerProps) { const canSubmit = !loading && + !fileBusy && title.trim().length > 0 && content.trim().length > 0 && (isCreate ? boardsReady && boardId > 0 : true) && @@ -517,17 +549,24 @@ export default function PostComposer(props: PostComposerProps) { (typeof bountyPoints === "number" && bountyPoints > 0)) && (postType !== "lottery" || lotteryPrizesReady); - /** 提交失败:底部操作区可见 + Toast;积分类滚到悬赏输入 */ - const reportSubmitError = useCallback((msg: string, focus: "actions" | "bounty" = "actions") => { - pendingErrorFocus.current = focus; - setError(msg); - toast(msg, "error"); - }, []); + /** 提交失败:底部操作区可见 + Toast;积分类滚到悬赏输入;待传附件滚到上传按钮 */ + const reportSubmitError = useCallback( + (msg: string, focus: "actions" | "bounty" | "attachments" = "actions") => { + pendingErrorFocus.current = focus; + setError(msg); + toast(msg, "error"); + }, + [] + ); useEffect(() => { if (!error) return; const focus = pendingErrorFocus.current ?? "actions"; pendingErrorFocus.current = null; + if (focus === "attachments") { + scrollToPendingUpload(); + return; + } const el = focus === "bounty" && bountyFieldRef.current ? bountyFieldRef.current @@ -535,8 +574,21 @@ export default function PostComposer(props: PostComposerProps) { el?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [error]); + // 待传清零后收起「尚未上传」提示(已上传或已移除) + useEffect(() => { + if (pendingFileCount > 0) return; + setError((prev) => (prev.includes("尚未上传") ? "" : prev)); + }, [pendingFileCount]); + const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); + if (pendingFileCount > 0) { + reportSubmitError( + `还有 ${pendingFileCount} 个附件尚未上传,请先上传或移除`, + "attachments" + ); + return; + } if ( isCreate && deadlineMode === "custom" && @@ -1177,6 +1229,7 @@ export default function PostComposer(props: PostComposerProps) { onChange={setContent} disabled={loading} rows={18} + imageMaxMB={attachLimits.imageMaxMB} placeholder={ "从这里开始写正文…\n\n工具栏可插入格式、代码块、链接、图片与隐藏内容(登录/回复/积分/密码可见)。支持粘贴与拖拽上传图片;默认双栏实时预览。" } @@ -1187,72 +1240,15 @@ export default function PostComposer(props: PostComposerProps) {
- {/* 附件 */} -
-

附件

-
-
-

单文件 ≤20MB,最多 10 个;可设免费或积分下载

- - void onPickFile(e.target.files?.[0] ?? null)} - /> -
- {attachments.length === 0 ? ( -

暂无附件

- ) : ( -
    - {attachments.map((a) => ( -
  • - -
    -
    {a.name}
    -
    {formatSize(a.size)}
    -
    - - -
  • - ))} -
- )} -
-
+
@@ -1272,6 +1268,11 @@ export default function PostComposer(props: PostComposerProps) {

)}
+ {isCreate ? ( 取消 @@ -1294,9 +1295,11 @@ export default function PostComposer(props: PostComposerProps) { ? isCreate ? "发布中…" : "保存中…" - : isCreate - ? "发布帖子" - : "保存修改"} + : fileBusy + ? "上传中…" + : isCreate + ? "发布帖子" + : "保存修改"}
diff --git a/frontend/components/admin/AdminExtChipEditor.tsx b/frontend/components/admin/AdminExtChipEditor.tsx new file mode 100644 index 0000000..f26467f --- /dev/null +++ b/frontend/components/admin/AdminExtChipEditor.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { Plus, RotateCcw, X } from "lucide-react"; +import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { DEFAULT_ATTACHMENT_EXTS } from "@/lib/api"; +import ConfirmDialog from "@/components/ConfirmDialog"; + +/** 分组一键加入(只补缺,不删已有) */ +export const ATTACHMENT_EXT_GROUPS: { id: string; label: string; exts: string[] }[] = [ + { + id: "doc", + label: "文档", + exts: ["pdf", "txt", "md", "csv", "json", "doc", "docx", "xls", "xlsx", "ppt", "pptx"], + }, + { + id: "archive", + label: "压缩包", + exts: ["zip", "7z", "rar", "tar", "gz", "tgz"], + }, + { + id: "image", + label: "图片", + exts: ["jpg", "jpeg", "png", "webp", "gif"], + }, + { + id: "media", + label: "音视频", + exts: ["mp3", "wav", "ogg", "mp4", "webm"], + }, + { + id: "install", + label: "安装包", + exts: ["exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll"], + }, + { + id: "web", + label: "网页与脚本", + exts: ["html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh"], + }, +]; + +function parseInputTokens(raw: string): string[] { + return raw + .split(/[,,;\s]+/) + .map((s) => s.trim().toLowerCase().replace(/^\./, "")) + .filter(Boolean); +} + +function isValidExt(e: string): boolean { + return /^[a-z0-9]{1,16}$/.test(e); +} + +/** + * 附件扩展名芯片编辑器:增删、批量输入、分组补缺、恢复默认。 + * 列表变更需显式保存(由父级 dirty + 保存按钮控制)。 + */ +export default function AdminExtChipEditor({ + value, + onChange, + disabled, + maxCount = 80, +}: { + value: string[]; + onChange: (next: string[]) => void; + disabled?: boolean; + maxCount?: number; +}) { + const [draft, setDraft] = useState(""); + const [hint, setHint] = useState(""); + const [confirmReset, setConfirmReset] = useState(false); + const inputRef = useRef(null); + + const setHintBrief = useCallback((msg: string) => { + setHint(msg); + window.setTimeout(() => setHint(""), 2200); + }, []); + + const addExts = useCallback( + (raw: string[]) => { + if (disabled) return; + const next = [...value]; + const seen = new Set(next); + let added = 0; + let invalid = 0; + for (const token of raw) { + if (!isValidExt(token)) { + invalid++; + continue; + } + if (seen.has(token)) continue; + if (next.length >= maxCount) { + setHintBrief(`最多 ${maxCount} 个扩展名`); + break; + } + seen.add(token); + next.push(token); + added++; + } + if (invalid && !added) setHintBrief("扩展名仅允许字母与数字"); + else if (invalid) setHintBrief(`已加入 ${added} 个,跳过非法项`); + else if (!added && raw.length) setHintBrief("已在列表中"); + if (added) onChange(next); + setDraft(""); + }, + [disabled, maxCount, onChange, setHintBrief, value] + ); + + const removeAt = (idx: number) => { + if (disabled) return; + onChange(value.filter((_, i) => i !== idx)); + }; + + const mergeGroup = (exts: string[]) => { + if (disabled) return; + const seen = new Set(value); + const next = [...value]; + for (const e of exts) { + if (seen.has(e)) continue; + if (next.length >= maxCount) break; + seen.add(e); + next.push(e); + } + onChange(next); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addExts(parseInputTokens(draft)); + return; + } + if (e.key === "Backspace" && draft === "" && value.length > 0) { + e.preventDefault(); + removeAt(value.length - 1); + } + }; + + const emptyWarn = value.length === 0; + + const groupButtons = useMemo( + () => + ATTACHMENT_EXT_GROUPS.map((g) => ( + + )), + // eslint-disable-next-line react-hooks/exhaustive-deps + [disabled, value] + ); + + return ( +
+ {emptyWarn ? ( +

+ 当前列表为空:开启限制时将无法上传任何附件 +

+ ) : null} + +
+ {groupButtons} + +
+ +
inputRef.current?.focus()} + role="group" + aria-label="扩展名列表" + > + {value.map((ext, idx) => ( + + .{ext} + + + ))} + + + setDraft(e.target.value)} + onKeyDown={onKeyDown} + onBlur={() => { + if (draft.trim()) addExts(parseInputTokens(draft)); + }} + /> + +
+ + {hint ? ( +

+ {hint} +

+ ) : ( +

+ 支持 .msi / msi{" "} + 或逗号批量;最多 {maxCount} 个 +

+ )} + + setConfirmReset(false)} + onConfirm={() => { + onChange([...DEFAULT_ATTACHMENT_EXTS]); + setConfirmReset(false); + }} + /> +
+ ); +} diff --git a/frontend/components/admin/index.ts b/frontend/components/admin/index.ts index 39a6988..3c6eed6 100644 --- a/frontend/components/admin/index.ts +++ b/frontend/components/admin/index.ts @@ -14,3 +14,5 @@ export { default as AdminIconButton } from "./AdminIconButton"; export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip"; export type { AdminStatusTone } from "./AdminStatusChip"; export { default as AdminShell } from "./AdminShell"; +export { default as AdminExtChipEditor } from "./AdminExtChipEditor"; +export { ATTACHMENT_EXT_GROUPS } from "./AdminExtChipEditor"; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 0689ae8..fc59a09 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -616,8 +616,29 @@ export interface PublicSettings { anim_chrome: boolean; /** 帖内 Markdown 外链是否新标签打开;缺省 true */ post_link_new_tab: boolean; + /** 是否限制附件扩展名;缺省 true */ + attachment_ext_limit: boolean; + /** 允许的扩展名(无点);缺省论坛向列表 */ + attachment_exts: string[]; + /** 附件单文件上限 MB;缺省 20 */ + attachment_max_mb: number; + /** 每帖附件个数;缺省 10 */ + attachment_max_count: number; + /** 正文插图上限 MB;缺省 5 */ + image_max_mb: number; } +/** 与后端 DefaultAttachmentExts 保持同步 */ +export const DEFAULT_ATTACHMENT_EXTS: string[] = [ + "pdf", "txt", "md", "csv", "json", + "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "zip", "7z", "rar", "tar", "gz", "tgz", + "jpg", "jpeg", "png", "webp", "gif", + "mp3", "wav", "ogg", "mp4", "webm", + "exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll", + "html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh", +]; + const DEFAULT_PUBLIC_SETTINGS: PublicSettings = { accent: "", trust_reviewed_publish: true, @@ -634,9 +655,45 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = { anim_smooth_scroll: true, anim_chrome: true, post_link_new_tab: true, + attachment_ext_limit: true, + attachment_exts: [...DEFAULT_ATTACHMENT_EXTS], + attachment_max_mb: 20, + attachment_max_count: 10, + image_max_mb: 5, }; +function normalizeAttachmentExts(raw: unknown): string[] { + if (!Array.isArray(raw)) return [...DEFAULT_ATTACHMENT_EXTS]; + const seen = new Set(); + const out: string[] = []; + for (const item of raw) { + const e = String(item || "") + .trim() + .toLowerCase() + .replace(/^\./, ""); + if (!/^[a-z0-9]{1,16}$/.test(e) || seen.has(e)) continue; + seen.add(e); + out.push(e); + if (out.length >= 80) break; + } + return out; +} + function normalizePublicSettings(data: Partial | null | undefined): PublicSettings { + const maxMB = + typeof data?.attachment_max_mb === "number" && data.attachment_max_mb >= 1 + ? Math.round(data.attachment_max_mb) + : DEFAULT_PUBLIC_SETTINGS.attachment_max_mb; + const maxCount = + typeof data?.attachment_max_count === "number" && + data.attachment_max_count >= 1 && + data.attachment_max_count <= 20 + ? Math.round(data.attachment_max_count) + : DEFAULT_PUBLIC_SETTINGS.attachment_max_count; + const imageMB = + typeof data?.image_max_mb === "number" && data.image_max_mb >= 1 + ? Math.round(data.image_max_mb) + : DEFAULT_PUBLIC_SETTINGS.image_max_mb; return { accent: data?.accent ?? "", trust_reviewed_publish: data?.trust_reviewed_publish !== false, @@ -664,6 +721,14 @@ function normalizePublicSettings(data: Partial | null | undefine anim_smooth_scroll: data?.anim_smooth_scroll !== false, anim_chrome: data?.anim_chrome !== false, post_link_new_tab: data?.post_link_new_tab !== false, + attachment_ext_limit: data?.attachment_ext_limit !== false, + attachment_exts: + data?.attachment_exts !== undefined + ? normalizeAttachmentExts(data.attachment_exts) + : [...DEFAULT_ATTACHMENT_EXTS], + attachment_max_mb: maxMB, + attachment_max_count: maxCount, + image_max_mb: imageMB, }; } @@ -1042,7 +1107,20 @@ export async function apiUploadPostFile( headers: clientHeaders(), body: fd, }); - return res.json(); + const ct = res.headers.get("content-type") || ""; + if (!ct.includes("application/json")) { + if (res.status === 413) return { error: "附件过大" }; + if (res.status >= 500) return { error: "上传失败,请稍后重试" }; + return { error: "上传失败" }; + } + const data = (await res.json().catch(() => ({}))) as { + attachment?: PostAttachment; + error?: string; + }; + if (!res.ok) { + return { error: data.error || (res.status === 413 ? "附件过大" : "上传失败") }; + } + return data; } export async function apiDeletePostFile(id: number) { @@ -1272,7 +1350,21 @@ export async function apiUploadPostImage( headers: clientHeaders(), body: form, }); - return res.json(); + const ct = res.headers.get("content-type") || ""; + if (!ct.includes("application/json")) { + if (res.status === 413) return { error: "图片过大" }; + if (res.status >= 500) return { error: "上传失败,请稍后重试" }; + return { error: "上传失败" }; + } + const data = (await res.json().catch(() => ({}))) as { + url?: string; + attachment?: MediaAttachment; + error?: string; + }; + if (!res.ok) { + return { error: data.error || (res.status === 413 ? "图片过大" : "上传失败") }; + } + return data; } // 选用一张本人历史上传的头像 @@ -1804,6 +1896,11 @@ export type UpdateSiteSettingsBody = { anim_smooth_scroll?: boolean; anim_chrome?: boolean; post_link_new_tab?: boolean; + attachment_ext_limit?: boolean; + attachment_exts?: string[]; + attachment_max_mb?: number; + attachment_max_count?: number; + image_max_mb?: number; }; // 更新站点设置(字段可选);accent 传空串恢复默认主题色 diff --git a/frontend/lib/fileKind.ts b/frontend/lib/fileKind.ts new file mode 100644 index 0000000..5af17f9 --- /dev/null +++ b/frontend/lib/fileKind.ts @@ -0,0 +1,49 @@ +/** 附件类型徽章分类(详情页与发帖编辑共用) */ + +export type FileKind = "image" | "archive" | "doc" | "media" | "file"; + +export function fileKind(name: string, mime: string): FileKind { + const m = (mime || "").toLowerCase(); + const ext = (name.split(".").pop() || "").toLowerCase(); + if ( + m.startsWith("image/") || + ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "ico", "heic"].includes(ext) + ) { + return "image"; + } + if ( + m.includes("zip") || + m.includes("rar") || + m.includes("7z") || + m.includes("compress") || + ["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz"].includes(ext) + ) { + return "archive"; + } + if ( + m.startsWith("audio/") || + m.startsWith("video/") || + ["mp3", "wav", "flac", "aac", "ogg", "mp4", "mkv", "webm", "mov", "avi"].includes(ext) + ) { + return "media"; + } + if ( + m.includes("pdf") || + m.includes("word") || + m.includes("text") || + m.includes("sheet") || + m.includes("excel") || + ["pdf", "doc", "docx", "txt", "md", "xls", "xlsx", "ppt", "pptx", "csv", "json"].includes(ext) + ) { + return "doc"; + } + return "file"; +} + +export function fileExtBadge(name: string): string { + const i = name.lastIndexOf("."); + if (i <= 0 || i === name.length - 1) return "FILE"; + const ext = name.slice(i + 1).replace(/[^a-zA-Z0-9]/g, ""); + if (!ext || ext.length > 5) return "FILE"; + return ext.toUpperCase(); +} diff --git a/frontend/lib/postFile.ts b/frontend/lib/postFile.ts new file mode 100644 index 0000000..413bbf2 --- /dev/null +++ b/frontend/lib/postFile.ts @@ -0,0 +1,58 @@ +/** 帖子附件客户端校验(与后端 setting / fileallow 对齐;安全边界仍在 Go) */ + +export const FILE_MAX_MB_DEFAULT = 20; +export const FILE_MAX_COUNT_DEFAULT = 10; + +export function normalizeExt(nameOrExt: string): string { + const base = nameOrExt.includes("/") + ? nameOrExt.split(/[/\\]/).pop() || "" + : nameOrExt; + const i = base.lastIndexOf("."); + const ext = (i >= 0 ? base.slice(i + 1) : base).trim().toLowerCase(); + return ext.replace(/[^a-z0-9]/g, "").slice(0, 16); +} + +export function buildAcceptAttr(exts: string[], limit: boolean): string | undefined { + if (!limit) return undefined; + if (!exts.length) return undefined; + return exts.map((e) => `.${e}`).join(","); +} + +export function isAllowedPostFileName( + name: string, + opts: { limit: boolean; exts: string[] } +): boolean { + if (!opts.limit) return true; + const ext = normalizeExt(name); + if (!ext) return false; + return opts.exts.includes(ext); +} + +export function formatMB(n: number): string { + return `${n}MB`; +} + +export type PostFileRejectReason = "size" | "type" | "count" | null; + +export function rejectPostFile( + file: File, + opts: { + limit: boolean; + exts: string[]; + maxMB: number; + maxCount: number; + currentCount: number; + } +): { reason: PostFileRejectReason; message?: string } { + if (opts.currentCount >= opts.maxCount) { + return { reason: "count", message: `最多 ${opts.maxCount} 个附件` }; + } + const maxBytes = opts.maxMB * 1024 * 1024; + if (file.size > maxBytes) { + return { reason: "size", message: `附件不能超过 ${opts.maxMB}MB` }; + } + if (!isAllowedPostFileName(file.name, opts)) { + return { reason: "type", message: "不支持该附件格式" }; + } + return { reason: null }; +} diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts index 3c8dbff..a8c5ed3 100644 --- a/frontend/lib/realtime.ts +++ b/frontend/lib/realtime.ts @@ -54,6 +54,11 @@ export interface RtSettingsData { anim_smooth_scroll?: boolean; anim_chrome?: boolean; post_link_new_tab?: boolean; + attachment_ext_limit?: boolean; + attachment_exts?: string[]; + attachment_max_mb?: number; + attachment_max_count?: number; + image_max_mb?: number; } // 群聊新消息:data 为落库的 ChatMessage(含 sender) diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 3d2e449..b89de0e 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -11,6 +11,10 @@ const nextConfig: NextConfig = { { protocol: "http", hostname: "localhost" }, ], }, + experimental: { + // rewrite/proxy 默认只缓冲 10MB;附件上限由 Go 站点设置决定,此处留足余量(含 multipart 开销) + proxyClientMaxBodySize: "512mb", + }, async rewrites() { return [ {