修复: 评论组件泄露私密 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:
@@ -34,11 +34,21 @@ type voteOptions struct {
|
|||||||
*gorm.DB
|
*gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaxVoteOptionLen 是选项内容在数据库中的最大字符数。
|
||||||
|
//
|
||||||
|
// 必须与 conf 的默认 vote_option_len 保持一致:配置曾经允许 300 而这里只有 200,
|
||||||
|
// 于是 201–300 字的选项「校验通过、写库失败」(MySQL 严格模式下报错,
|
||||||
|
// PostgreSQL 直接报 value too long)。改列宽而不是改配置,是因为配置本来
|
||||||
|
// 就是给箱主按需调小的,不该反过来限制它的上界。
|
||||||
|
//
|
||||||
|
// ⚠️ 改这个常量时必须同步改下面 Content 字段的 size 标签(结构体标签不能引用常量)。
|
||||||
|
const MaxVoteOptionLen = 300
|
||||||
|
|
||||||
// VoteOption 选项表:单选/多选/评分/排序共用。
|
// VoteOption 选项表:单选/多选/评分/排序共用。
|
||||||
type VoteOption struct {
|
type VoteOption struct {
|
||||||
dbutil.Model
|
dbutil.Model
|
||||||
VoteID uint `gorm:"index:idx_option_vote" json:"vote_id"`
|
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"`
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,3 +114,92 @@ func testVoteOptionsCountByVoteID(t *testing.T, ctx context.Context, db *voteOpt
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(0), got)
|
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
@@ -16,6 +16,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
@@ -248,6 +249,12 @@ func fitsWithin(w, h, maxSide int) bool {
|
|||||||
// cwebp 长时间占用 CPU 与内存,故在此显式设限。
|
// cwebp 长时间占用 CPU 与内存,故在此显式设限。
|
||||||
const maxGIFFrames = 200
|
const maxGIFFrames = 200
|
||||||
|
|
||||||
|
// gifConvertTimeout 是单次 cwebp 转换的硬上限。
|
||||||
|
// 帧数限制挡不住「帧少但每帧极大」或畸形数据把 cwebp 拖住的情况;子进程一旦挂住,
|
||||||
|
// 调用它的上传 goroutine 会一直等,最终拖死整个上传路径。超时后 exec 会杀掉子进程,
|
||||||
|
// 调用方按错误回退到原 GIF,不影响上传本身成功。
|
||||||
|
const gifConvertTimeout = 30 * time.Second
|
||||||
|
|
||||||
// ConvertGifToAnimatedWebP 将 GIF 转换为动画 WebP(需要系统安装 cwebp)。
|
// ConvertGifToAnimatedWebP 将 GIF 转换为动画 WebP(需要系统安装 cwebp)。
|
||||||
// cwebp 不可用、GIF 损坏或帧数超限时返回错误,调用方应回退到原始 GIF。
|
// cwebp 不可用、GIF 损坏或帧数超限时返回错误,调用方应回退到原始 GIF。
|
||||||
func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
|
func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
|
||||||
@@ -281,9 +288,16 @@ func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
|
|||||||
|
|
||||||
// -mixed:对透明帧用无损、其余有损,是动画的推荐组合。
|
// -mixed:对透明帧用无损、其余有损,是动画的推荐组合。
|
||||||
// -metadata none:剥离调色板外的元数据,进一步减小体积。
|
// -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())
|
"-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name())
|
||||||
if err := cmd.Run(); err != nil {
|
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")
|
return nil, errors.Wrap(err, "run cwebp")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-7
@@ -36,6 +36,19 @@ var voteTextRepliesCache = cache.NewTTLCache(64)
|
|||||||
|
|
||||||
const voteTextRepliesCacheTTL = 30 * time.Second
|
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 数组)。
|
// voteFormFieldInput 表单字段的前端输入结构(vote-form.html 中的 JS 数组)。
|
||||||
@@ -244,8 +257,8 @@ func parseOptions(ctx context.Context, form *voteForm, voteType string, maxChoic
|
|||||||
if o == "" {
|
if o == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len([]rune(o)) > conf.Limits.VoteOptionLen {
|
if len([]rune(o)) > voteOptionMaxLen() {
|
||||||
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen))
|
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", voteOptionMaxLen()))
|
||||||
}
|
}
|
||||||
// 选项内容审核(fail-closed):草稿跳过,发布时统一审核
|
// 选项内容审核(fail-closed):草稿跳过,发布时统一审核
|
||||||
if !draft {
|
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))
|
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldReservedOption", i+1, label))
|
||||||
}
|
}
|
||||||
// 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验)
|
// 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验)
|
||||||
if len([]rune(o)) > conf.Limits.VoteOptionLen {
|
if len([]rune(o)) > voteOptionMaxLen() {
|
||||||
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen))
|
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", voteOptionMaxLen()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(opts) < 1 {
|
if len(opts) < 1 {
|
||||||
@@ -516,7 +529,7 @@ func VoteList(ctx context.Context) {
|
|||||||
func VoteNew(ctx context.Context) {
|
func VoteNew(ctx context.Context) {
|
||||||
ctx.Data["Title"] = "新建投票"
|
ctx.Data["Title"] = "新建投票"
|
||||||
ctx.Data["IsEdit"] = false
|
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。
|
// 默认投票类型为表单收集(用户要求);同时给定模板零值兜底,避免切到多选时 max_choices 显示 0。
|
||||||
ctx.Data["Vote"] = &db.Vote{
|
ctx.Data["Vote"] = &db.Vote{
|
||||||
Type: db.VoteTypeForm,
|
Type: db.VoteTypeForm,
|
||||||
@@ -534,7 +547,7 @@ func VoteNew(ctx context.Context) {
|
|||||||
// 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data,
|
// 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data,
|
||||||
// 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。
|
// 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。
|
||||||
func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flashMsg string, internal bool) {
|
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)) }
|
get := func(n string) string { return strings.TrimSpace(ctx.Request().FormValue(n)) }
|
||||||
title := get("title")
|
title := get("title")
|
||||||
description := get("description")
|
description := get("description")
|
||||||
@@ -674,7 +687,7 @@ func VoteEdit(ctx context.Context) {
|
|||||||
|
|
||||||
ctx.Data["Vote"] = vote
|
ctx.Data["Vote"] = vote
|
||||||
ctx.Data["IsEdit"] = true
|
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"] = "编辑投票"
|
ctx.Data["Title"] = "编辑投票"
|
||||||
// 草稿保存成功仅通过本次 URL 参数驱动统一 alert,不使用 session flash。
|
// 草稿保存成功仅通过本次 URL 参数驱动统一 alert,不使用 session flash。
|
||||||
if ctx.Query("saved") == "1" {
|
if ctx.Query("saved") == "1" {
|
||||||
|
|||||||
@@ -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
@@ -799,6 +799,19 @@ func parseMultipleOptionIDs(ids []string, maxChoices int, validOptions map[uint]
|
|||||||
return chosen, nil
|
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。
|
// parseSubmission 按投票类型解析并校验提交内容,构造 VoteSubmission。
|
||||||
// owner 为投票发起人(用于其全局屏蔽词表对开放文本的本地过滤)。
|
// owner 为投票发起人(用于其全局屏蔽词表对开放文本的本地过滤)。
|
||||||
func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.CreateVoteSubmissionOptions, error) {
|
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
|
// 多选 checkbox
|
||||||
if f.FieldType == db.VoteFormFieldTypeCheckbox {
|
if f.FieldType == db.VoteFormFieldTypeCheckbox {
|
||||||
var chosen []string
|
var chosen []string
|
||||||
|
// 同一字段名可以被客户端重复提交(field_x=A&field_x=A),
|
||||||
|
// 不去重就会在结果统计里把同一个选项计多次,等于让一个人刷高计数。
|
||||||
|
// 按最终落库的字符串去重:预置值按值去重,「其他」按用户填的文本去重。
|
||||||
|
chosenSeen := make(map[string]bool, len(values))
|
||||||
for _, val := range values {
|
for _, val := range values {
|
||||||
val = strings.TrimSpace(val)
|
val = strings.TrimSpace(val)
|
||||||
if 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 {
|
if err := censorCheck(ctx, owner, lang, custom); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
chosen = append(chosen, custom)
|
chosen = appendUniqueValue(chosen, chosenSeen, custom)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !validFieldOptions[val] {
|
if !validFieldOptions[val] {
|
||||||
return nil, errors.New(i18n.T(lang, "vote.err.fieldInvalidOption", f.Label))
|
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 {
|
if len(chosen) == 0 && f.Required {
|
||||||
return nil, errors.New(i18n.T(lang, "vote.err.fieldRequired", f.Label))
|
return nil, errors.New(i18n.T(lang, "vote.err.fieldRequired", f.Label))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -157,7 +157,17 @@
|
|||||||
el: '#waline',
|
el: '#waline',
|
||||||
//后端服务器地址
|
//后端服务器地址
|
||||||
serverURL: 'https://waline.337845818.best/',
|
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: {
|
locale: {
|
||||||
nick: '昵称',
|
nick: '昵称',
|
||||||
|
|||||||
Reference in New Issue
Block a user