修复: 评论组件泄露私密 token、cwebp 无超时、选项长度与去重

Waline(安全)
- item.html 的 path 从 window.location.href 改为剔除 ?t= 后的 URL。
  私密题 /_/<domain>/<id>?t=<token> 的钥匙原本会整条发往 Waline 服务端
  并落进其访问日志。只删 t、保留其他参数,公开题的评论线程键不变。

cwebp(可靠性)
- ConvertGifToAnimatedWebP 改用 exec.CommandContext + 30s 上限。
  原来裸 exec.Command 无超时,畸形/超大 GIF 会把子进程和上传 goroutine 一起挂住。
  超时杀进程并记 Warn,调用方按既有逻辑回退为原 GIF,不影响上传成功。

投票选项长度(正确性)
- vote_options.content 由 varchar(200) 放宽到 300,与配置默认值一致。
  此前 201–300 字的选项通过校验后在写库阶段失败。
- 新增 db.MaxVoteOptionLen;voteOptionMaxLen() 取配置与存储上限的较小值,
  校验与前端 maxlength 同源,避免配置调大后再次越界。

多选 checkbox 去重(正确性)
- 抽出 appendUniqueValue,预置值与「其他」文本共用同一 seen 集合。
  此前重复提交同名字段会让同一选项在统计里被计多次。

测试
- vote_dedupe_test.go:去重与顺序。
- vote_option_len_test.go:配置小于/等于/大于存储上限三种情形。
- TestVoteOptionMaxLengthRoundTrip:300 个多字节字符存回不截断。
- TestVoteOptionColumnWidens:把列改回 200 后 AutoMigrate 能扩回 300(老库升级路径)。

