feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, f, price)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取文件失败"})
|
||||
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
|
||||
}
|
||||
att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, data, price)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ type updateSettingsRequest struct {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
if fh.Size > service.ImageMaxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"})
|
||||
return
|
||||
}
|
||||
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))
|
||||
att, err := h.Upload.SaveImage(claims.ID, f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
|
||||
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
|
||||
}
|
||||
if len(data) > service.ImageMaxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"})
|
||||
return
|
||||
}
|
||||
|
||||
att, err := h.Upload.SaveImage(claims.ID, data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
36
backend/service/attachment_settings_test.go
Normal file
36
backend/service/attachment_settings_test.go
Normal file
@@ -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)
|
||||
}
|
||||
176
backend/service/fileallow.go
Normal file
176
backend/service/fileallow.go
Normal file
@@ -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("<!doctype"),
|
||||
[]byte("<html"),
|
||||
[]byte("<svg"),
|
||||
[]byte("<?xml"),
|
||||
[]byte("<script"),
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if bytes.HasPrefix(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveAttachmentMIME 根据扩展名与内存内容决定 MIME(小样例/测试用)。
|
||||
func ResolveAttachmentMIME(ext string, data []byte) string {
|
||||
ext = strings.ToLower(ext)
|
||||
if IsActiveContentExt(ext) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if _, ok := RasterImageExts[ext]; ok {
|
||||
if looksLikeMarkupOrScript(data) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if !validRasterImage(bytes.NewReader(data), ext) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return MimeForAttachmentExt(ext)
|
||||
}
|
||||
return MimeForAttachmentExt(ext)
|
||||
}
|
||||
|
||||
// ResolveAttachmentMIMEFile 只读文件头判定 MIME,不把整文件读进内存。
|
||||
func ResolveAttachmentMIMEFile(ext, path string) string {
|
||||
ext = strings.ToLower(ext)
|
||||
if IsActiveContentExt(ext) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, ok := RasterImageExts[ext]; ok {
|
||||
head := make([]byte, 512)
|
||||
n, _ := io.ReadFull(f, head)
|
||||
if n > 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))
|
||||
}
|
||||
70
backend/service/fileallow_test.go
Normal file
70
backend/service/fileallow_test.go
Normal file
@@ -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("<!DOCTYPE html><html></html>"))
|
||||
if m != "application/octet-stream" {
|
||||
t.Fatalf("html mime=%s", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAttachmentMIMEDisguisedPNG(t *testing.T) {
|
||||
m := ResolveAttachmentMIME(".png", []byte("<svg xmlns='http://www.w3.org/2000/svg'></svg>"))
|
||||
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
|
||||
}
|
||||
@@ -4,18 +4,19 @@ 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
|
||||
)
|
||||
|
||||
@@ -23,18 +24,28 @@ var (
|
||||
ErrAttachmentNotFound = errors.New("附件不存在")
|
||||
ErrAttachmentForbidden = 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
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -78,6 +110,11 @@ type PublicSiteSettings struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
@@ -33,12 +35,30 @@ const (
|
||||
type UploadService struct {
|
||||
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
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
return errors.New("图片不能超过 " + strconv.Itoa(mb) + "MB")
|
||||
}
|
||||
|
||||
// 先读魔数头,再与剩余流合并落盘,整图不进内存
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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<SectionId>("basic");
|
||||
|
||||
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
|
||||
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
|
||||
const [savedExts, setSavedExts] = useState<string[]>(() => [...(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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
@@ -310,7 +399,7 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
</AdminSection>
|
||||
</div>
|
||||
|
||||
<div id="settings-content" className="scroll-mt-4">
|
||||
<div id="settings-content" className="scroll-mt-4 flex flex-col gap-4">
|
||||
<AdminSection title="内容与互动" description="控制注册、评论、消息与链接打开方式">
|
||||
<SwitchRow
|
||||
bordered
|
||||
@@ -380,6 +469,137 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
}
|
||||
/>
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection
|
||||
title="附件与上传"
|
||||
description="帖子附件限额、扩展名白名单与正文插图体积;改后经实时通道同步到发帖页"
|
||||
>
|
||||
<SwitchRow
|
||||
bordered
|
||||
title={
|
||||
<>
|
||||
<Paperclip size={15} /> 附件单文件上限
|
||||
</>
|
||||
}
|
||||
description="发帖附件体积上限。过大可能拖垮进程,并须把反代 / Next 请求体上限调到不少于该值。"
|
||||
control={
|
||||
<AdminStepper
|
||||
value={attMaxMB}
|
||||
min={1}
|
||||
max={999999}
|
||||
unit="MB"
|
||||
ariaLabel="附件单文件上限"
|
||||
disabled={savingLimits}
|
||||
onChange={(n) => {
|
||||
setAttMaxMB(n);
|
||||
scheduleLimitSave({ attachment_max_mb: n });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{attMaxMB >= 50 ? (
|
||||
<p className="j13-ext-warn !mt-0 !mb-3" role="status">
|
||||
当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大,否则用户仍会先撞上网关 413。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SwitchRow
|
||||
bordered
|
||||
title={
|
||||
<>
|
||||
<Paperclip size={15} /> 每帖附件个数
|
||||
</>
|
||||
}
|
||||
description="单帖(含草稿)最多可绑定的附件数量。"
|
||||
control={
|
||||
<AdminStepper
|
||||
value={attMaxCount}
|
||||
min={1}
|
||||
max={20}
|
||||
unit="个"
|
||||
ariaLabel="每帖附件个数"
|
||||
disabled={savingLimits}
|
||||
onChange={(n) => {
|
||||
setAttMaxCount(n);
|
||||
scheduleLimitSave({ attachment_max_count: n });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchRow
|
||||
bordered
|
||||
title={
|
||||
<>
|
||||
<ImageIcon size={15} /> 正文插图上限
|
||||
</>
|
||||
}
|
||||
description="Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图会在服务端解析头部。"
|
||||
control={
|
||||
<AdminStepper
|
||||
value={imageMaxMB}
|
||||
min={1}
|
||||
max={999999}
|
||||
unit="MB"
|
||||
ariaLabel="正文插图上限"
|
||||
disabled={savingLimits}
|
||||
onChange={(n) => {
|
||||
setImageMaxMB(n);
|
||||
scheduleLimitSave({ image_max_mb: n });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{imageMaxMB >= 10 ? (
|
||||
<p className="j13-ext-warn !mt-0 !mb-3" role="status">
|
||||
插图 ≥10MB:解码头部更耗内存,建议保持较小值。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SwitchRow
|
||||
bordered
|
||||
title={
|
||||
<>
|
||||
<Paperclip size={15} /> 限制附件扩展名
|
||||
</>
|
||||
}
|
||||
description={
|
||||
extLimit
|
||||
? "仅允许下方列表中的扩展名;安装包 / 脚本 / 网页可按需保留。"
|
||||
: "任意格式可传,仍受体积上限与下载沙箱约束。"
|
||||
}
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={extLimit}
|
||||
disabled={togglingExtLimit}
|
||||
onChange={() => void toggleExtLimit()}
|
||||
label="限制附件扩展名"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-[13.5px] font-medium mb-2" style={{ color: "var(--ink)" }}>
|
||||
允许的扩展名
|
||||
</p>
|
||||
<AdminExtChipEditor
|
||||
value={exts}
|
||||
onChange={setExts}
|
||||
disabled={!extLimit || savingExts}
|
||||
/>
|
||||
<div className="flex justify-end mt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!extsDirty || savingExts || !extLimit}
|
||||
onClick={() => void saveExts()}
|
||||
>
|
||||
{savingExts ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存扩展名
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSection>
|
||||
</div>
|
||||
|
||||
<div id="settings-brand" className="scroll-mt-4">
|
||||
|
||||
@@ -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;
|
||||
|
||||
495
frontend/components/ComposeAttachments.tsx
Normal file
495
frontend/components/ComposeAttachments.tsx
Normal file
@@ -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<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export default function ComposeAttachments({
|
||||
attachments,
|
||||
onChange,
|
||||
limits,
|
||||
disabled,
|
||||
onBusyChange,
|
||||
onPendingChange,
|
||||
sectionRef,
|
||||
}: Props) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [pending, setPending] = useState<PendingItem[]>([]);
|
||||
const [queue, setQueue] = useState<QueueItem[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const priceTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(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<HTMLElement>,
|
||||
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 (
|
||||
<section {...sectionProps}>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h2 id={titleId} className="j13-compose-section-title j13-compose-attach-title !mb-0">
|
||||
<Paperclip size={14} aria-hidden />
|
||||
附件
|
||||
<span className="j13-compose-attach-badge">
|
||||
{attachments.length}/{limits.maxCount}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={`j13-compose-drop${dragOver ? " is-over" : ""}${
|
||||
pickDisabled ? " is-disabled" : ""
|
||||
}`}
|
||||
onDragEnter={(e) => {
|
||||
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="选择附件:拖拽或点击添加待传文件"
|
||||
>
|
||||
<Upload size={22} className="j13-compose-drop-icon" aria-hidden />
|
||||
<p className="j13-compose-drop-lead">
|
||||
{dragOver ? "放开以加入待传列表" : "拖到此处或点击选择文件"}
|
||||
</p>
|
||||
<p className="j13-compose-drop-hint">{hintLine}</p>
|
||||
<p className="j13-compose-drop-hint">选好后会在下方出现醒目的「上传」按钮 · 可设积分下载</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept={accept}
|
||||
disabled={pickDisabled}
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) stageFiles(e.target.files);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<div className="j13-compose-att-pending" role="region" aria-label="待上传附件">
|
||||
<div className="j13-compose-att-pending-head">
|
||||
<p className="j13-compose-att-pending-title">
|
||||
已选 {pending.length} 个文件 · 尚未上传
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={uploading || disabled}
|
||||
onClick={clearPending}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<ul className="j13-compose-att-queue" aria-label="待上传列表">
|
||||
{pending.map((p) => (
|
||||
<li key={p.key} className="j13-compose-att-card">
|
||||
<span className="j13-att-type" data-kind={fileKind(p.name, p.file.type || "")} aria-hidden>
|
||||
{fileExtBadge(p.name)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="j13-compose-att-name truncate">{p.name}</div>
|
||||
<div className="meta text-[12px]">{formatSize(p.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-2"
|
||||
data-tip="从待传列表移除"
|
||||
aria-label={`移除待传 ${p.name}`}
|
||||
disabled={uploading}
|
||||
onClick={() => removePending(p.key)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="btn j13-compose-att-upload-btn"
|
||||
disabled={!canUpload}
|
||||
onClick={() => void uploadPending()}
|
||||
>
|
||||
<Upload size={16} aria-hidden />
|
||||
上传 {pending.length} 个文件
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{queue.length > 0 ? (
|
||||
<ul className="j13-compose-att-queue" aria-label="上传队列">
|
||||
{queue.map((q) => (
|
||||
<li key={q.key} className={`j13-compose-att-card${q.error ? " is-error" : ""}`}>
|
||||
<span className="j13-att-type" data-kind="file" aria-hidden>
|
||||
…
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="j13-compose-att-name truncate">{q.name}</div>
|
||||
<div className="meta text-[12px]">
|
||||
{q.error || formatSize(q.size)}
|
||||
{uploading && !q.error ? (
|
||||
<Loader2 size={12} className="inline ml-1.5 animate-spin" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="j13-compose-att-bar" aria-hidden>
|
||||
<span style={{ width: `${q.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{attachments.length === 0 && pending.length === 0 && queue.length === 0 ? (
|
||||
<p className="meta text-[13px] py-1">暂无附件 · 选择文件后点上传</p>
|
||||
) : attachments.length > 0 ? (
|
||||
<ul className="j13-compose-att-list">
|
||||
{attachments.map((a) => (
|
||||
<li key={a.id} className="j13-compose-att-card">
|
||||
<span className="j13-att-type" data-kind={fileKind(a.name, a.mime)} aria-hidden>
|
||||
{fileExtBadge(a.name)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="j13-compose-att-name truncate">{a.name}</div>
|
||||
<div className="meta text-[12px]">{formatSize(a.size)}</div>
|
||||
</div>
|
||||
<label
|
||||
className={`meta text-[12px] flex items-center gap-1.5 shrink-0${
|
||||
a.price_points > 0 ? " j13-compose-att-paid" : ""
|
||||
}`}
|
||||
>
|
||||
积分
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
value={a.price_points}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setAttPrice(a.id, Number(e.target.value))}
|
||||
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
|
||||
data-tip="0 = 免费下载"
|
||||
aria-label={`${a.name} 下载积分,0 为免费`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-2"
|
||||
data-tip="移除附件"
|
||||
aria-label="移除附件"
|
||||
disabled={disabled}
|
||||
onClick={() => void removeAttachment(a.id)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 底栏「附件 N/M」入口 */
|
||||
export function ComposeAttachAction({
|
||||
count,
|
||||
max,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
count: number;
|
||||
max: number;
|
||||
onClick: () => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm inline-flex items-center gap-1.5"
|
||||
onClick={onClick}
|
||||
data-tip="跳转到附件区"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
附件 {count}/{max}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(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);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<span className="j13-att-type" data-kind={fileKind(a.name, a.mime)} aria-hidden>
|
||||
{fileExt(a.name)}
|
||||
{fileExtBadge(a.name)}
|
||||
</span>
|
||||
<span className="j13-att-main">
|
||||
<span className="j13-att-name">{a.name}</span>
|
||||
@@ -312,7 +267,7 @@ export default function PostAttachments({
|
||||
data-kind={fileKind(pending.name, pending.mime)}
|
||||
aria-hidden
|
||||
>
|
||||
{fileExt(pending.name)}
|
||||
{fileExtBadge(pending.name)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="j13-att-pay-name">{pending.name}</p>
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const skipDraftSave = useRef(true);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const attachSectionRef = useRef<HTMLElement | null>(null);
|
||||
const actionsErrorRef = useRef<HTMLDivElement>(null);
|
||||
const bountyFieldRef = useRef<HTMLDivElement>(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<RtSettingsData>(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") => {
|
||||
/** 提交失败:底部操作区可见 + 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) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 附件 */}
|
||||
<section className="j13-compose-section" aria-label="附件">
|
||||
<h2 className="j13-compose-section-title">附件</h2>
|
||||
<div className="j13-compose-attach shrink-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<p className="meta text-[12.5px] !mb-0">单文件 ≤20MB,最多 10 个;可设免费或积分下载</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm inline-flex items-center gap-1.5 shrink-0"
|
||||
disabled={loading || fileBusy || attachments.length >= 10}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
{fileBusy ? "上传中…" : "添加文件"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => void onPickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
{attachments.length === 0 ? (
|
||||
<p className="meta text-[13px] py-2">暂无附件</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{attachments.map((a) => (
|
||||
<li key={a.id} className="j13-compose-attach-row">
|
||||
<Download size={15} className="shrink-0 opacity-50" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13.5px]">{a.name}</div>
|
||||
<div className="meta text-[12px]">{formatSize(a.size)}</div>
|
||||
</div>
|
||||
<label
|
||||
className={`meta text-[12px] flex items-center gap-1.5 shrink-0${
|
||||
a.price_points > 0 ? " j13-compose-att-paid" : ""
|
||||
}`}
|
||||
>
|
||||
积分
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
value={a.price_points}
|
||||
<ComposeAttachments
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
limits={attachLimits}
|
||||
disabled={loading}
|
||||
onChange={(e) => void setAttPrice(a.id, Number(e.target.value))}
|
||||
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
|
||||
data-tip="0 = 免费下载"
|
||||
aria-label={`${a.name} 下载积分,0 为免费`}
|
||||
onBusyChange={setFileBusy}
|
||||
onPendingChange={setPendingFileCount}
|
||||
sectionRef={attachSectionRef}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-2"
|
||||
data-tip="移除附件"
|
||||
aria-label="移除附件"
|
||||
onClick={() => void removeAttachment(a.id)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-actions">
|
||||
@@ -1272,6 +1268,11 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
</p>
|
||||
)}
|
||||
<div className="j13-compose-actions-end">
|
||||
<ComposeAttachAction
|
||||
count={attachments.length}
|
||||
max={attachLimits.maxCount}
|
||||
onClick={scrollToAttachments}
|
||||
/>
|
||||
{isCreate ? (
|
||||
<Link href={cancelHref} className="btn btn-line !py-3">
|
||||
取消
|
||||
@@ -1294,6 +1295,8 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
? isCreate
|
||||
? "发布中…"
|
||||
: "保存中…"
|
||||
: fileBusy
|
||||
? "上传中…"
|
||||
: isCreate
|
||||
? "发布帖子"
|
||||
: "保存修改"}
|
||||
|
||||
244
frontend/components/admin/AdminExtChipEditor.tsx
Normal file
244
frontend/components/admin/AdminExtChipEditor.tsx
Normal file
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !py-1 !px-2 text-[12px]"
|
||||
disabled={disabled}
|
||||
onClick={() => mergeGroup(g.exts)}
|
||||
>
|
||||
+ {g.label}
|
||||
</button>
|
||||
)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[disabled, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`j13-ext-editor${disabled ? " is-disabled" : ""}`}>
|
||||
{emptyWarn ? (
|
||||
<p className="j13-ext-warn" role="status">
|
||||
当前列表为空:开启限制时将无法上传任何附件
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="j13-ext-groups" aria-label="分组一键加入">
|
||||
{groupButtons}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !py-1 !px-2 text-[12px] inline-flex items-center gap-1"
|
||||
disabled={disabled}
|
||||
onClick={() => setConfirmReset(true)}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="j13-ext-wall"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
role="group"
|
||||
aria-label="扩展名列表"
|
||||
>
|
||||
{value.map((ext, idx) => (
|
||||
<span key={ext} className="j13-ext-chip">
|
||||
.{ext}
|
||||
<button
|
||||
type="button"
|
||||
className="j13-ext-chip-x"
|
||||
disabled={disabled}
|
||||
aria-label={`移除 ${ext}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeAt(idx);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<span className="j13-ext-input-wrap">
|
||||
<Plus size={14} className="opacity-40 shrink-0" aria-hidden />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="j13-ext-input"
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
placeholder="输入扩展名,Enter 添加"
|
||||
aria-label="添加扩展名"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => {
|
||||
if (draft.trim()) addExts(parseInputTokens(draft));
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hint ? (
|
||||
<p className="meta text-[12px] mt-1.5" role="status">
|
||||
{hint}
|
||||
</p>
|
||||
) : (
|
||||
<p className="meta text-[12px] mt-1.5">
|
||||
支持 <code className="text-[11px]">.msi</code> / <code className="text-[11px]">msi</code>{" "}
|
||||
或逗号批量;最多 {maxCount} 个
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmReset}
|
||||
title="恢复默认扩展名"
|
||||
message="将用站点默认列表覆盖当前编辑内容(需再点保存才会生效)。"
|
||||
confirmLabel="恢复默认"
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
onConfirm={() => {
|
||||
onChange([...DEFAULT_ATTACHMENT_EXTS]);
|
||||
setConfirmReset(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string>();
|
||||
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<PublicSettings> | 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<PublicSettings> | 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 传空串恢复默认主题色
|
||||
|
||||
49
frontend/lib/fileKind.ts
Normal file
49
frontend/lib/fileKind.ts
Normal file
@@ -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();
|
||||
}
|
||||
58
frontend/lib/postFile.ts
Normal file
58
frontend/lib/postFile.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -11,6 +11,10 @@ const nextConfig: NextConfig = {
|
||||
{ protocol: "http", hostname: "localhost" },
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
// rewrite/proxy 默认只缓冲 10MB;附件上限由 Go 站点设置决定,此处留足余量(含 multipart 开销)
|
||||
proxyClientMaxBodySize: "512mb",
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user