Compare commits

..
2 Commits
Author SHA1 Message Date
tamakyi 5fdc3df22d 修复: 评论组件泄露私密 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 逻辑。
2026-11-05 17:04:47 +08:00
tamakyi 7ffab24025 安全: 修复页面密码零值绕过,导出不再泄露投票者 IP
页面密码(P0,2026-11-05 复核发现)
- 旧实现 `ver, _ := Session.Get(key).(uint); if ver != user.PagePasswordVer`
  把「session 里没有授权记录」(断言失败得 0)与「新增列默认值 0」判成相等,
  升级瞬间所有存量页面密码静默失效:匿名访客不带 cookie 即可读箱主页。
- 收口为 pagePasswordAuthorized:缺 key 一律视为未授权,
  key 名由 pagePassKey 统一生成,Pager / ListAPI / AskStatusAPI 三处共用。
- 新增迁移 v6 回填 page_password_ver / ask_password_ver(仅仍为 0 且已设密码的行,
  幂等),让模型语义与「改过一次密码」一致,并作废升级前的布尔授权。
- AskStatusAPI 的 L4 查询补日志(保持与 Pager 一致的 fail-open 口径)。

导出(P1)
- JSON 导出的投票提交不再输出 voter_ip。库里照常保留,
  防刷、取证与站方 CLI 处置不受影响,只是不进箱主的自助导出文件。

测试
- route/question/page_password_test.go:8 个用例钉住零值/类型不符/轮换失效等边界。
- internal/db/page_password_migration_test.go:回填只碰该碰的行且幂等。