验证:WSL2 Debian + PostgreSQL 全量 go test ./... 27 个包全绿;
隔离实例渲染单题页确认新的 path 逻辑。
This commit is contained in:
tamakyi
2026-11-05 17:04:47 +08:00
parent 7ffab24025
commit 5fdc3df22d
8 changed files with 249 additions and 12 deletions
+11 -1
View File
@@ -34,11 +34,21 @@ type voteOptions struct {
*gorm.DB
}
// MaxVoteOptionLen 是选项内容在数据库中的最大字符数。
//
// 必须与 conf 的默认 vote_option_len 保持一致:配置曾经允许 300 而这里只有 200,
// 于是 201–300 字的选项「校验通过、写库失败」(MySQL 严格模式下报错,
// PostgreSQL 直接报 value too long)。改列宽而不是改配置,是因为配置本来
// 就是给箱主按需调小的,不该反过来限制它的上界。
//
// ⚠️ 改这个常量时必须同步改下面 Content 字段的 size 标签(结构体标签不能引用常量)。
const MaxVoteOptionLen = 300
// VoteOption 选项表:单选/多选/评分/排序共用。
type VoteOption struct {
dbutil.Model
VoteID uint `gorm:"index:idx_option_vote" json:"vote_id"`
Content string `gorm:"size:200" json:"content"`
Content string `gorm:"size:300" json:"content"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
+89
View File
@@ -114,3 +114,92 @@ func testVoteOptionsCountByVoteID(t *testing.T, ctx context.Context, db *voteOpt
require.Nil(t, err)
require.Equal(t, int64(0), got)
}
// TestVoteOptionMaxLengthRoundTrip 保证「配置允许的长度」真的能存下。
//
// 曾经 conf 允许 300 字而列宽只有 200:201–300 字的选项通过校验后在写库时才失败
// (PostgreSQL 报 value too long,MySQL 严格模式同样报错),用户看到的是「内部错误」。
func TestVoteOptionMaxLengthRoundTrip(t *testing.T) {
conn, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("vote_options"); err != nil {
t.Fatal(err)
}
})
ctx := context.Background()
store := NewVoteOptionsStore(conn)
// 结构体标签不能引用常量,列宽与 MaxVoteOptionLen 是两处独立字面量,
// 这里显式比一次,防止只改了一边。
columns, err := conn.Migrator().ColumnTypes(&VoteOption{})
require.Nil(t, err)
for _, ct := range columns {
if ct.Name() != "content" {
continue
}
if length, ok := ct.Length(); ok {
require.EqualValues(t, MaxVoteOptionLen, length, "vote_options.content 列宽必须与 MaxVoteOptionLen 一致")
}
}
// 用多字节字符填满上限,顺便验证按字符而非字节计数。
content := ""
for len([]rune(content)) < MaxVoteOptionLen {
content += "選"
}
option, err := store.Create(ctx, CreateVoteOptionOptions{VoteID: 1, Content: content})
require.Nil(t, err)
got, err := store.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, option.ID, got[0].ID)
require.Equal(t, MaxVoteOptionLen, len([]rune(got[0].Content)), "存进去的字符数应与上限一致,未被截断")
}
// TestVoteOptionColumnWidens 验证老库升级路径:已经存在 varchar(200) 的列,
// 跑一次 AutoMigrate 后必须变成 MaxVoteOptionLen。
//
// 只改 model 标签是不够的 —— 如果 AutoMigrate 不扩列,老部署上线后
// 201–300 字的选项依旧写不进去,而新库(测试库)永远发现不了这个问题。
func TestVoteOptionColumnWidens(t *testing.T) {
conn, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("vote_options"); err != nil {
t.Fatal(err)
}
})
// 退回老库的列宽(两种方言语法不同)。
var alter string
switch conn.Dialector.Name() {
case "postgres":
alter = "ALTER TABLE vote_options ALTER COLUMN content TYPE varchar(200)"
case "mysql":
alter = "ALTER TABLE vote_options MODIFY COLUMN content varchar(200)"
default:
t.Skipf("未适配的方言:%s", conn.Dialector.Name())
}
require.Nil(t, conn.Exec(alter).Error)
contentLength := func() int64 {
columns, err := conn.Migrator().ColumnTypes(&VoteOption{})
require.Nil(t, err)
for _, ct := range columns {
if ct.Name() != "content" {
continue
}
length, ok := ct.Length()
require.True(t, ok, "content 列应有明确长度")
return length
}
t.Fatal("未找到 content 列")
return 0
}
require.EqualValues(t, 200, contentLength(), "前置条件:列宽应已被改回 200")
require.Nil(t, conn.AutoMigrate(&VoteOption{}))
require.EqualValues(t, MaxVoteOptionLen, contentLength(), "AutoMigrate 必须把列宽扩到 MaxVoteOptionLen")
}
+15 -1
View File
@@ -16,6 +16,7 @@ import (
"mime/multipart"
"os"
"os/exec"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
@@ -248,6 +249,12 @@ func fitsWithin(w, h, maxSide int) bool {
// cwebp 长时间占用 CPU 与内存,故在此显式设限。
const maxGIFFrames = 200
// gifConvertTimeout 是单次 cwebp 转换的硬上限。
// 帧数限制挡不住「帧少但每帧极大」或畸形数据把 cwebp 拖住的情况;子进程一旦挂住,
// 调用它的上传 goroutine 会一直等,最终拖死整个上传路径。超时后 exec 会杀掉子进程,
// 调用方按错误回退到原 GIF,不影响上传本身成功。
const gifConvertTimeout = 30 * time.Second
// ConvertGifToAnimatedWebP 将 GIF 转换为动画 WebP(需要系统安装 cwebp)。
// cwebp 不可用、GIF 损坏或帧数超限时返回错误,调用方应回退到原始 GIF。
func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
@@ -281,9 +288,16 @@ func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
// -mixed:对透明帧用无损、其余有损,是动画的推荐组合。
// -metadata none:剥离调色板外的元数据,进一步减小体积。
cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-metadata", "none",
// 用 CommandContext 而非裸 Command:超时会杀进程,不会让上传 goroutine 无限等待。
ctx, cancel := context.WithTimeout(context.Background(), gifConvertTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "cwebp", "-q", "80", "-mixed", "-metadata", "none",
"-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name())
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
logrus.WithField("timeout", gifConvertTimeout).Warn("cwebp 转换超时,回退为原 GIF")
return nil, errors.Errorf("cwebp timed out after %s", gifConvertTimeout)
}
return nil, errors.Wrap(err, "run cwebp")
}
+20 -7
View File
@@ -36,6 +36,19 @@ var voteTextRepliesCache = cache.NewTTLCache(64)
const voteTextRepliesCacheTTL = 30 * time.Second
// voteOptionMaxLen 是选项输入的实际上限。
//
// 配置项 vote_option_len 只保证 ≥1,可以调得比数据库列还大;而 vote_options.content
// 是 varchar(300),超出的内容会被数据库拒绝(PostgreSQL 报错、MySQL 严格模式报错)。
// 取「配置值」与「存储上限」的较小值,保证"校验通过"等于"能存下",
// 也让前端输入框的 maxlength 与实际可保存长度一致。
func voteOptionMaxLen() int {
if conf.Limits.VoteOptionLen < db.MaxVoteOptionLen {
return conf.Limits.VoteOptionLen
}
return db.MaxVoteOptionLen
}
// ---------------- 表单解析 ----------------
// voteFormFieldInput 表单字段的前端输入结构(vote-form.html 中的 JS 数组)。
@@ -244,8 +257,8 @@ func parseOptions(ctx context.Context, form *voteForm, voteType string, maxChoic
if o == "" {
continue
}
if len([]rune(o)) > conf.Limits.VoteOptionLen {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen))
if len([]rune(o)) > voteOptionMaxLen() {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", voteOptionMaxLen()))
}
// 选项内容审核(fail-closed):草稿跳过,发布时统一审核
if !draft {
@@ -348,8 +361,8 @@ func parseFormFields(ctx context.Context, form *voteForm, draft bool) error {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldReservedOption", i+1, label))
}
// 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验)
if len([]rune(o)) > conf.Limits.VoteOptionLen {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen))
if len([]rune(o)) > voteOptionMaxLen() {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", voteOptionMaxLen()))
}
}
if len(opts) < 1 {
@@ -516,7 +529,7 @@ func VoteList(ctx context.Context) {
func VoteNew(ctx context.Context) {
ctx.Data["Title"] = "新建投票"
ctx.Data["IsEdit"] = false
ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS)
ctx.Data["MaxVoteOptionLen"] = voteOptionMaxLen() // 选项输入框 maxlength 同源(vote-form.html JS)
// 默认投票类型为表单收集(用户要求);同时给定模板零值兜底,避免切到多选时 max_choices 显示 0。
ctx.Data["Vote"] = &db.Vote{
Type: db.VoteTypeForm,
@@ -534,7 +547,7 @@ func VoteNew(ctx context.Context) {
// 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data,
// 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。
func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flashMsg string, internal bool) {
ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS)
ctx.Data["MaxVoteOptionLen"] = voteOptionMaxLen() // 选项输入框 maxlength 同源(vote-form.html JS)
get := func(n string) string { return strings.TrimSpace(ctx.Request().FormValue(n)) }
title := get("title")
description := get("description")
@@ -674,7 +687,7 @@ func VoteEdit(ctx context.Context) {
ctx.Data["Vote"] = vote
ctx.Data["IsEdit"] = true
ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS)
ctx.Data["MaxVoteOptionLen"] = voteOptionMaxLen() // 选项输入框 maxlength 同源(vote-form.html JS)
ctx.Data["Title"] = "编辑投票"
// 草稿保存成功仅通过本次 URL 参数驱动统一 alert,不使用 session flash。
if ctx.Query("saved") == "1" {
+36
View File
@@ -0,0 +1,36 @@
package user
import (
"testing"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
// TestVoteOptionMaxLen 钉住「配置值不能超过存储上限」这条约束。
//
// 背景:conf 的 vote_option_len 只校验 ≥1,可以填得比 vote_options.content
// 的列宽还大;那样校验会放行,写库才失败(PostgreSQL 直接报错)。
func TestVoteOptionMaxLen(t *testing.T) {
original := conf.Limits.VoteOptionLen
t.Cleanup(func() { conf.Limits.VoteOptionLen = original })
cases := []struct {
name string
conf int
want int
}{
{"配置小于存储上限时以配置为准", 100, 100},
{"配置等于存储上限", db.MaxVoteOptionLen, db.MaxVoteOptionLen},
{"配置超过存储上限时截到上限", db.MaxVoteOptionLen + 500, db.MaxVoteOptionLen},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
conf.Limits.VoteOptionLen = c.conf
if got := voteOptionMaxLen(); got != c.want {
t.Fatalf("voteOptionMaxLen() = %d, want %d", got, c.want)
}
})
}
}
+19 -2
View File
@@ -799,6 +799,19 @@ func parseMultipleOptionIDs(ids []string, maxChoices int, validOptions map[uint]
return chosen, nil
}
// appendUniqueValue 把 v 追加到 dst,但同一个值只保留第一次出现。
// 多选类字段(checkbox)依赖它去重:客户端可以重复提交同名字段,
// 不去重就会在结果统计里把同一选项计多次。
//
// seen 由调用方持有并跨多次调用复用,用来记录已追加过的值。
func appendUniqueValue(dst []string, seen map[string]bool, v string) []string {
if seen[v] {
return dst
}
seen[v] = true
return append(dst, v)
}
// parseSubmission 按投票类型解析并校验提交内容,构造 VoteSubmission。
// owner 为投票发起人(用于其全局屏蔽词表对开放文本的本地过滤)。
func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.CreateVoteSubmissionOptions, error) {
@@ -924,6 +937,10 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
// 多选 checkbox
if f.FieldType == db.VoteFormFieldTypeCheckbox {
var chosen []string
// 同一字段名可以被客户端重复提交(field_x=A&field_x=A),
// 不去重就会在结果统计里把同一个选项计多次,等于让一个人刷高计数。
// 按最终落库的字符串去重:预置值按值去重,「其他」按用户填的文本去重。
chosenSeen := make(map[string]bool, len(values))
for _, val := range values {
val = strings.TrimSpace(val)
if val == "" {
@@ -947,13 +964,13 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
if err := censorCheck(ctx, owner, lang, custom); err != nil {
return nil, err
}
chosen = append(chosen, custom)
chosen = appendUniqueValue(chosen, chosenSeen, custom)
continue
}
if !validFieldOptions[val] {
return nil, errors.New(i18n.T(lang, "vote.err.fieldInvalidOption", f.Label))
}
chosen = append(chosen, val)
chosen = appendUniqueValue(chosen, chosenSeen, val)
}
if len(chosen) == 0 && f.Required {
return nil, errors.New(i18n.T(lang, "vote.err.fieldRequired", f.Label))
+48
View File
@@ -0,0 +1,48 @@
package vote
import (
"testing"
)
// TestAppendUniqueValue 钉住多选字段的去重语义:
// 客户端重复提交同一选项时,落库与统计都只能算一次。
func TestAppendUniqueValue(t *testing.T) {
t.Run("重复值只保留第一次", func(t *testing.T) {
seen := map[string]bool{}
var got []string
for _, v := range []string{"A", "A", "B", "A", "B", "C"} {
got = appendUniqueValue(got, seen, v)
}
want := []string{"A", "B", "C"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v(顺序应按首次出现)", got, want)
}
}
})
t.Run("空切片与单值", func(t *testing.T) {
seen := map[string]bool{}
var got []string
got = appendUniqueValue(got, seen, "只有一项")
if len(got) != 1 || got[0] != "只有一项" {
t.Fatalf("got %v", got)
}
})
t.Run("seen 复用时跨批次仍然去重", func(t *testing.T) {
// checkbox 的「其他」文本与预置值走同一条追加路径,
// seen 必须在整个字段解析期间共享,而不是每轮新建。
seen := map[string]bool{}
var got []string
got = appendUniqueValue(got, seen, "预置")
got = appendUniqueValue(got, seen, "其他文本")
got = appendUniqueValue(got, seen, "预置")
if len(got) != 2 {
t.Fatalf("got %v, want 2 项", got)
}
})
}
+11 -1
View File
@@ -157,7 +157,17 @@
el: '#waline',
//后端服务器地址
serverURL: 'https://waline.337845818.best/',
path: window.location.href,
// 评论线程键:必须剔除 ?t= 私密题 token。
// 原来直接传 location.href,私密题 /_/<domain>/<id>?t=<token> 的钥匙
// 会整条 URL 发到 Waline 服务端并落进它的访问日志;token 本身就能读该题,
// 等于凭空多出一份可被日志检索的凭证副本。
// 只删 t、保留其余参数:公开题的 page/sort 等分页参数不变,
// 已有评论线程的键不受影响。
path: (function () {
var u = new URL(window.location.href);
u.searchParams.delete('t');
return u.toString();
})(),
// 中文默认
locale: {
nick: '昵称',