安全: 修复页面密码零值绕过,导出不再泄露投票者 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,输对密码后放行、改密后旧会话立即失效, 删除迁移账本后重启可正确回填。
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user