修复: 评论组件泄露私密 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
+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" {