验证:WSL2 Debian + PostgreSQL 全量测试通过;隔离实例实测
ver=0 匿名访问返回密码页、API 401,输对密码后放行、改密后旧会话立即失效,
删除迁移账本后重启可正确回填。
2026-11-05 16:31:54 +08:00
15 changed files with 513 additions and 46 deletions
+7
View File
@@ -67,6 +67,13 @@ var migrations = []Migration{
Name: "normalize harassment setting",
Up: normalizeHarassmentSetting,
},
{
// 新增列 AutoMigrate 只补 0,而 0 与「无授权」的零值撞车 —— 必须回填,
// 否则升级后存量用户的页面密码会被静默绕过。
Version: 6,
Name: "backfill page password version",
Up: backfillPagePasswordVer,
},
}
// runMigrations 按版本号升序执行尚未应用的迁移。
@@ -0,0 +1,58 @@
package db
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestBackfillPagePasswordVer 覆盖 2026-11-05 那个 P0 的数据面:
// 新增列 AutoMigrate 只补 0,而 0 恰好等于「session 里没有授权记录」的零值,
// 不回填的话存量页面密码在升级瞬间全部失效。
func TestBackfillPagePasswordVer(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("users")
mustCreate := func(domain string, pagePassword string, pageVer uint, askPassword string, askVer uint) *User {
u := &User{
Name: domain,
Email: domain + "@example.com",
Password: "x",
Domain: domain,
PagePassword: pagePassword,
PagePasswordVer: pageVer,
AskPassword: askPassword,
AskPasswordVer: askVer,
}
require.NoError(t, conn.Create(u).Error)
return u
}
// 存量:有密码但版本号仍为默认 0 —— 必须被回填。
legacy := mustCreate("legacy", "hashed-page", 0, "hashed-ask", 0)
// 已轮换过密码:版本号非 0,不能被重置。
rotated := mustCreate("rotated", "hashed-page", 3, "hashed-ask", 5)
// 没有密码:版本号保持 0,回填不应凭空造出授权。
noPassword := mustCreate("nopassword", "", 0, "", 0)
require.NoError(t, backfillPagePasswordVer(conn))
var gotLegacy, gotRotated, gotNoPassword User
require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error)
require.NoError(t, conn.First(&gotRotated, rotated.ID).Error)
require.NoError(t, conn.First(&gotNoPassword, noPassword.ID).Error)
require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "存量页面密码应回填为 1")
require.EqualValues(t, 1, gotLegacy.AskPasswordVer, "存量提问密码应回填为 1")
require.EqualValues(t, 3, gotRotated.PagePasswordVer, "已轮换的版本号不能被覆盖")
require.EqualValues(t, 5, gotRotated.AskPasswordVer, "已轮换的版本号不能被覆盖")
require.EqualValues(t, 0, gotNoPassword.PagePasswordVer, "没设密码的账号不应被写版本号")
require.EqualValues(t, 0, gotNoPassword.AskPasswordVer, "没设密码的账号不应被写版本号")
// 幂等:再跑一次不应把已回填的行推到 2。
require.NoError(t, backfillPagePasswordVer(conn))
require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error)
require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "迁移必须幂等")
}
+23
View File
@@ -885,6 +885,29 @@ func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) {
return hot, nil
}
// backfillPagePasswordVer 给「已设置密码但版本号仍为 0」的存量账号补一次版本号。
//
// 背景:PagePasswordVer / AskPasswordVer 是 2026-11-05 新增的列,AutoMigrate 只补默认值 0。
// 而 0 同时是「session 里没有授权记录」时类型断言的零值,两者相等会让页面密码判定恒为通过
// (见 route/question/page.go 的 pagePasswordAuthorized 注释)。
// 把存量行统一置 1:语义与「改过一次密码」一致,升级前发出的旧授权(布尔 true)
// 也会因类型不符而自然作废,用户重输一次密码即可。
//
// 幂等:只更新仍为 0 的行,重复执行不会把版本号越推越大。
func backfillPagePasswordVer(db *gorm.DB) error {
if err := db.Model(&User{}).
Where("page_password <> '' AND page_password_ver = 0").
Update("page_password_ver", 1).Error; err != nil {
return errors.Wrap(err, "backfill page password version")
}
if err := db.Model(&User{}).
Where("ask_password <> '' AND ask_password_ver = 0").
Update("ask_password_ver", 1).Error; err != nil {
return errors.Wrap(err, "backfill ask password version")
}
return nil
}
func (db *users) UpdatePagePassword(ctx context.Context, id uint, password string) error {
user, err := db.GetByID(ctx, id)
if err != nil {
+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")
}
+10 -11
View File
@@ -7,7 +7,6 @@ package question
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -106,23 +105,23 @@ func AskStatusAPI(ctx context.Context) error {
// 否则持有正确问题 token 的请求者可以绕过页面访问边界,取得回答状态、
// 创建时间和前 60 字摘要。
// 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err == nil && level == db.RestrictionL4 {
emptyResult := func() error {
out := make([]askStatusResult, 0, len(req.Items))
for _, it := range req.Items {
out = append(out, askStatusResult{ID: it.ID})
}
return ctx.JSON(map[string]interface{}{"items": out})
}
if owner.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == owner.ID) {
passKey := fmt.Sprintf("page_pass_%d", owner.ID)
sessionVer, _ := ctx.Session.Get(passKey).(uint)
if sessionVer != owner.PagePasswordVer {
out := make([]askStatusResult, 0, len(req.Items))
for _, it := range req.Items {
out = append(out, askStatusResult{ID: it.ID})
}
return ctx.JSON(map[string]interface{}{"items": out})
// 处置查询失败时不拦(fail-open),与 Pager / ListAPI 的既定口径一致:
// 这一层不是内容外传通道,DB 异常时再 fail-closed 只会让所有提问箱一起白屏。
// 与它们唯一的差别是这里补了日志,不再静默吞掉错误。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction for ask status")
} else if level == db.RestrictionL4 {
return emptyResult()
}
if !pagePasswordAuthorized(ctx, owner) {
return emptyResult()
}
// 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。
+29 -12
View File
@@ -85,17 +85,13 @@ func Pager(ctx context.Context) {
ctx.Data["ThemeSlug"] = theme.Resolve(pageUser.Theme)
// 页面密码检查:session 中保存授权时的 PagePasswordVer 版本号,
// 与当前版本号比对,修改密码后旧授权立即失效(SEC-06)。
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
sessionVer, _ := ctx.Session.Get(passKey).(uint)
if sessionVer != pageUser.PagePasswordVer {
if !pagePasswordAuthorized(ctx, pageUser) {
// 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页
ctx.Data["Next"] = ctx.Request().URL.RequestURI()
ctx.Data["PageUser"] = pageUser
ctx.Success("question/password")
return
}
}
// 分页加载公开已回答的问题(首次加载 20 条,置顶优先)
// 多取 1 条探测是否仍有下一页,供模板决定「加载更多」按钮的初始可见状态,
@@ -257,6 +253,32 @@ func PasswordPage(ctx context.Context) {
ctx.Success("question/password")
}
// pagePasswordAuthorized 判断当前访客是否已通过该箱主的页面密码校验。
//
// ⚠️ 这里是页面密码的唯一判定入口,HTML 页面与列表 API 都必须走它,不要各写一份。
//
// 关键点:session 里没有对应键时必须判为**未授权**。
// 曾用 `sessionVer, _ := ctx.Session.Get(key).(uint); if sessionVer != user.PagePasswordVer`
// 的写法:断言失败同样得到零值 0,而 PagePasswordVer 对存量用户也是 0(新增列默认值),
// 于是 0 != 0 为假 —— 升级瞬间所有既有页面密码静默失效,匿名访客无需密码即可看箱主页。
// 因此必须先用 ok 断言「确实存在授权记录」,再比较版本号。
func pagePasswordAuthorized(ctx context.Context, owner *db.User) bool {
if owner.PagePassword == "" {
return true
}
// 箱主本人不受页面密码限制。
if ctx.IsLogged && ctx.User.ID == owner.ID {
return true
}
ver, ok := ctx.Session.Get(pagePassKey(owner.ID)).(uint)
return ok && ver == owner.PagePasswordVer
}
// pagePassKey 是页面密码授权在 session 里的键名,读写两侧共用,避免格式漂移。
func pagePassKey(userID uint) string {
return fmt.Sprintf("page_pass_%d", userID)
}
func VerifyPagePassword(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
@@ -279,9 +301,8 @@ func VerifyPagePassword(ctx context.Context) {
password := ctx.Request().FormValue("password")
if pageUser.CheckPagePassword(password) {
pagePasswordGuard.Reset(guardKey)
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
// 保存当前密码版本号到 session,修改密码后旧授权自动失效(SEC-06)
ctx.Session.Set(passKey, pageUser.PagePasswordVer)
ctx.Session.Set(pagePassKey(pageUser.ID), pageUser.PagePasswordVer)
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
// 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
// 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
@@ -331,13 +352,9 @@ func ListAPI(ctx context.Context) error {
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID); err == nil && level == db.RestrictionL4 {
return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound"))
}
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
sessionVer, _ := ctx.Session.Get(passKey).(uint)
if sessionVer != pageUser.PagePasswordVer {
if !pagePasswordAuthorized(ctx, pageUser) {
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc"))
}
}
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
+126
View File
@@ -0,0 +1,126 @@
package question
import (
"net/http"
"testing"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
)
// stubSession 只实现 pagePasswordAuthorized 用得到的 Get/Set,其余方法给空实现。
type stubSession struct {
data map[interface{}]interface{}
}
func newStubSession() *stubSession {
return &stubSession{data: map[interface{}]interface{}{}}
}
func (s *stubSession) ID() string { return "test-session" }
func (s *stubSession) RegenerateID(http.ResponseWriter, *http.Request) error {
return nil
}
func (s *stubSession) Get(key interface{}) interface{} { return s.data[key] }
func (s *stubSession) Set(key, val interface{}) { s.data[key] = val }
func (s *stubSession) SetFlash(val interface{}) {}
func (s *stubSession) Delete(key interface{}) { delete(s.data, key) }
func (s *stubSession) Flush() { s.data = map[interface{}]interface{}{} }
func (s *stubSession) Encode() ([]byte, error) { return nil, nil }
func (s *stubSession) HasChanged() bool { return false }
// TestPagePasswordAuthorized 是 2026-11-05 P0 的回归用例。
//
// 旧实现 `ver, _ := Session.Get(key).(uint); if ver != user.PagePasswordVer`
// 把「session 里没有授权记录」(断言失败 → 0)与「版本号默认值 0」判成相等,
// 于是升级瞬间所有存量页面密码失效——匿名访客不带任何 cookie 就能看箱主页。
// 这里把「无 key + ver=0」钉成必须拒绝。
func TestPagePasswordAuthorized(t *testing.T) {
const userID = 7
cases := []struct {
name string
pagePassword string
pageVer uint
sessionFill func(*stubSession) // 预置 session,nil 表示完全没有授权键
logged bool
viewerID uint
want bool
}{
{
name: "未设页面密码时直接放行",
pagePassword: "",
pageVer: 0,
want: true,
},
{
name: "存量账号 ver=0 且 session 无授权键必须拒绝",
pagePassword: "hashed",
pageVer: 0,
want: false,
},
{
name: "升级前留下的布尔授权(类型不符)作废",
pagePassword: "hashed",
pageVer: 0,
sessionFill: func(s *stubSession) { s.Set(pagePassKey(userID), true) },
want: false,
},
{
name: "版本号一致的授权通过",
pagePassword: "hashed",
pageVer: 1,
sessionFill: func(s *stubSession) { s.Set(pagePassKey(userID), uint(1)) },
want: true,
},
{
name: "改过密码后旧版本授权失效",
pagePassword: "hashed",
pageVer: 2,
sessionFill: func(s *stubSession) { s.Set(pagePassKey(userID), uint(1)) },
want: false,
},
{
name: "session 里是别的类型(如字符串)一律拒绝",
pagePassword: "hashed",
pageVer: 1,
sessionFill: func(s *stubSession) { s.Set(pagePassKey(userID), "1") },
want: false,
},
{
name: "箱主本人不受页面密码限制",
pagePassword: "hashed",
pageVer: 9,
logged: true,
viewerID: userID,
want: true,
},
{
name: "其他登录用户仍需通过密码",
pagePassword: "hashed",
pageVer: 9,
logged: true,
viewerID: userID + 1,
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
session := newStubSession()
if c.sessionFill != nil {
c.sessionFill(session)
}
ctx := context.Context{
Session: session,
IsLogged: c.logged,
User: &db.User{ID: c.viewerID},
}
owner := &db.User{ID: userID, PagePassword: c.pagePassword, PagePasswordVer: c.pageVer}
if got := pagePasswordAuthorized(ctx, owner); got != c.want {
t.Fatalf("pagePasswordAuthorized() = %v, want %v", got, c.want)
}
})
}
}
+2 -2
View File
@@ -696,7 +696,8 @@ func ExportProfileJSON(ctx context.Context) {
type exportedVoteSubmission struct {
ID uint `json:"id"`
OptionID uint `json:"option_id"`
VoterIP string `json:"voter_ip"`
// 参与者 IP 属于第三方个人数据,不随箱主自助导出外流。
// 库里照常保留(防刷、取证、站方 CLI 处置都要用),只是不进这份给箱主的文件。
FormData datatypes.JSON `json:"form_data"`
CustomText string `json:"custom_text"`
CreatedAt time.Time `json:"created_at"`
@@ -818,7 +819,6 @@ func ExportProfileJSON(ctx context.Context) {
exportSubs = append(exportSubs, exportedVoteSubmission{
ID: s.ID,
OptionID: s.OptionID,
VoterIP: s.VoterIP,
FormData: s.FormData,
CustomText: s.CustomText,
CreatedAt: s.CreatedAt,
+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: '昵称',