Compare commits
2
Commits
25aee35030
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fdc3df22d | ||
|
|
7ffab24025 |
@@ -67,6 +67,13 @@ var migrations = []Migration{
|
|||||||
Name: "normalize harassment setting",
|
Name: "normalize harassment setting",
|
||||||
Up: normalizeHarassmentSetting,
|
Up: normalizeHarassmentSetting,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 新增列 AutoMigrate 只补 0,而 0 与「无授权」的零值撞车 —— 必须回填,
|
||||||
|
// 否则升级后存量用户的页面密码会被静默绕过。
|
||||||
|
Version: 6,
|
||||||
|
Name: "backfill page password version",
|
||||||
|
Up: backfillPagePasswordVer,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// runMigrations 按版本号升序执行尚未应用的迁移。
|
// 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, "迁移必须幂等")
|
||||||
|
}
|
||||||
@@ -885,6 +885,29 @@ func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) {
|
|||||||
return hot, nil
|
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 {
|
func (db *users) UpdatePagePassword(ctx context.Context, id uint, password string) error {
|
||||||
user, err := db.GetByID(ctx, id)
|
user, err := db.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package question
|
|||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -106,23 +105,23 @@ func AskStatusAPI(ctx context.Context) error {
|
|||||||
// 否则持有正确问题 token 的请求者可以绕过页面访问边界,取得回答状态、
|
// 否则持有正确问题 token 的请求者可以绕过页面访问边界,取得回答状态、
|
||||||
// 创建时间和前 60 字摘要。
|
// 创建时间和前 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))
|
out := make([]askStatusResult, 0, len(req.Items))
|
||||||
for _, it := range req.Items {
|
for _, it := range req.Items {
|
||||||
out = append(out, askStatusResult{ID: it.ID})
|
out = append(out, askStatusResult{ID: it.ID})
|
||||||
}
|
}
|
||||||
return ctx.JSON(map[string]interface{}{"items": out})
|
return ctx.JSON(map[string]interface{}{"items": out})
|
||||||
}
|
}
|
||||||
if owner.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == owner.ID) {
|
// 处置查询失败时不拦(fail-open),与 Pager / ListAPI 的既定口径一致:
|
||||||
passKey := fmt.Sprintf("page_pass_%d", owner.ID)
|
// 这一层不是内容外传通道,DB 异常时再 fail-closed 只会让所有提问箱一起白屏。
|
||||||
sessionVer, _ := ctx.Session.Get(passKey).(uint)
|
// 与它们唯一的差别是这里补了日志,不再静默吞掉错误。
|
||||||
if sessionVer != owner.PagePasswordVer {
|
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err != nil {
|
||||||
out := make([]askStatusResult, 0, len(req.Items))
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction for ask status")
|
||||||
for _, it := range req.Items {
|
} else if level == db.RestrictionL4 {
|
||||||
out = append(out, askStatusResult{ID: it.ID})
|
return emptyResult()
|
||||||
}
|
}
|
||||||
return ctx.JSON(map[string]interface{}{"items": out})
|
if !pagePasswordAuthorized(ctx, owner) {
|
||||||
}
|
return emptyResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。
|
// 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。
|
||||||
|
|||||||
+35
-18
@@ -85,16 +85,12 @@ func Pager(ctx context.Context) {
|
|||||||
ctx.Data["ThemeSlug"] = theme.Resolve(pageUser.Theme)
|
ctx.Data["ThemeSlug"] = theme.Resolve(pageUser.Theme)
|
||||||
// 页面密码检查:session 中保存授权时的 PagePasswordVer 版本号,
|
// 页面密码检查:session 中保存授权时的 PagePasswordVer 版本号,
|
||||||
// 与当前版本号比对,修改密码后旧授权立即失效(SEC-06)。
|
// 与当前版本号比对,修改密码后旧授权立即失效(SEC-06)。
|
||||||
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
|
if !pagePasswordAuthorized(ctx, pageUser) {
|
||||||
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
|
// 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页
|
||||||
sessionVer, _ := ctx.Session.Get(passKey).(uint)
|
ctx.Data["Next"] = ctx.Request().URL.RequestURI()
|
||||||
if sessionVer != pageUser.PagePasswordVer {
|
ctx.Data["PageUser"] = pageUser
|
||||||
// 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页
|
ctx.Success("question/password")
|
||||||
ctx.Data["Next"] = ctx.Request().URL.RequestURI()
|
return
|
||||||
ctx.Data["PageUser"] = pageUser
|
|
||||||
ctx.Success("question/password")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页加载公开已回答的问题(首次加载 20 条,置顶优先)
|
// 分页加载公开已回答的问题(首次加载 20 条,置顶优先)
|
||||||
@@ -257,6 +253,32 @@ func PasswordPage(ctx context.Context) {
|
|||||||
ctx.Success("question/password")
|
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) {
|
func VerifyPagePassword(ctx context.Context) {
|
||||||
domain := ctx.Param("domain")
|
domain := ctx.Param("domain")
|
||||||
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||||||
@@ -279,9 +301,8 @@ func VerifyPagePassword(ctx context.Context) {
|
|||||||
password := ctx.Request().FormValue("password")
|
password := ctx.Request().FormValue("password")
|
||||||
if pageUser.CheckPagePassword(password) {
|
if pageUser.CheckPagePassword(password) {
|
||||||
pagePasswordGuard.Reset(guardKey)
|
pagePasswordGuard.Reset(guardKey)
|
||||||
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
|
|
||||||
// 保存当前密码版本号到 session,修改密码后旧授权自动失效(SEC-06)
|
// 保存当前密码版本号到 session,修改密码后旧授权自动失效(SEC-06)
|
||||||
ctx.Session.Set(passKey, pageUser.PagePasswordVer)
|
ctx.Session.Set(pagePassKey(pageUser.ID), pageUser.PagePasswordVer)
|
||||||
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
|
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
|
||||||
// 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
|
// 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
|
||||||
// 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
|
// 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
|
||||||
@@ -331,12 +352,8 @@ func ListAPI(ctx context.Context) error {
|
|||||||
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID); err == nil && level == db.RestrictionL4 {
|
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"))
|
return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound"))
|
||||||
}
|
}
|
||||||
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
|
if !pagePasswordAuthorized(ctx, pageUser) {
|
||||||
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
|
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc"))
|
||||||
sessionVer, _ := ctx.Session.Get(passKey).(uint)
|
|
||||||
if sessionVer != pageUser.PagePasswordVer {
|
|
||||||
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
|
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -694,9 +694,10 @@ func ExportProfileJSON(ctx context.Context) {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
type exportedVoteSubmission struct {
|
type exportedVoteSubmission struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
OptionID uint `json:"option_id"`
|
OptionID uint `json:"option_id"`
|
||||||
VoterIP string `json:"voter_ip"`
|
// 参与者 IP 属于第三方个人数据,不随箱主自助导出外流。
|
||||||
|
// 库里照常保留(防刷、取证、站方 CLI 处置都要用),只是不进这份给箱主的文件。
|
||||||
FormData datatypes.JSON `json:"form_data"`
|
FormData datatypes.JSON `json:"form_data"`
|
||||||
CustomText string `json:"custom_text"`
|
CustomText string `json:"custom_text"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -818,7 +819,6 @@ func ExportProfileJSON(ctx context.Context) {
|
|||||||
exportSubs = append(exportSubs, exportedVoteSubmission{
|
exportSubs = append(exportSubs, exportedVoteSubmission{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
OptionID: s.OptionID,
|
OptionID: s.OptionID,
|
||||||
VoterIP: s.VoterIP,
|
|
||||||
FormData: s.FormData,
|
FormData: s.FormData,
|
||||||
CustomText: s.CustomText,
|
CustomText: s.CustomText,
|
||||||
CreatedAt: s.CreatedAt,
|
CreatedAt: s.CreatedAt,
|
||||||
|
|||||||
+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