chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"path"
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
)
func Login(ctx context.Context) {
ctx.Success("auth/login")
}
func LoginAction(ctx context.Context, f form.Login, recaptcha recaptcha.RecaptchaV3) {
if ctx.HasError() {
ctx.Success("auth/login")
return
}
// Check captcha code (unified: recaptcha, turnstile, or math).
uri := ctx.Request().Request.RequestURI // Keep the query when redirecting.
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect(uri)
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect(uri)
return
}
user, err := db.Users.Authenticate(ctx.Request().Context(), f.Email, f.Password)
if err != nil {
if errors.Is(err, db.ErrBadCredential) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errBadCredential"))
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to authenticate user")
ctx.SetInternalErrorFlash()
}
ctx.Redirect(uri)
return
}
to := ctx.Query("to")
to = path.Clean("/" + to)
if to == "" {
to = "/_/" + user.Domain
}
// 会话固定防护:登录认证成功后、写入用户标识之前,重新生成会话 ID,
// 使登录前种下的匿名会话失效,防止攻击者借固定会话 ID 劫持已认证会话。
if err := ctx.Session.RegenerateID(ctx.ResponseWriter(), ctx.Request().Request); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to regenerate session ID on login")
ctx.SetInternalErrorFlash()
ctx.Redirect(uri)
return
}
ctx.Session.Set("uid", user.ID)
ctx.Session.Set("pwd_ver", user.PasswordVersion)
ctx.Redirect(to)
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"github.com/tamakyi/TamaBox/internal/context"
)
func Logout(ctx context.Context) {
ctx.Session.Flush()
ctx.Redirect("/")
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"os"
"time"
"github.com/flamego/cache"
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/shared"
)
func ForgotPassword(ctx context.Context) {
ctx.Success("auth/forgot-password")
}
func ForgotPasswordAction(ctx context.Context, f form.ForgotPassword, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) {
if ctx.HasError() {
ctx.Success("auth/forgot-password")
return
}
// Check captcha code (unified: recaptcha, turnstile, or math).
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/forgot-password")
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/forgot-password")
return
}
user, err := db.Users.GetByEmail(ctx.Request().Context(), f.Email)
if err != nil {
if errors.Is(err, db.ErrUserNotExists) {
// 防邮箱枚举:不区分邮箱是否存在,统一渲染"已发送"提示页(不实际发信)。
ctx.Data["email"] = f.Email
ctx.Success("auth/forgot-password-sent")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user by email")
ctx.SetInternalErrorFlash()
ctx.Redirect("/forgot-password")
return
}
emailSentCacheKey := "forgot-password-email-sent:" + user.Email
_, err = cache.Get(ctx.Request().Context(), emailSentCacheKey)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to read password recovery email sent cache")
}
} else {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errEmailFrequent"))
ctx.Redirect("/forgot-password")
return
}
code := randstr.String(64)
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
if err := cache.Set(ctx.Request().Context(), recoveryCodeCacheKey, user.ID, 24*time.Hour); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set password recovery code cache")
ctx.SetInternalErrorFlash()
ctx.Redirect("/forgot-password")
return
}
if err := mail.SendPasswordRecoveryMail(user.Email, code); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send password recovery mail")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errEmailFailed"))
ctx.Redirect("/forgot-password")
return
}
if err := cache.Set(ctx.Request().Context(), emailSentCacheKey, time.Now(), 2*time.Minute); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set password recovery email cache")
}
ctx.Data["email"] = user.Email
ctx.Success("auth/forgot-password-sent")
}
func checkRecoverPasswordCode(ctx context.Context, cache cache.Cache) (*db.User, bool) {
code := ctx.Query("code")
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
userIDItf, err := cache.Get(ctx.Request().Context(), recoveryCodeCacheKey)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errCodeExpired"))
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to read password recovery code cache")
ctx.SetInternalErrorFlash()
}
ctx.Redirect("/login")
return nil, false
}
userID, ok := userIDItf.(uint)
if !ok {
logrus.WithContext(ctx.Request().Context()).WithField("user_id_itf", userIDItf).Error("Failed to convert user id interface to uint")
ctx.SetInternalErrorFlash()
ctx.Redirect("/login")
return nil, false
}
user, err := db.Users.GetByID(ctx.Request().Context(), userID)
if err != nil {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errUserNotFound"))
ctx.Redirect("/login")
return nil, false
}
return user, true
}
func RecoverPassword(ctx context.Context, cache cache.Cache) {
user, ok := checkRecoverPasswordCode(ctx, cache)
if !ok {
return
}
ctx.Data["User"] = user
ctx.Success("auth/password-recovery")
}
func RecoverPasswordAction(ctx context.Context, cache cache.Cache, f form.RecoverPassword) {
user, ok := checkRecoverPasswordCode(ctx, cache)
if !ok {
return
}
if ctx.HasError() {
ctx.Data["User"] = user
ctx.Success("auth/password-recovery")
return
}
code := ctx.Query("code")
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
if err := cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete password recovery code cache")
ctx.SetInternalErrorFlash()
ctx.Redirect("/login")
return
}
if err := db.Users.UpdatePassword(ctx.Request().Context(), user.ID, f.NewPassword); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user password")
ctx.SetInternalErrorFlash()
ctx.Refresh()
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "auth.errPasswordChanged"))
ctx.Redirect("/login")
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
)
func Register(ctx context.Context) {
ctx.Success("auth/register")
}
func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.RecaptchaV3) {
if ctx.HasError() {
ctx.Success("auth/register")
return
}
// Check captcha code (unified: recaptcha, turnstile, or math).
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/register")
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/register")
return
}
if err := db.Users.Create(ctx.Request().Context(), db.CreateUserOptions{
Name: f.Name,
Password: f.Password,
Email: f.Email,
Avatar: conf.Upload.DefaultAvatarURL,
Domain: f.Domain,
Background: conf.Upload.DefaultBackground,
Intro: "问你想问的",
Qrcodebackcolor: "#FDF8F0",
Qrcodecolor: "#C45500",
Usernamecolor: "#000000",
Introcolor: "#000000",
Dotscale: "0.35",
BackgroundImage: conf.Upload.DefaultBackgroundImageURL,
Backgroundimagealpha: "0.8",
Qrcodepdpcolor: "#C45500",
Acfunlink: "none",
Bililink: "none",
Weibolink: "none",
Bloglink: "box.shiroko.one",
Xiaohongshulink: "none",
Language: i18n.Resolve(f.Language), // 注册时选择的界面语言;留空回退站点默认
}); err != nil {
switch {
case errors.Is(err, db.ErrDuplicateEmail):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateEmail")))
case errors.Is(err, db.ErrDuplicateDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain")))
case errors.Is(err, db.ErrBadCredential):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errBadCredential")))
default:
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new user")
ctx.SetInternalError()
}
ctx.Success("auth/register")
return
}
// 成功提示跟随用户注册时选择的语言
lang := "zh-CN"
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetSuccessFlash(i18n.T(lang, "auth.register.success"))
ctx.Redirect("/login")
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/stats"
)
func Home(ctx context.Context) {
if v, err := stats.Get(ctx.Request().Context()); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to compute site stats")
} else {
ctx.Data["SiteStats"] = v
}
ctx.Success("home")
}
// SiteStatsAPI 返回首页"提问箱信息统计"的 JSON(服务端 30 分钟缓存),
// 供前端 setInterval 定时刷新,避免重新加载整页。
func SiteStatsAPI(ctx context.Context) error {
v, err := stats.Get(ctx.Request().Context())
if err != nil {
return ctx.JSONError(50000, i18n.T(ctx.Lang(), "home.errStats"))
}
return ctx.JSON(v)
}
func Sponsor(ctx context.Context) {
ctx.Success("sponsor")
}
func AboutMe(ctx context.Context) {
ctx.Success("about-me")
}
func ChangeLogs(ctx context.Context) {
ctx.Success("change-logs")
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2024 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package pixel
import (
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// pixelSafeResponseHeaders 允许从上游透传到客户端的响应头白名单。
// 绝不透传 Set-Cookie / Set-Cookie2 等,避免上游借像素代理向客户端注入会话或凭证。
var pixelSafeResponseHeaders = []string{
"Content-Type",
"Content-Length",
"Content-Encoding",
"Content-Disposition",
"Cache-Control",
"ETag",
"Expires",
"Last-Modified",
"Pragma",
"Vary",
}
func Index(ctx context.Context) {
ctx.Success("pixel")
}
func Proxy(ctx context.Context) error {
uri := ctx.Param("**")
// 路径穿越防护:归一化后必须仍位于 /api/ 之下,
// 否则(如含 ../ 跳出 /api)直接拒绝,防止访问到未授权路径。
cleaned := path.Clean("/api/" + uri)
if !strings.HasPrefix(cleaned, "/api/") {
return ctx.JSONError(http.StatusBadRequest, i18n.T(ctx.Lang(), "pixel.errPath"))
}
userID := strconv.Itoa(int(ctx.User.ID))
// 仅允许只读 GET:写方法(POST/PUT/DELETE)在路由层已被限制为 GET,
// 此处不再处理请求体,杜绝被用作对上游的越权写入口。
req, err := http.NewRequest(http.MethodGet, "http://pixel/", nil)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create request")
return ctx.ServerError()
}
req.URL.Host = conf.Pixel.Host
req.URL.Path = cleaned
req.Header.Set("neko-user-id", userID)
client := http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send request")
return ctx.ServerError()
}
defer func() { _ = resp.Body.Close() }()
// 仅透传白名单内的安全响应头。
for _, k := range pixelSafeResponseHeaders {
if v := resp.Header.Get(k); v != "" {
ctx.ResponseWriter().Header().Set(k, v)
}
}
ctx.ResponseWriter().WriteHeader(resp.StatusCode)
_, err = io.Copy(ctx.ResponseWriter(), resp.Body)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to copy response")
return ctx.ServerError()
}
return nil
}
+208
View File
@@ -0,0 +1,208 @@
package question
import (
stdctx "context"
"fmt"
"unicode/utf8"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/shared"
)
// isQuestioner 判断当前请求者是否为该提问的提问者。
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
func isQuestioner(ctx context.Context, question *db.Question, token string) bool {
if question.Token != "" && question.Token == token {
return true
}
if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID {
return true
}
return false
}
// PublishFollowUp 提问者在问题被回答后,用 token 继续追问。
func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishFollowUpQuestion) {
// govalid 校验失败(追问内容为空)时回显表单,与 AnswerFollowUp 同路径。
// (旧实现漏掉该早退:required 失败后仍会继续 censor/入库,追问空内容也会被创建。)
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 追问内容长度上限(conf.Limits.FollowUpContentLen,[limits] 配置段),
// 与前端追问框 maxlength 同源,rune 计数。
if utf8.RuneCountInString(f.Content) > conf.Limits.FollowUpContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.contentTooLong", conf.Limits.FollowUpContentLen)), f)
ctx.Success("question/item")
return
}
token := ctx.Query("t")
if token == "" {
token = f.Token
}
if !isQuestioner(ctx, question, token) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNoPermission"))
ctx.Redirect("/")
return
}
// 只能对已回答的问题追问
if question.Answer == "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNotAnswered"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
// 追问次数限制:先快速预检查(友好提示),CreateIfBelowLimit 内再原子兜底
// (并发场景下 count-then-create 存在竞态,必须以数据库原子检查为准)。
count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if count >= db.MaxFollowUpCount {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
// 服务端校验"等主人回复":模板仅在前一条被回复后才展示追问表单,
// 但 POST 可被直接构造绕过,因此必须检查最新一条追问是否已回复。
latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get latest follow up")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if latest != nil && latest.Answer == "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 内容安全检测
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor follow up text")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if !censorResponse.Pass {
ctx.SetErrorFlash(censorResponse.ErrorMessage())
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
created, err := db.FollowUps.CreateIfBelowLimit(ctx.Request().Context(), db.CreateFollowUpOptions{
QuestionID: question.ID,
Content: f.Content,
})
if err != nil {
if errors.Is(err, db.ErrFollowUpLimitReached) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create follow up")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
_ = created
// 邮件通知提问箱主人(开启邮件提醒时)
shared.SafeGo(func() {
if pageUser.Notify == db.NotifyTypeEmail {
if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user")
}
}
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.sent"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
}
// AnswerFollowUp 提问箱主人回答某条追问。
func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.AnswerFollowUpQuestion) {
if ctx.User.ID != pageUser.ID {
ctx.Redirect("/")
return
}
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 追问的回答内容长度上限:与主回答共用 conf.Limits.AnswerContentLen
// ([limits] 配置段),与前端回复框 maxlength 同源,rune 计数。
if utf8.RuneCountInString(f.Answer) > conf.Limits.AnswerContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.answerTooLong", conf.Limits.AnswerContentLen)), f)
ctx.Success("question/item")
return
}
followUpID := uint(ctx.ParamInt("followUpID"))
// 内容安全检测
censorResponse, err := censor.Text(ctx.Request().Context(), f.Answer)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor follow up answer")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
ctx.Success("question/item")
return
}
if !censorResponse.Pass {
ctx.SetErrorFlash(censorResponse.ErrorMessage())
ctx.Success("question/item")
return
}
if err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer); err != nil {
if errors.Is(err, db.ErrFollowUpNotFound) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNotFound"))
ctx.Success("question/item")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer follow up")
ctx.SetInternalErrorFlash()
ctx.Success("question/item")
return
}
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接
shared.SafeGo(func() {
if question.ReceiveReplyEmail != "" {
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
}
}
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.answered"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
+787
View File
@@ -0,0 +1,787 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package question
import (
"bytes"
stdctx "context"
"crypto/md5"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime/multipart"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/wuhan005/govalid"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
func Pager(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
if errors.Is(err, db.ErrUserNotExists) {
ctx.Redirect("/")
return
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user by domain")
ctx.SetInternalError()
}
ctx.Success("question/page")
return
}
ctx.Map(pageUser)
// 个人页语言:永远跟随提问箱主人(pageUser)配置的语言,忽略访客 cookie;未配置时回退站点默认语言
if pageUser.Language != "" {
lang := i18n.Resolve(pageUser.Language)
ctx.Data["Lang"] = lang
ctx.Data["LangCode"] = lang
}
// 页面密码检查
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized {
// 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页
ctx.Data["Next"] = ctx.Request().URL.RequestURI()
ctx.Data["PageUser"] = pageUser
ctx.Success("question/password")
return
}
}
// 分页加载公开已回答的问题(首次加载 20 条,置顶优先)
// 多取 1 条探测是否仍有下一页,供模板决定「加载更多」按钮的初始可见状态,
// 避免内容不足一页时按钮仍然显示、点击后才消失。
const pageQuestionsPageSize = 20
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
PageSize: pageQuestionsPageSize + 1, // 每页数量 + 1 条探测行
},
FilterAnswered: true, // 只显示已回答的问题
PrivateStatus: 1, // 1 = 仅公开(is_private = false)
PinFirst: true, // 置顶的问题排在最前
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions by page id")
ctx.SetInternalError()
ctx.Success("question/page")
return
}
pageQuestionHasMore := false
if len(pageQuestions) > pageQuestionsPageSize {
pageQuestions = pageQuestions[:pageQuestionsPageSize]
pageQuestionHasMore = true
}
answeredCount, err := db.Questions.Count(ctx.Request().Context(), pageUser.ID, db.GetQuestionsCountOptions{
FilterAnswered: true,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count questions")
ctx.SetInternalError()
ctx.Success("question/page")
return
}
// 页面标题语言与页面语言保持一致:Data["Lang"] 已由 Pager 上方逻辑或 Contexter 解析好
// (提问箱主人语言 > 站点默认语言 shared.App > i18n.Default;个人页忽略访客 cookie)。
titleLang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
titleLang = l
}
ctx.SetTitle(fmt.Sprintf("%s - %s", pageUser.Name, i18n.T(titleLang, "site.title")))
ctx.Data["IsUserPage"] = true
ctx.Data["IsOwnPage"] = ctx.IsLogged && ctx.User.ID == pageUser.ID
ctx.Data["PageUser"] = pageUser
ctx.Data["PageQuestions"] = pageQuestions
ctx.Data["PageQuestionHasMore"] = pageQuestionHasMore
ctx.Data["AnsweredCount"] = answeredCount
ctx.Data["ChatMaxMessages"] = pageUser.ChatMaxMessages
// 功能开关(开关 + 定时 + 定量收集)统一由 feature_controls 驱动:
// - 提问:关闭后隐藏提问框并提示原因;
// - 私聊:关闭后隐藏/禁用私聊入口;
// - 投票:关闭后投票 Tab 保留显示但置灰禁用,hover 提示原因(不再隐藏入口)。
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
ctx.Data["CanAsk"] = askState.Open && (ctx.IsLogged || pageUser.HarassmentSetting != db.HarassmentSettingTypeRegisterOnly)
ctx.Data["AskState"] = askState
// 二维码下的引导文案随「仅允许注册用户提问」设置联动,避免勾选后仍显示"谁都可以匿名提问"。
ctx.Data["RegisterOnlyAsk"] = pageUser.HarassmentSetting == db.HarassmentSettingTypeRegisterOnly
if !askState.Open {
ctx.Data["AskClosedMessage"] = i18n.FeatureClosedMessage(titleLang, db.FeatureQuestion, askState.Reason)
}
// 关闭状态下展示更多信息:上次关闭时间 / 下次开放时间(含循环定时)。
ctx.Data["AskNextOpenAtStr"] = formatFeatureTime(askState.NextOpenAt)
ctx.Data["AskLastCloseAtStr"] = formatFeatureTime(askState.LastCloseAt)
chatState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
ctx.Data["AllowChat"] = chatState.Open
ctx.Data["ChatState"] = chatState
voteState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureVote)
ctx.Data["VoteEnabled"] = voteState.Open
ctx.Data["VoteState"] = voteState
if !voteState.Open {
// 投票 Tab 置灰禁用时的原因提示(手动关闭 / 未到开放时间 / 已结束 / 已达上限)。
ctx.Data["VoteClosedMessage"] = i18n.FeatureClosedMessage(titleLang, db.FeatureVote, voteState.Reason)
}
// 个人主页初始 Tab:默认提问;支持 ?tab=votes 直达投票面板(可分享链接)。
// hash(#votes)优先级更高,由前端 JS 在渲染后覆盖。
// 投票功能关闭时投票 Tab 置灰不可点,直达链接 ?tab=votes 回落到提问 Tab,
// 否则会渲染出一个所有面板都隐藏的空页面。
activeTab := ctx.Query("tab")
if activeTab != "votes" || !voteState.Open {
activeTab = "questions"
}
ctx.Data["ActiveProfileTab"] = activeTab
// 匿名投票板块:该箱主"进行中"的公开投票(个人主页投票 Tab 展示,最多 20 条)。
// 箱主关闭投票功能时不展示任何投票(也不再浪费一次查询)。
pageVotes := []*db.Vote{}
if voteState.Open {
pageVotes, err = db.Votes.ListPublishedPublicByUserID(ctx.Request().Context(), pageUser.ID, 20)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to list page votes")
pageVotes = []*db.Vote{}
}
}
ctx.Data["PageVotes"] = pageVotes
// 设置下一页游标(如果有数据)。置顶优先场景下必须使用复合键集游标,
// 否则置顶项与非置顶项 id 交错会造成"加载更多"漏掉介于其间的非置顶项。
if len(pageQuestions) > 0 {
last := pageQuestions[len(pageQuestions)-1]
ctx.Data["PageQuestionCursor"] = db.EncodePinCursor(last.IsPinned, last.CreatedAt, last.ID)
} else {
ctx.Data["PageQuestionCursor"] = ""
}
}
// conversationWithFirstMessage 用于在列表展示私聊时附带第一条消息
type conversationWithFirstMessage struct {
Conversation *db.Conversation
FirstMessage string
}
// formatFeatureTime 格式化功能开放/关闭时间供模板展示;nil 返回空串。
func formatFeatureTime(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02 15:04")
}
func PasswordPage(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
ctx.Data["PageUser"] = pageUser
// 直接访问密码页(无"上一页")时,验证后回首页
ctx.Data["Next"] = "/_/" + domain
// 密码页同样遵循提问箱主人配置的语言
if pageUser.Language != "" {
lang := i18n.Resolve(pageUser.Language)
ctx.Data["Lang"] = lang
ctx.Data["LangCode"] = lang
}
ctx.Success("question/password")
}
func VerifyPagePassword(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
password := ctx.Request().FormValue("password")
if pageUser.CheckPagePassword(password) {
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
ctx.Session.Set(passKey, true)
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页
next := ctx.Request().FormValue("next")
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.Contains(next, "://") {
next = "/_/" + domain
}
ctx.Redirect(next)
} else {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword"))
ctx.Redirect("/_/" + domain + "/password")
}
}
func List(ctx context.Context) {
ctx.Success("question/list")
}
func ListAPI(ctx context.Context) error {
domain := ctx.Param("domain")
pageSize := ctx.QueryInt("page_size")
cursorValue := ctx.Query("cursor")
// 规范化每页大小,并多取 1 条用于探测是否仍有下一页(has_more),
// 使前端在加载完最后一页后能立即隐藏「加载更多」按钮,
// 而不是依赖下一次点击返回空列表来收尾(避免最后一页重复展示)。
if pageSize < 1 {
pageSize = dbutil.DefaultPageSize
}
if pageSize > dbutil.MaxDefaultPageSize-1 {
pageSize = dbutil.MaxDefaultPageSize - 1
}
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
if errors.Is(err, db.ErrUserNotExists) {
return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound"))
}
return ctx.ServerError()
}
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
Value: cursorValue,
PageSize: pageSize + 1,
},
FilterAnswered: true,
PrivateStatus: 1, // 只返回公开问题
PinFirst: true,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions by page id")
return ctx.ServerError()
}
// 实际取回条数超过 pageSize 说明还有下一页;裁掉多取的探测行。
hasMore := false
if len(pageQuestions) > pageSize {
pageQuestions = pageQuestions[:pageSize]
hasMore = true
}
// 列表 API 返回纯文本内容(剥离 Markdown 符号/排版,与首屏展示一致)
for _, q := range pageQuestions {
q.Content = templatepkg.PlainText(q.Content)
}
// 响应直接附带服务端编码好的下一页复合键集游标(与 EncodePinCursor 一致)。
// 不在前端用 Date.getTime()*1e6 自行拼游标:JS 双精度浮点无法精确表示纳秒
// 级 UnixNano(毫秒以下精度丢失),时间戳相同的提问会在翻页边界被跳过。
resp := struct {
Data []*db.Question `json:"data"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}{Data: pageQuestions, HasMore: hasMore}
if len(pageQuestions) > 0 {
last := pageQuestions[len(pageQuestions)-1]
resp.NextCursor = db.EncodePinCursor(last.IsPinned, last.CreatedAt, last.ID)
}
return ctx.JSON(resp)
}
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问密码检查
if pageUser.AskPassword != "" {
if !pageUser.CheckAskPassword(f.AskPassword) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
ctx.Success("question/list")
return
}
}
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
isChatRequest := f.StartChat == "true" && ctx.IsLogged
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
if !askState.Open && !isChatRequest {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureQuestion, askState.Reason))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if !ctx.IsLogged && pageUser.HarassmentSetting == db.HarassmentSettingTypeRegisterOnly {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errLoginRequired"))
ctx.Redirect(fmt.Sprintf("/login?to=%s", ctx.Request().Request.RequestURI))
return
}
// ---------- 私聊处理 ----------
if f.StartChat == "true" && ctx.IsLogged {
if ctx.User.ID == pageUser.ID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errSelfChat"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 私聊功能开关 / 定时 / 定量
chatState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
if !chatState.Open {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureChat, chatState.Reason))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 检查是否被对方拉黑
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errBlockedChat"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 检查每日消息限制
if pageUser.DailyMessageLimit > 0 {
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to count today messages")
}
if todayCount >= int64(pageUser.DailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errChatDailyLimit", pageUser.DailyMessageLimit))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
}
if ctx.HasError() {
ctx.Success("question/list")
return
}
// 私聊首条消息长度上限:与对话内后续消息一致(conf.Limits.ChatMessageLen,
// [limits] 配置段)。此分支与提问共用同一 content 字段,长度不能按提问上限约束,
// 必须在入口按聊天语义单独校验。
if utf8.RuneCountInString(f.Content) > conf.Limits.ChatMessageLen {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errTooLong", conf.Limits.ChatMessageLen))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 箱主全局屏蔽词(提问/私聊/投票共用一份词表):首条消息同样拦截,
// 防止通过发起私聊绕过提问入口的屏蔽词校验(发送者是对方,非箱主本人)。
if _, hit := shared.BlockWordHit(pageUser.BlockWords, f.Content); hit {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockWords"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 私聊同样需要验证码校验(防止机器人批量发起私聊刷骚扰信息)
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 同 IP + 相同内容重复拦截(防刷屏)
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errDuplicateSubmit"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 内容安全检测
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
ctx.Success("question/list")
return
}
if !censorResponse.Pass {
ctx.SetError(errors.New(censorResponse.ErrorMessage()), f)
ctx.Success("question/list")
return
}
maxMessages := pageUser.ChatMaxMessages
if maxMessages <= 0 {
// 存量兜底(老数据 / 极端配置):回落到系统硬顶 conf.Limits.ChatMaxMessages
maxMessages = conf.Limits.ChatMaxMessages
}
conv, err := db.Conversations.Create(ctx.Request().Context(), db.CreateConversationOptions{
OwnerID: pageUser.ID,
AskerID: ctx.User.ID,
MaxMessages: maxMessages,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
_, err = db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: f.Content,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create first message")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if err := db.Conversations.IncrementMessageCount(ctx.Request().Context(), conv.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
}
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureChat, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment chat quota")
}
// 请求态数据提前提取,避免 goroutine 在请求结束后访问 ctx.User
askerName := ctx.User.Name
askerEmail := ctx.User.Email
shared.SafeGo(func() {
if pageUser.Notify == db.NotifyTypeEmail {
if err := mail.SendNewConversationMail(pageUser.Email, askerName, pageUser.Domain, conv.Token, f.Content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to owner")
}
if err := mail.SendNewConversationMail(askerEmail, askerName, pageUser.Domain, conv.Token, f.Content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to asker")
}
}
})
chatURL := fmt.Sprintf("/_/%s/chat/%s", pageUser.Domain, conv.Token)
chatAbsURL := fmt.Sprintf("%s%s", strings.TrimRight(conf.App.ExternalURL, "/"), chatURL)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.chatSent", chatAbsURL))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// ---------- 普通提问 ----------
// 提问内容长度上限(conf.Limits.QuestionContentLen,[limits] 配置段),
// 与前端提问框 maxlength 同源。旧实现依赖 govalid maxlen 标签(写死 2000,
// 无法随配置调整),现移到此处以 rune 计数校验。
if utf8.RuneCountInString(f.Content) > conf.Limits.QuestionContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.question.contentTooLong", conf.Limits.QuestionContentLen)), f)
ctx.Success("question/list")
return
}
var receiveReplyEmail string
if f.ReceiveReplyViaEmail != "" {
if errs, ok := govalid.Check(struct {
Email string `valid:"required;email" label:"邮箱地址" msg:"form.email.required"`
}{
Email: f.ReceiveReplyEmail,
}); !ok {
ctx.SetError(errs[0], f)
ctx.Success("question/list")
return
}
receiveReplyEmail = f.ReceiveReplyEmail
}
if ctx.HasError() {
ctx.Success("question/list")
return
}
// Check captcha code (unified: recaptcha, turnstile, or math).
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
content := f.Content
isPrivate := f.IsPrivate != ""
// 统一走 shared.ResolveClientIP:仅 trust_proxy=true 时信任代理头,防止伪造 IP 混入审计记录
fromIP := shared.ResolveClientIP(ctx.Request().Request)
// 同 IP + 相同内容重复拦截(防刷屏)
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(fromIP, content)) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errDuplicateSubmit")), f)
ctx.Success("question/list")
return
}
// 箱主全局屏蔽词(逗号分隔,提问/私聊/投票共用一份词表):命中即拒绝。
if _, hit := shared.BlockWordHit(pageUser.BlockWords, content); hit {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errBlockWords")), f)
ctx.Success("question/list")
return
}
censorResponse, err := censor.Text(ctx.Request().Context(), content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行,
// 避免审核服务宕机期间提问内容免审直接公开。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
ctx.Success("question/list")
return
}
if !censorResponse.Pass {
errorMessage := censorResponse.ErrorMessage()
ctx.SetError(errors.New(errorMessage), f)
ctx.Success("question/list")
return
}
// 统一走 shared.ResolveClientIP(已在重复拦截处解析,此处复用)
var askerUserID uint
if ctx.IsLogged {
askerUserID = ctx.User.ID
}
question, err := db.Questions.Create(ctx.Request().Context(), db.CreateQuestionOptions{
FromIP: fromIP,
UserID: pageUser.ID,
Content: content,
ReceiveReplyEmail: receiveReplyEmail,
AskerUserID: askerUserID,
IsPrivate: isPrivate,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new question")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
// 提问定量收集:一条提问计一次(含私密提问);追问不重复计数
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment question quota")
}
if len(f.Images) > 0 {
image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{
Type: db.UploadImageQuestionTypeAsk,
Image: image,
QuestionID: question.ID,
UploaderUserID: askerUserID,
}); err != nil {
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errImageTooLarge"))
ctx.Success("question/list")
return
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errUploadImageFailed")), f)
ctx.Success("question/list")
return
}
}
}
if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{
ContentCensorMetadata: censorResponse.ToJSON(),
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update question censor result")
}
shared.SafeGo(func() {
if pageUser.Notify == db.NotifyTypeEmail {
if err := mail.SendNewQuestionMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to user")
}
if err := mail.SendNewQuestionMailToUser(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to questioner")
}
}
})
questionPrivateURL := fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)
questionPrivateAbsURL := fmt.Sprintf("%s%s", strings.TrimRight(conf.App.ExternalURL, "/"), questionPrivateURL)
// 提问成功页:强提醒保存私密链接/token(尤其针对未留邮箱的匿名提问者)
ctx.Data["Question"] = question
ctx.Data["PrivateURL"] = questionPrivateURL
ctx.Data["PrivateAbsURL"] = questionPrivateAbsURL
ctx.Data["HasReceiveEmail"] = receiveReplyEmail != ""
titleLang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
titleLang = l
}
ctx.SetTitle(i18n.T(titleLang, "success.pageTitle", i18n.T(titleLang, "site.title")))
ctx.Success("question/success")
}
type uploadImageOptions struct {
Type db.UploadImageQuestionType
Image *multipart.FileHeader
QuestionID uint
UploaderUserID uint
IsDeletingPrevious bool
}
var ErrUploadImageSizeTooLarge = errors.New("图片文件大小不能大于 5Mb")
func uploadImage(ctx context.Context, opts uploadImageOptions) error {
imgFile := opts.Image
fileName := imgFile.Filename
fileExt := filepath.Ext(fileName)
fileSize := imgFile.Size
if fileSize > 1024*1024*5 {
return ErrUploadImageSizeTooLarge
}
uploadImageFile, err := imgFile.Open()
if err != nil {
return errors.Wrap(err, "open image")
}
defer func() { _ = uploadImageFile.Close() }()
// 读取原始文件数据并计算 MD5
hasher := md5.New()
originalData, err := io.ReadAll(io.TeeReader(uploadImageFile, hasher))
if err != nil {
return errors.Wrap(err, "read image data")
}
fileMd5 := fmt.Sprintf("%x", hasher.Sum(nil))
// 根据格式决定是否转换为 WebP
var uploadData []byte
var contentType string
var uploadKey string
// 检测原始格式(解码前先校验像素总量,防止解压炸弹)
if err := imagecheck.CheckPixelLimit(bytes.NewReader(originalData)); err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
return imagecheck.ErrImageTooLarge
}
return errors.Wrap(err, "decode image config")
}
_, format, err := image.Decode(bytes.NewReader(originalData))
if err != nil {
return errors.Wrap(err, "decode image")
}
now := time.Now()
switch format {
case "gif":
// GIF 保持原样上传,不转换(动画 GIF 需要 cwebp 工具支持)
uploadData = originalData
contentType = "image/gif"
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)
default:
// JPEG / PNG 等静态图片转换为 WebP
webpData, err := storage.ConvertStaticImageToWebP(bytes.NewReader(originalData))
if err != nil {
// 转换失败时回退为原始数据
logrus.WithError(err).Warn("WebP conversion failed, falling back to original format")
uploadData = originalData
contentType = contentTypeFromFormat(format)
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)
} else {
uploadData = webpData
contentType = "image/webp"
// WebP 无后缀名,与 UploadPictureToS3 风格一致
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), ".webp")
}
}
client, err := storage.GetS3Client()
if err != nil {
return errors.Wrap(err, "get S3 client")
}
if _, err := client.PutObject(ctx.Request().Context(), &s3.PutObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(uploadKey),
Body: bytes.NewReader(uploadData),
ContentLength: aws.Int64(int64(len(uploadData))),
ContentType: aws.String(contentType),
}); err != nil {
return errors.Wrap(err, "put object")
}
_, err = db.UploadImages.Create(ctx.Request().Context(), db.CreateUploadImageOptions{
Type: opts.Type,
QuestionID: opts.QuestionID,
UploaderUserID: opts.UploaderUserID,
Name: fileName,
FileSize: int64(len(uploadData)),
Md5: fileMd5,
Key: uploadKey,
IsDeletingPrevious: opts.IsDeletingPrevious,
})
if err != nil {
return errors.Wrap(err, "create upload image")
}
return nil
}
// contentTypeFromFormat 根据图片格式返回 MIME 类型
func contentTypeFromFormat(format string) string {
switch format {
case "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
default:
return "application/octet-stream"
}
}
+338
View File
@@ -0,0 +1,338 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package question
import (
stdctx "context"
"fmt"
"net/url"
"unicode/utf8"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/shared"
)
func Questioner(ctx context.Context, pageUser *db.User) {
// 从 URL query 读取 token;追问等 POST 请求 token 在表单隐藏域中,需回退读取表单字段。
// FormValue 同时覆盖 query 与 body(含 multipart),对 GET 请求行为与 Query 一致。
token := ctx.Query("t")
if token == "" {
token = ctx.Request().FormValue("token")
}
questionID := uint(ctx.ParamInt("questionID"))
question, err := db.Questions.GetByID(ctx.Request().Context(), questionID)
if err != nil {
if !errors.Is(err, db.ErrQuestionNotExist) {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get question by ID")
}
ctx.Redirect("/")
return
}
ctx.Data["Question"] = question
askUploadImages, err := db.UploadImages.GetByTypeQuestionID(ctx.Request().Context(), db.UploadImageQuestionTypeAsk, questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get ask upload images")
}
ctx.Data["AskUploadImages"] = askUploadImages
answerUploadImages, err := db.UploadImages.GetByTypeQuestionID(ctx.Request().Context(), db.UploadImageQuestionTypeAnswer, questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get answer upload images")
}
ctx.Data["AnswerUploadImages"] = answerUploadImages
// Check the question is belongs to the correct page user.
// If the question has not been answered, we should check the question is belongs to the correct page user.
// The questioner can use the token to view the question.
// 注意:token 为空的未回答/私密问题同样禁止匿名访问(不能因 token 为空而放行)。
if question.UserID != pageUser.ID ||
((question.Answer == "" || question.IsPrivate) &&
(!ctx.IsLogged || ctx.User.ID != question.UserID) &&
!(question.Token != "" && question.Token == token)) {
ctx.Redirect("/")
return
}
// The page's owner or the question's token can have the permission to delete the question.
// Inject the permission into the context.
canDelete := ctx.IsLogged && ctx.User.ID == pageUser.ID
ctx.Map(canDelete)
ctx.Data["CanDelete"] = canDelete
// ---------- 追问 ----------
followUps, err := db.FollowUps.GetByQuestionID(ctx.Request().Context(), questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get follow ups")
followUps = []*db.FollowUp{}
}
ctx.Data["FollowUps"] = followUps
// 提问者身份:token 匹配(匿名)或登录账号匹配(登录用户)
isQuestioner := isQuestioner(ctx, question, token)
ctx.Data["IsQuestioner"] = isQuestioner
// 是否可追问:已回答 + 提问者 + 追问次数未达上限
followUpRemaining := 0
if isQuestioner && question.Answer != "" {
count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups")
} else {
followUpRemaining = db.MaxFollowUpCount - int(count)
}
}
// 是否可追问:已回答 + 提问者 + 追问次数未达上限 + 上一条追问已被主人回复
// (主人尚未回复上一条追问时,提问者不应继续发起新的追问)
// 等待提示同样仅对提问者展示——主人/旁观者看到的应是回复框或正常内容。
waitingFollowUpReply := false
if isQuestioner && len(followUps) > 0 && followUps[len(followUps)-1].Answer == "" {
waitingFollowUpReply = true
}
canFollowUp := isQuestioner && question.Answer != "" && followUpRemaining > 0 && !waitingFollowUpReply
ctx.Data["CanFollowUp"] = canFollowUp
ctx.Data["WaitingFollowUpReply"] = waitingFollowUpReply
ctx.Data["FollowUpRemaining"] = followUpRemaining
ctx.Data["FollowUpLimit"] = db.MaxFollowUpCount
// 供追问表单隐藏域回传(匿名提问者必须携带 token)
ctx.Data["QuestionToken"] = token
// 返回来源:admin=从管理后台(/user/questions)进入;空=从提问箱主页(/_/domain)或直接访问,
// 默认返回提问箱主页。详情页底部"返回xxx的提问箱"按钮据此决定跳转目标。
backFrom := ctx.Query("from")
ctx.Data["BackFrom"] = backFrom
// 从后台返回时,把进入时携带的筛选参数(sort/status/type/read/q)原样回传,
// 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。
if backFrom == "admin" {
backQuery := url.Values{}
for _, key := range []string{"sort", "status", "type", "read", "q"} {
if v := ctx.Query(key); v != "" {
backQuery.Set(key, v)
}
}
backTo := "/user/questions"
if qs := backQuery.Encode(); qs != "" {
backTo += "?" + qs
}
ctx.Data["BackTo"] = backTo
}
ctx.Map(question)
}
func Item(ctx context.Context) {
ctx.Success("question/item")
}
func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishAnswerQuestion) {
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 回答内容长度上限(conf.Limits.AnswerContentLen,[limits] 配置段),
// 与前端回答框 maxlength 同源(发布与编辑共用,rune 计数)。
if utf8.RuneCountInString(f.Answer) > conf.Limits.AnswerContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.answer.contentTooLong", conf.Limits.AnswerContentLen)), f)
ctx.Success("question/item")
return
}
if ctx.User.ID != pageUser.ID {
ctx.Redirect("/")
return
}
// 纵深防御:确认该提问归属当前页面箱主(Questioner 中间件已保证,这里二次校验防中间件改动引入越权)。
if question.UserID != pageUser.ID {
ctx.Redirect("/")
return
}
answer := f.Answer
// 🚨 Content security check.
censorResponse, err := censor.Text(ctx.Request().Context(), answer)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
ctx.Success("question/item")
return
}
if !censorResponse.Pass {
errorMessage := censorResponse.ErrorMessage()
ctx.SetError(errors.New(errorMessage), f)
ctx.Success("question/item")
return
}
if len(f.Images) > 0 {
image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{
Type: db.UploadImageQuestionTypeAnswer,
Image: image,
QuestionID: question.ID,
UploaderUserID: ctx.User.ID,
IsDeletingPrevious: true,
}); err != nil {
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(err.Error())
ctx.Success("question/item")
return
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
}
}
}
if err := db.Questions.AnswerByID(ctx.Request().Context(), question.ID, f.Answer); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer question")
ctx.SetInternalError(f)
ctx.Success("question/item")
return
}
// Update censor result.
if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{
AnswerCensorMetadata: censorResponse.ToJSON(),
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update answer censor result")
}
shared.SafeGo(func() {
if question.ReceiveReplyEmail != "" && question.Answer == "" { // We only send the email when the question has not been answered.
// Send notification to questioner.
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send receive reply mail to questioner")
}
}
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answered"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
func Delete(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.DeleteByID(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete question")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.Redirect("/_/" + pageUser.Domain)
}
func SetPrivate(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetPrivate(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question private")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
func SetPublic(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetPublic(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question public")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
// TogglePin 置顶/取消置顶(仅提问箱主人,操作入口在我的提问管理页)。
func TogglePin(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetPinned(ctx.Request().Context(), question.ID, !question.IsPinned); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle question pinned")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.operated"))
ctx.Redirect("/user/questions")
}
// SetRead 手动标记提问为已读,跳转回管理列表并定位到该条。
func SetRead(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetRead(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question read")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedRead"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
}
// SetUnread 手动标记提问为未读。已回答的提问锁定为已读,不可改回未读。
// 跳转与 SetRead 一致,回到管理列表并定位到该条,保证"已读/未读"操作路径统一。
func SetUnread(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 已回答的问题锁定为已读:上层直接拦截,避免误导用户。
if question.Answer != "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errAnsweredLocked"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
return
}
if err := db.Questions.SetUnread(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question unread")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedUnread"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
}
+970
View File
@@ -0,0 +1,970 @@
package question
import (
"bytes"
stdctx "context"
"crypto/md5"
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/disintegration/imaging"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
"github.com/sirupsen/logrus"
qrcode "github.com/skip2/go-qrcode"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/template"
)
// 卡片尺寸与留白
const (
CardWidth = 960
CardHeight = 540
Margin = 15
)
// 内容区域边界
const (
ContentX = Margin
ContentY = Margin
ContentW = CardWidth - 2*Margin
ContentH = CardHeight - 2*Margin
)
// 头像参数
const (
AvatarSize = 112
AvatarCX = ContentX + 90
AvatarCY = ContentY + 90
AvatarRingW = 6
AvatarRingR = AvatarSize/2 + AvatarRingW
)
// 左侧文字区域
const (
TextStartX = AvatarCX + AvatarRingR + 30
TextStartY = ContentY + 75
)
// 右侧二维码卡片尺寸
const (
QRCardW = 270
QRCardH = 315
QRCardX = ContentX + ContentW - QRCardW - 15
QRCardY = ContentY + (ContentH-QRCardH)/2
)
// 二维码尺寸
const (
QRCodeSize = 165
QRCodeX = (QRCardW - QRCodeSize) / 2
)
// 社交信息
const (
SocialIconW = 24
SocialIconH = 24
SocialTextX = TextStartX + SocialIconW + 8
SocialLineH = 32
)
// 底部水印
const (
FooterY = ContentY + ContentH - 20
)
// 字体大小
const (
FontSizeName = 36
FontSizeIntro = 20
FontSizeLink = 15
FontSizeSocial = 17
FontSizeQRHint = 17
FontSizeFooter = 14
)
// 颜色系统
var (
ColorWhite = color.RGBA{255, 255, 255, 255}
ColorBlack = color.RGBA{0, 0, 0, 255}
ColorDarkText = color.RGBA{30, 30, 30, 255}
ColorGrayText = color.RGBA{100, 100, 100, 255}
ColorSocialGray = color.RGBA{80, 80, 80, 255}
ColorHintGray = color.RGBA{90, 90, 90, 255}
ColorFooterGray = color.RGBA{150, 150, 150, 255}
OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165}
)
// 全局字体缓存
var (
fontOnce sync.Once
fontCache *truetype.Font
fontErr error
)
func getFont() (*truetype.Font, error) {
fontOnce.Do(func() {
fontBytes, err := os.ReadFile(conf.App.FontPath)
if err != nil {
fontErr = err
return
}
fontCache, fontErr = freetype.ParseFont(fontBytes)
})
return fontCache, fontErr
}
// shareDialer 与 shareDialContext:SSRF 防护——解析域名后校验所有解析 IP 均非内网,
// 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。
// 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘
// 导致分享卡缺图(分享图源同样在 Cloudflare/R2,链路质量差时多 IP 容错尤为必要)。
var shareDialer = &net.Dialer{Timeout: 8 * time.Second, KeepAlive: 30 * time.Second}
// shareDialMaxCandidates 限制并发拨号扇出上限。
const shareDialMaxCandidates = 6
func shareDialContext(ctx stdctx.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, err
}
var candidates []net.IP
for _, ip := range ips {
if isBlockedIP(ip) {
return nil, fmt.Errorf("blocked ip address: %s", ip)
}
candidates = append(candidates, ip)
}
if len(candidates) == 0 {
return nil, fmt.Errorf("no public ip address resolved for %s", host)
}
return shared.DialFirstReachable(ctx, shareDialer, network, candidates, port, shareDialMaxCandidates)
}
// 全局 HTTP 客户端:连接池复用,避免每次请求都重新建立 TCP/TLS 连接。
// 超时预算说明:分享卡图源(头像/背景/社交图标)同样位于 Cloudflare/R2,服务器到该链路
// 质量差(实测单次约 6.5s),总预算放宽以容纳多 IP 并发拨号与慢速首字节;R2 图缺失时
// 分享卡会静默降级(无头像/背景),宁可等待也不轻易放弃。
var shareHTTPClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 8 * time.Second,
ResponseHeaderTimeout: 8 * time.Second,
DialContext: shareDialContext,
},
// SSRF 防护:重定向逐跳校验,防止 302 跳转到内网地址(如 169.254.169.254 云元数据)绕过初始校验
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
if isBlockedImageURL(req.URL.String()) {
return fmt.Errorf("blocked redirect to %s", req.URL)
}
return nil
},
}
// ttlCache 是一个简单的并发安全带 TTL 缓存。
// 带 maxItems 条数上限,超限时先清过期、再淘汰最早过期的一条,
// 防止低配服务器(1C1G)上缓存无限膨胀。
type ttlCache struct {
mu sync.Mutex
items map[string]ttlCacheItem
maxItems int
}
type ttlCacheItem struct {
value interface{}
expiresAt time.Time
}
func newTTLCache(maxItems int) *ttlCache {
c := &ttlCache{items: make(map[string]ttlCacheItem), maxItems: maxItems}
// 常驻清理循环:每轮迭代单独 recover,panic 后循环不中断
go func() {
for range time.Tick(5 * time.Minute) {
func() {
defer func() {
if r := recover(); r != nil {
logrus.WithField("panic", r).Error("ttlCache cleanup panic recovered")
}
}()
c.cleanup()
}()
}
}()
return c
}
func (c *ttlCache) Get(key string) (interface{}, bool) {
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.items[key]
if !ok {
return nil, false
}
if time.Now().After(item.expiresAt) {
delete(c.items, key)
return nil, false
}
return item.value, true
}
func (c *ttlCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
// 超限时先清过期,仍满则淘汰最早过期的一条
if len(c.items) >= c.maxItems {
now := time.Now()
for k, item := range c.items {
if now.After(item.expiresAt) {
delete(c.items, k)
}
}
if len(c.items) >= c.maxItems {
var victim string
var earliest time.Time
first := true
for k, item := range c.items {
if first || item.expiresAt.Before(earliest) {
earliest = item.expiresAt
victim = k
first = false
}
}
if victim != "" {
delete(c.items, victim)
}
}
}
c.items[key] = ttlCacheItem{value: value, expiresAt: time.Now().Add(ttl)}
}
func (c *ttlCache) cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for k, item := range c.items {
if now.After(item.expiresAt) {
delete(c.items, k)
}
}
}
// 远程图片缓存(背景图、头像、社交图标),TTL 1 小时。
// 存的是解码后的 image.Image,最吃内存(1280×720 RGBA ≈ 3.7MB/张),
// 1C1G 环境下限制为 24 条:正常站点同一时刻活跃用户远小于此值。
// 社交图标 URL 固定不变,基本永远命中;头像/背景变更时最多延迟 1 小时生效。
var imageCache = newTTLCache(24)
// 分享图结果缓存,TTL 30 分钟。key 含用户资料指纹,资料变更后自动失效。
// 存的是 PNG 编码字节(约 0.2~1MB/张),限制 32 条,总内存 ≤ 32MB。
var shareCardCache = newTTLCache(32)
func ShareCard(ctx context.Context) {
domain := ctx.Param("domain")
user, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
// 命中缓存直接返回,避免重复下载图片与重新绘制
cacheKey := shareCardCacheKey(user)
if cached, ok := shareCardCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte))
return
}
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
// 1. 背景
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
innerRect := image.Rect(ContentX, ContentY, ContentX+ContentW, ContentY+ContentH)
// ---- 并发下载所有远程图片 ----
var (
bgImg image.Image
avImg image.Image
)
icons := []struct {
show bool
url string
icon string
}{
{user.Acfunlink != "" && user.Acfunlink != "none", user.Acfunlink, "https://r2.337845818.best/tamabox/icon/social/ailicili.png"},
{user.Bililink != "" && user.Bililink != "none", user.Bililink, "https://r2.337845818.best/tamabox/icon/social/bili.png"},
{user.Weibolink != "" && user.Weibolink != "none", user.Weibolink, "https://r2.337845818.best/tamabox/icon/social/weibo.png"},
{user.Bloglink != "" && user.Bloglink != "none", user.Bloglink, "https://r2.337845818.best/tamabox/icon/social/blog.png"},
{user.Xiaohongshulink != "" && user.Xiaohongshulink != "none", user.Xiaohongshulink, "https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png"},
}
iconImgs := make([]image.Image, len(icons))
var wg sync.WaitGroup
// 背景图
bgURL := user.Background
if bgURL == "" {
bgURL = conf.App.ShareBackground
}
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(bgURL, ContentW, ContentH)
if err == nil {
bgImg = img
}
})
// 头像
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(user.Avatar, AvatarSize, AvatarSize)
if err == nil {
avImg = img
}
})
// 社交图标
for i, item := range icons {
if !item.show {
continue
}
idx, iconURL := i, item.icon
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(iconURL, SocialIconW, SocialIconH)
if err == nil {
iconImgs[idx] = img
}
})
}
wg.Wait()
// ---- 下载结束 ----
// 绘制背景
if bgImg != nil {
draw.Draw(canvas, innerRect, bgImg, image.Point{}, draw.Src)
}
overlay := image.NewUniform(OverlayColor)
draw.Draw(canvas, innerRect, overlay, image.Point{}, draw.Over)
// 2. 加载字体(复用缓存)
ft, err := getFont()
if err != nil {
http.Error(ctx.ResponseWriter(), "分享图生成失败:字体文件未配置或无法加载", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load font for share card")
return
}
fontCtx := freetype.NewContext()
fontCtx.SetFont(ft)
fontCtx.SetFontSize(24)
fontCtx.SetDPI(72)
// 3. 头像
drawCircleFilled(canvas, AvatarCX, AvatarCY, AvatarRingR, ColorWhite)
if avImg != nil {
avImg = imaging.Fill(avImg, AvatarSize, AvatarSize, imaging.Center, imaging.Lanczos)
mask := image.NewAlpha(image.Rect(0, 0, AvatarSize, AvatarSize))
drawCircle(mask, AvatarSize/2, AvatarSize/2, AvatarSize/2, color.Alpha{255})
draw.DrawMask(canvas,
image.Rect(AvatarCX-AvatarSize/2, AvatarCY-AvatarSize/2, AvatarCX+AvatarSize/2, AvatarCY+AvatarSize/2),
avImg, image.Point{}, mask, image.Point{}, draw.Over)
} else {
mask := image.NewAlpha(image.Rect(0, 0, AvatarSize, AvatarSize))
drawCircle(mask, AvatarSize/2, AvatarSize/2, AvatarSize/2, color.Alpha{255})
draw.DrawMask(canvas,
image.Rect(AvatarCX-AvatarSize/2, AvatarCY-AvatarSize/2, AvatarCX+AvatarSize/2, AvatarCY+AvatarSize/2),
&image.Uniform{color.Gray{180}}, image.Point{}, mask, image.Point{}, draw.Over)
}
// 4. 左侧文字
// 文字区裁剪边界:左侧文字不得侵入右侧二维码卡片(QRCardX 左侧留 20px 间距)。
leftClip := image.Rect(TextStartX, ContentY, QRCardX-20, ContentY+ContentH)
leftMaxW := QRCardX - TextStartX - 60
y := TextStartY
drawTextWithShadow(canvas, user.Name, TextStartX, y, FontSizeName, ColorDarkText, color.NRGBA{0, 0, 0, 50}, 1, fontCtx)
y += int(FontSizeName) + 15
// 简介最多 4 行,超出截断加省略号,避免长简介向下覆盖社交区/水印
y = drawTextMultilineLimited(canvas, user.Intro, TextStartX, y, leftMaxW, FontSizeIntro, ColorGrayText, fontCtx, 46, 4, leftClip)
y += 30
// 5. 社交信息
socialY := y + 22
for i, item := range icons {
if !item.show || iconImgs[i] == nil {
continue
}
draw.Draw(canvas,
image.Rect(TextStartX, socialY, TextStartX+SocialIconW, socialY+SocialIconH),
iconImgs[i], image.Point{}, draw.Over)
drawTextClipped(canvas, item.url, SocialTextX, socialY+SocialIconH-6, FontSizeSocial, ColorSocialGray, fontCtx, leftClip)
socialY += SocialLineH
}
// 6. 右侧二维码卡片
cardRect := image.Rect(QRCardX, QRCardY, QRCardX+QRCardW, QRCardY+QRCardH)
cardCenterX := QRCardX + QRCardW/2
drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 18)
drawRoundedRect(canvas, cardRect, ColorWhite, 18)
boxURL := fmt.Sprintf("%s/_/%s", conf.App.ExternalURL, user.Domain)
qr, err := generateQR(boxURL, user, avImg)
if err != nil {
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code")
return
}
qrResized := imaging.Resize(qr, QRCodeSize, QRCodeSize, imaging.NearestNeighbor)
const (
qrToHintGap = 26
hintLineHeight = 17
hintToSepGap = 21
sepHeight = 1
sepToLinkGap = 21
linkLineHeight = 15
)
totalContentH := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight
startY := QRCardY + (QRCardH-totalContentH)/2
qrDrawX := QRCardX + QRCodeX
qrDrawY := startY
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QRCodeSize, qrDrawY+QRCodeSize), qrResized, image.Point{}, draw.Over)
hintY := qrDrawY + QRCodeSize + qrToHintGap + hintLineHeight
drawTextCentered(canvas, "扫码向我匿名提问", cardCenterX, hintY, FontSizeQRHint, ColorHintGray, fontCtx)
sepY := hintY + hintToSepGap
drawLine(canvas, cardCenterX-90, sepY, cardCenterX+90, sepY, color.NRGBA{200, 200, 200, 180})
linkY := sepY + sepToLinkGap + linkLineHeight
linkText := boxURL
linkFontSize := float64(FontSizeLink)
availableW := QRCardW - 45
actualW := measureTextWidth(linkText, linkFontSize)
if actualW > float64(availableW) {
linkFontSize = linkFontSize * float64(availableW) / actualW
}
drawTextCentered(canvas, linkText, cardCenterX, linkY, linkFontSize, ColorDarkText, fontCtx)
// 7. 底部水印
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, FooterY, FontSizeFooter, ColorFooterGray, fontCtx)
// 8. 输出(缓存 + 响应)
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, canvas, &jpeg.Options{Quality: 88}); err != nil {
http.Error(ctx.ResponseWriter(), "图片生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode share card PNG")
return
}
shareCardCache.Set(cacheKey, buf.Bytes(), time.Hour)
writeShareCardResponse(ctx, buf.Bytes())
}
// writeShareCardResponse 统一输出分享图响应。
// 加 Cache-Control 让浏览器与 CDN(如 Cloudflare)缓存,进一步减轻服务器压力。
func writeShareCardResponse(ctx context.Context, data []byte) {
ctx.ResponseWriter().Header().Set("Content-Type", "image/jpeg")
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename=\"share.jpg\"")
ctx.ResponseWriter().Header().Set("Content-Length", strconv.Itoa(len(data)))
ctx.ResponseWriter().Header().Set("Cache-Control", "public, max-age=300")
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write(data)
}
// shareCardCacheKey 基于用户资料生成缓存指纹。
// 资料(名称/简介/头像/背景/社交链接)任何一项变更都会导致 key 变化,缓存自动失效。
func shareCardCacheKey(user *db.User) string {
h := md5.New()
fmt.Fprintf(h, "%s|%s|%s|%s|%s|%s|%s|%s|%s|%s",
user.Domain, user.Name, user.Intro, user.Avatar, user.Background,
user.Acfunlink, user.Bililink, user.Weibolink, user.Bloglink, user.Xiaohongshulink)
return fmt.Sprintf("sharecard:%x", h.Sum(nil))
}
// ---------- 工具函数 ----------
// maxImagePixels 限制远程图片解码后的最大像素总量(4096×4096≈67MB RGBA),
// 防止恶意超大图耗尽服务器内存。
const maxImagePixels = 4096 * 4096
// isBlockedImageURL SSRF 防护:仅允许 http/https,且主机不能解析到私网/环回等内网地址。
func isBlockedImageURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return true
}
if u.Scheme != "http" && u.Scheme != "https" {
return true
}
host := u.Hostname()
if host == "" {
return true
}
// 直接是 IP 字面量:私网/环回/链路本地/未指定/组播一律拒绝
if ip := net.ParseIP(host); ip != nil {
return isBlockedIP(ip)
}
// 域名:解析出任一内网 IP 即拒绝
ips, err := net.LookupIP(host)
if err != nil {
// 解析失败交给 HTTP 层报错(不阻断),避免 DNS 临时故障误伤正常图片
return false
}
for _, ip := range ips {
if isBlockedIP(ip) {
return true
}
}
return false
}
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
func loadImage(url string, w, h int) (image.Image, error) {
// SSRF 防护:拒绝非 http/https 与内网地址(127.0.0.1、10.x、192.168.x 等)
if isBlockedImageURL(url) {
return nil, fmt.Errorf("blocked image url: %s", url)
}
key := fmt.Sprintf("%s|%dx%d", url, w, h)
if cached, ok := imageCache.Get(key); ok {
return cached.(image.Image), nil
}
resp, err := shareHTTPClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad status: %s", resp.Status)
}
img, _, err := image.Decode(resp.Body)
if err != nil {
return nil, err
}
// 解码后限制像素总量,防止超大图耗尽内存
b := img.Bounds()
if b.Dx()*b.Dy() > maxImagePixels {
return nil, fmt.Errorf("image too large: %dx%d", b.Dx(), b.Dy())
}
filled := imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos)
imageCache.Set(key, filled, time.Hour)
return filled, nil
}
func drawCircle(mask *image.Alpha, cx, cy, r int, c color.Alpha) {
for x := -r; x <= r; x++ {
for y := -r; y <= r; y++ {
if x*x+y*y <= r*r {
mask.SetAlpha(cx+x, cy+y, c)
}
}
}
}
func drawCircleFilled(dst *image.RGBA, cx, cy, r int, clr color.Color) {
for x := -r; x <= r; x++ {
for y := -r; y <= r; y++ {
if x*x+y*y <= r*r {
dst.Set(cx+x, cy+y, clr)
}
}
}
}
func drawRoundedRect(dst *image.RGBA, rect image.Rectangle, clr color.Color, radius int) {
draw.Draw(dst, image.Rect(rect.Min.X+radius, rect.Min.Y, rect.Max.X-radius, rect.Max.Y),
&image.Uniform{clr}, image.Point{}, draw.Over)
draw.Draw(dst, image.Rect(rect.Min.X, rect.Min.Y+radius, rect.Max.X, rect.Max.Y-radius),
&image.Uniform{clr}, image.Point{}, draw.Over)
drawCircleFilledRGBA(dst, rect.Min.X+radius, rect.Min.Y+radius, radius, clr)
drawCircleFilledRGBA(dst, rect.Max.X-radius, rect.Min.Y+radius, radius, clr)
drawCircleFilledRGBA(dst, rect.Min.X+radius, rect.Max.Y-radius, radius, clr)
drawCircleFilledRGBA(dst, rect.Max.X-radius, rect.Max.Y-radius, radius, clr)
}
func drawCircleFilledRGBA(dst *image.RGBA, cx, cy, r int, clr color.Color) {
for x := -r; x <= r; x++ {
for y := -r; y <= r; y++ {
if x*x+y*y <= r*r {
dst.Set(cx+x, cy+y, clr)
}
}
}
}
func drawLine(dst *image.RGBA, x1, y1, x2, y2 int, clr color.Color) {
if y1 != y2 {
return
}
for x := x1; x <= x2; x++ {
dst.Set(x, y1, clr)
}
}
func drawText(dst *image.RGBA, text string, x, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) {
ftCtx.SetClip(dst.Bounds())
ftCtx.SetSrc(image.NewUniform(col))
ftCtx.SetFontSize(fontSize)
ftCtx.SetDst(dst)
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
}
// drawTextClipped 同 drawText,但将绘制裁剪到 clip 区域。
// 用于左侧文字区:即使宽度估算偏差导致某行文字偏宽,也只会被裁掉,不会侵入右侧二维码卡片。
func drawTextClipped(dst *image.RGBA, text string, x, y int, fontSize float64, col color.Color, ftCtx *freetype.Context, clip image.Rectangle) {
ftCtx.SetClip(clip)
ftCtx.SetSrc(image.NewUniform(col))
ftCtx.SetFontSize(fontSize)
ftCtx.SetDst(dst)
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
}
// drawTextWithShadow 先绘制偏移阴影再绘制原色文字,增强在复杂背景上的可读性。
func drawTextWithShadow(dst *image.RGBA, text string, x, y int, fontSize float64, col, shadow color.Color, offset int, ftCtx *freetype.Context) {
drawText(dst, text, x+offset, y+offset, fontSize, shadow, ftCtx)
drawText(dst, text, x, y, fontSize, col, ftCtx)
}
func splitText(text string, fontSize float64, maxWidth int) []string {
var lines []string
var currentLine []rune
var currentWidth float64
for _, r := range text {
charW := charPixelWidth(r, fontSize)
if currentWidth+charW > float64(maxWidth) && len(currentLine) > 0 {
lines = append(lines, string(currentLine))
currentLine = []rune{r}
currentWidth = charW
} else {
currentLine = append(currentLine, r)
currentWidth += charW
}
}
if len(currentLine) > 0 {
lines = append(lines, string(currentLine))
}
return lines
}
func charPixelWidth(r rune, fontSize float64) float64 {
// 宽度系数取「保守上界」:中文字体是方形,实际渲染宽度≈fontSize;
// 若估算偏窄,splitText 换行后的行宽会超出 maxWidth,文字侵入相邻区域。
// 这里按实际宽度的上界估算,保证 splitText 的行数是安全的(宁可多换行,配合行数截断)。
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Punct, r) || unicode.Is(unicode.Symbol, r) {
return fontSize * 1.0
}
return fontSize * 0.62
}
// measureTextWidth 用逐字符宽度估算(与 splitText 的 charPixelWidth 一致),
// 避免每次测量都创建 freetype.Context 并执行 DrawString(显著降低生成耗时)。
func measureTextWidth(text string, fontSize float64) float64 {
return measureTextEstimate(text, fontSize)
}
func measureTextEstimate(text string, fontSize float64) float64 {
total := 0.0
for _, r := range text {
total += charPixelWidth(r, fontSize)
}
return total
}
func drawTextCentered(dst *image.RGBA, text string, cx, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) {
ftCtx.SetClip(dst.Bounds())
ftCtx.SetSrc(image.NewUniform(col))
ftCtx.SetFontSize(fontSize)
actualW := measureTextWidth(text, fontSize)
x := cx - int(actualW/2)
if x < 0 {
x = 0
}
ftCtx.SetDst(dst)
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
}
func generateQR(content string, user *db.User, logoImg image.Image) (image.Image, error) {
qr, err := qrcode.New(content, qrcode.High)
if err != nil {
return nil, err
}
// 强制传统黑白,忽略用户所有颜色设置
qr.BackgroundColor = color.RGBA{255, 255, 255, 255}
qr.ForegroundColor = color.RGBA{0, 0, 0, 255}
qrPaletted := qr.Image(256)
rgba := image.NewRGBA(qrPaletted.Bounds())
draw.Draw(rgba, rgba.Bounds(), qrPaletted, image.Point{}, draw.Src)
if logoImg != nil {
logo := imaging.Fill(logoImg, 48, 48, imaging.Center, imaging.Lanczos)
bounds := rgba.Bounds()
logoPos := image.Pt(
bounds.Min.X+(bounds.Dx()-logo.Bounds().Dx())/2,
bounds.Min.Y+(bounds.Dy()-logo.Bounds().Dy())/2,
)
draw.Draw(rgba, logo.Bounds().Add(logoPos), logo, image.Point{}, draw.Over)
}
return rgba, nil
}
// ---------- 单题分享图 ----------
// 单题分享图布局常量(1280x720)
const (
QContentX = 45
QContentY = 45
QContentW = CardWidth - 2*QContentX
QContentH = CardHeight - 2*QContentY
QAvatarSize = 33
QAvatarX = QContentX + 2
QAvatarY = QContentY + 2
QNameX = QAvatarX + QAvatarSize + 12
QNameY = QContentY + 28
QSepY = QContentY + 50
QLabelFont = 20.0
QQuestionFont = 26.0
QQuestionLineH = 34
QQuestionMaxLine = 3
QAnswerFont = 22.0
QAnswerLineH = 30
QAnswerMaxLine = 7
// 右侧二维码卡片
QQRCardW = 210
QQRCardH = 255
QQRCardX = QContentX + QContentW - QQRCardW
QQRCardY = QContentY + (QContentH-QQRCardH)/2
QQRSize = 128
QQRCodeX = (QQRCardW - QQRSize) / 2
// 内容区最大宽度(左侧,避开二维码卡片)
QTextMaxW = QQRCardX - QContentX - 30
QWatermarkY = CardHeight - 30
)
// 单题分享图结果缓存,TTL 30 分钟,上限 16 条(1C1G 内存友好)。
var questionShareCache = newTTLCache(16)
// QuestionShareCard 生成单个问题(问题+回答)的分享卡片图。
func QuestionShareCard(ctx context.Context) {
domain := ctx.Param("domain")
user, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
questionID := uint(ctx.ParamInt("questionID"))
question, err := db.Questions.GetByID(ctx.Request().Context(), questionID)
if err != nil {
ctx.Redirect("/")
return
}
// 仅公开且已回答的问题可以生成分享图(Questioner 中间件已挡住私密/未回答,这里再兜底一次)
if question.UserID != user.ID || question.Answer == "" || question.IsPrivate {
ctx.Redirect("/")
return
}
cacheKey := questionShareCardCacheKey(user, question)
if cached, ok := questionShareCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte))
return
}
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
innerRect := image.Rect(QContentX, QContentY, QContentX+QContentW, QContentY+QContentH)
// ---- 并发加载背景与头像 ----
var (
bgImg image.Image
avImg image.Image
)
var wg sync.WaitGroup
bgURL := user.Background
if bgURL == "" {
bgURL = conf.App.ShareBackground
}
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(bgURL, QContentW, QContentH)
if err == nil {
bgImg = img
}
})
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(user.Avatar, QAvatarSize, QAvatarSize)
if err == nil {
avImg = img
}
})
wg.Wait()
// 背景 + 白色遮罩
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
if bgImg != nil {
draw.Draw(canvas, innerRect, bgImg, image.Point{}, draw.Src)
}
draw.Draw(canvas, innerRect, image.NewUniform(OverlayColor), image.Point{}, draw.Over)
// 字体
ft, err := getFont()
if err != nil {
http.Error(ctx.ResponseWriter(), "分享图生成失败:字体文件未配置或无法加载", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load font for question share card")
return
}
fontCtx := freetype.NewContext()
fontCtx.SetFont(ft)
fontCtx.SetFontSize(24)
fontCtx.SetDPI(72)
// 头部:头像 + 名字
drawCircleFilled(canvas, QAvatarX+QAvatarSize/2, QAvatarY+QAvatarSize/2, QAvatarSize/2+2, ColorWhite)
if avImg != nil {
avImg = imaging.Fill(avImg, QAvatarSize, QAvatarSize, imaging.Center, imaging.Lanczos)
mask := image.NewAlpha(image.Rect(0, 0, QAvatarSize, QAvatarSize))
drawCircle(mask, QAvatarSize/2, QAvatarSize/2, QAvatarSize/2, color.Alpha{255})
draw.DrawMask(canvas,
image.Rect(QAvatarX, QAvatarY, QAvatarX+QAvatarSize, QAvatarY+QAvatarSize),
avImg, image.Point{}, mask, image.Point{}, draw.Over)
} else {
mask := image.NewAlpha(image.Rect(0, 0, QAvatarSize, QAvatarSize))
drawCircle(mask, QAvatarSize/2, QAvatarSize/2, QAvatarSize/2, color.Alpha{255})
draw.DrawMask(canvas,
image.Rect(QAvatarX, QAvatarY, QAvatarX+QAvatarSize, QAvatarY+QAvatarSize),
&image.Uniform{color.Gray{180}}, image.Point{}, mask, image.Point{}, draw.Over)
}
drawTextWithShadow(canvas, user.Name+" 的提问箱", QNameX, QNameY, 22, ColorDarkText, color.NRGBA{0, 0, 0, 45}, 1, fontCtx)
// 分隔线
drawLine(canvas, QContentX+2, QSepY, QContentX+QContentW-2, QSepY, color.NRGBA{190, 190, 190, 200})
// 左侧文字区裁剪边界:问题/回答文字不得侵入右侧二维码卡片(QQRCardX 左侧留 10px 间距)。
qLeftClip := image.Rect(QContentX, QContentY, QQRCardX-10, QContentY+QContentH)
// 问题区
labelY := QSepY + 32
drawText(canvas, "提问", QContentX+2, labelY, QLabelFont, ColorGrayText, fontCtx)
textY := labelY + 30
textY = drawTextMultilineLimited(canvas, sanitizeText(template.MarkdownToPlainText(question.Content)), QContentX+2, textY, QTextMaxW, QQuestionFont, ColorDarkText, fontCtx, QQuestionLineH, QQuestionMaxLine, qLeftClip)
// 回答区
labelY = textY + 22
drawText(canvas, "回答", QContentX+2, labelY, QLabelFont, ColorGrayText, fontCtx)
textY = labelY + 28
textY = drawTextMultilineLimited(canvas, sanitizeText(template.MarkdownToPlainText(question.Answer)), QContentX+2, textY, QTextMaxW, QAnswerFont, ColorDarkText, fontCtx, QAnswerLineH, QAnswerMaxLine, qLeftClip)
_ = textY
// 右侧二维码卡片
cardRect := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH)
drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 20)
drawRoundedRect(canvas, cardRect, ColorWhite, 20)
questionURL := fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, user.Domain, question.ID)
qr, err := generateQR(questionURL, user, avImg)
if err != nil {
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card")
return
}
qrResized := imaging.Resize(qr, QQRSize, QQRSize, imaging.NearestNeighbor)
qrDrawX := QQRCardX + QQRCodeX
// 二维码与提示语整体垂直居中(已删除底部冗余的长链接,卡片更简洁)
qrDrawY := QQRCardY + 40
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QQRSize, qrDrawY+QQRSize), qrResized, image.Point{}, draw.Over)
hintY := qrDrawY + QQRSize + 30
drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, hintY, 17, ColorHintGray, fontCtx)
// 底部水印
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, QWatermarkY, FontSizeFooter, ColorFooterGray, fontCtx)
// 输出(缓存 + 响应)
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, canvas, &jpeg.Options{Quality: 88}); err != nil {
http.Error(ctx.ResponseWriter(), "图片生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode question share card PNG")
return
}
questionShareCache.Set(cacheKey, buf.Bytes(), time.Hour)
writeShareCardResponse(ctx, buf.Bytes())
}
// questionShareCardCacheKey 基于问题内容与用户资料生成缓存指纹,内容/资料变更自动失效。
func questionShareCardCacheKey(user *db.User, question *db.Question) string {
h := md5.New()
fmt.Fprintf(h, "q|%d|%s|%s|%s|%s|%s", question.ID, question.Content, question.Answer, user.Name, user.Avatar, user.Background)
return fmt.Sprintf("qshare:%x", h.Sum(nil))
}
// sanitizeText 将换行符替换为空格,避免绘图时排版错乱。
func sanitizeText(text string) string {
return strings.ReplaceAll(text, "\n", " ")
}
// drawTextMultilineLimited 多行绘制,超过 maxLines 行时截断并追加省略号。
// clip 限定绘制区域,防止估算换行偏差导致文字溢出到相邻元素(如右侧二维码卡片)。
func drawTextMultilineLimited(dst *image.RGBA, text string, x, y int, maxWidth int, fontSize float64, col color.Color, ftCtx *freetype.Context, lineHeight int, maxLines int, clip image.Rectangle) int {
lines := splitText(text, fontSize, maxWidth)
if len(lines) > maxLines {
lines = lines[:maxLines]
lines[maxLines-1] = lines[maxLines-1] + "…"
}
curY := y
for _, line := range lines {
drawTextClipped(dst, line, x, curY, fontSize, col, ftCtx, clip)
curY += lineHeight
}
return curY
}
+148
View File
@@ -0,0 +1,148 @@
package service
import (
"crypto/tls"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// serviceTransport 复用的共享 Transport,避免每条请求新建导致连接无法复用、
// 且无超时的资源泄漏。带响应头/空闲连接超时,防止后端挂死时请求无限期阻塞。
var (
serviceTransportOnce sync.Once
serviceTransport http.RoundTripper
)
func getServiceTransport() http.RoundTripper {
serviceTransportOnce.Do(func() {
serviceTransport = &http.Transport{
// 默认校验证书;仅当配置 tls_insecure_skip_verify=true(内网自签名证书后端)时跳过。
TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.Service.TLSInsecureSkipVerify},
ResponseHeaderTimeout: 15 * time.Second,
IdleConnTimeout: 30 * time.Second,
ExpectContinueTimeout: 2 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
}
})
return serviceTransport
}
// singleJoiningSlash 将基础路径与附加路径安全拼接,避免双斜杠或丢失分隔符。
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}
func Proxy(ctx context.Context) error {
span := trace.SpanFromContext(ctx.Request().Context())
var userID uint
if ctx.IsLogged {
userID = ctx.User.ID
}
if span.SpanContext().IsValid() {
span.SetAttributes(
attribute.Int("nekobox.service.user-id", int(userID)),
)
}
uri := ctx.Param("**")
basePath := strings.Split(uri, "/")[0]
forwardPath := strings.TrimPrefix(uri, basePath)
var forwardURLStr string
for _, backend := range conf.Service.Backends {
if backend.Prefix == basePath {
forwardURLStr = backend.ForwardURL
break
}
}
if len(forwardURLStr) == 0 {
return ctx.JSONError(http.StatusNotFound, i18n.T(ctx.Lang(), "proxy.errNotFound"))
}
forwardURL, err := url.Parse(forwardURLStr)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to parse forward URL")
return ctx.JSONError(http.StatusInternalServerError, i18n.T(ctx.Lang(), "proxy.errInternal"))
}
// 路径穿越防护:先用 path.Clean 归一化,剥离 ../ 等相对段,
// 确保转发路径始终相对于后端基址,不会被 ../ 越出白名单前缀。
forwardPath = path.Clean(forwardPath)
if !strings.HasPrefix(forwardPath, "/") {
forwardPath = "/" + forwardPath
}
reverseProxy := httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL = forwardURL
req.URL.Path = singleJoiningSlash(forwardURL.Path, forwardPath)
// 透传原始查询串(此前静默丢弃,导致带参请求后端收不到 query)。
req.URL.RawQuery = ctx.Request().URL.RawQuery
req.Host = forwardURL.Host
// 安全加固:不透传用户浏览器带来的敏感请求头给后端。
// 后端通过 X-NekoBox-User-ID 识别用户身份,不应接收用户的会话
// Cookie / 鉴权头,否则可能被用于横向越权或凭证泄露。
// 同时清除客户端伪造的 X-Forwarded-*,避免请求头注入。
req.Header.Del("Cookie")
req.Header.Del("Authorization")
req.Header.Del("Proxy-Authorization")
req.Header.Del("X-Forwarded-For")
req.Header.Del("X-Forwarded-Host")
req.Header.Del("X-Forwarded-Proto")
traceHeaders := http.Header{}
otel.GetTextMapPropagator().Inject(ctx.Request().Context(), propagation.HeaderCarrier(traceHeaders))
for key := range traceHeaders {
req.Header.Set(key, traceHeaders.Get(key))
}
req.Header.Set("X-NekoBox-From", "nekobox-gateway")
req.Header.Set("X-NekoBox-User-ID", strconv.Itoa(int(userID)))
},
Transport: getServiceTransport(),
ErrorHandler: func(writer http.ResponseWriter, request *http.Request, err error) {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to handle reverse proxy request")
_ = ctx.JSONError(http.StatusInternalServerError, i18n.T(ctx.Lang(), "proxy.errInternal"))
},
}
// 审计日志:记录每一次反代转发(谁、哪个前缀、哪条路径、转发到哪个后端),
// 便于事后排查异常访问与越权探测。
logrus.WithContext(ctx.Request().Context()).WithFields(logrus.Fields{
"user_id": userID,
"prefix": basePath,
"path": forwardPath,
"backend": forwardURLStr,
}).Info("Service proxy forwarding request")
reverseProxy.ServeHTTP(ctx.ResponseWriter(), ctx.Request().Request)
return nil
}
+299
View File
@@ -0,0 +1,299 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
stdctx "context"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/flamego/cache"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// hotVotesCacheKey 探索页热门投票缓存 key(缓存 60s,避免每次 COUNT 聚合)。
// v2:改用独立 DTO 缓存,修复 db.Vote.LinkToken 因 json:"-" 在 Redis 序列化时丢失、
// 导致热门投票链接退化为 /vote/ 的问题。
// v3:额外保留 UserID,用于按箱主的投票功能开关过滤。
const hotVotesCacheKey = "explore:hot_votes:v3"
// hotVoteCacheItem 热门投票缓存条目:独立 DTO,显式保留 LinkToken(db.Vote 的
// LinkToken 带 json:"-" 标签,直接 Marshal Vote 会丢失,缓存命中后链接失效)。
type hotVoteCacheItem struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
LinkToken string `json:"link_token"`
CreatedAt time.Time `json:"created_at"`
}
var tagColors = []string{
"uk-label-success",
"uk-label-warning",
"uk-label-danger",
"uk-label-primary",
"uk-label-secondary",
}
type exploreUser struct {
*db.User
TagList []string
TagColors []string
}
// getHotVotesCached 读取探索页热门投票,带 Redis 缓存(60s)。
// 存储格式为 JSON []byte,避免 gob 序列化对模型类型注册的依赖。
func getHotVotesCached(ctx context.Context, c cache.Cache) ([]*db.Vote, error) {
if v, err := c.Get(stdctx.Background(), hotVotesCacheKey); err == nil {
if data, ok := v.([]byte); ok {
var items []*hotVoteCacheItem
if json.Unmarshal(data, &items) == nil && len(items) > 0 {
votes := make([]*db.Vote, 0, len(items))
for _, it := range items {
votes = append(votes, &db.Vote{
Model: dbutil.Model{ID: it.ID, CreatedAt: it.CreatedAt},
UserID: it.UserID,
Title: it.Title,
Type: it.Type,
LinkToken: it.LinkToken,
})
}
return votes, nil
}
}
}
votes, err := db.Votes.GetHot(ctx.Request().Context(), 6)
if err != nil {
return nil, err
}
items := make([]*hotVoteCacheItem, 0, len(votes))
for _, v := range votes {
items = append(items, &hotVoteCacheItem{
ID: v.ID,
UserID: v.UserID,
Title: v.Title,
Type: v.Type,
LinkToken: v.LinkToken,
CreatedAt: v.CreatedAt,
})
}
if data, err := json.Marshal(items); err == nil {
_ = c.Set(stdctx.Background(), hotVotesCacheKey, data, 60*time.Second)
}
return votes, nil
}
// filterVotesByOwnerFeature 过滤掉箱主已关闭投票功能的投票(开关 / 定时 / 定量收集),
// 避免探索页出现点了打不开的死链。功能配置批量加载,避免逐条查询造成 N+1;
// 加载失败时按原样返回,依赖表抖动不应导致探索页空白。
func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote {
if len(votes) == 0 {
return votes
}
ownerIDs := make([]uint, 0, len(votes))
seen := make(map[uint]bool, len(votes))
for _, v := range votes {
if !seen[v.UserID] {
seen[v.UserID] = true
ownerIDs = append(ownerIDs, v.UserID)
}
}
controls, err := db.FeatureControls.ListByUserIDs(ctx.Request().Context(), ownerIDs, db.FeatureVote)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load vote feature controls")
return votes
}
now := time.Now()
out := make([]*db.Vote, 0, len(votes))
for _, v := range votes {
fc, ok := controls[v.UserID]
if !ok {
fc = db.DefaultFeatureControl(v.UserID, db.FeatureVote)
}
if fc.Evaluate(now).Open {
out = append(out, v)
}
}
return out
}
// Explore 显示用户列表,支持分页和排序
func Explore(ctx context.Context, c cache.Cache) {
keyword := ctx.Query("q")
sort := ctx.Query("sort")
// 白名单校验排序方式
if sort != "latest" && sort != "oldest" {
sort = "latest"
}
pageStr := ctx.Query("page")
page := 1
if pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
pageSize := 20
var users []*db.User
var total int64
var err error
if keyword != "" {
users, total, err = db.Users.GetByTag(ctx.Request().Context(), keyword, page, pageSize, sort)
} else {
users, total, err = db.Users.GetShowInHome(ctx.Request().Context(), page, pageSize, sort)
}
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get users for explore")
ctx.SetInternalError() // 设置内部错误并渲染页面
ctx.Success("user/explore")
return
}
// 热门投票板块(Redis 缓存 60s)
hotVotes, err := getHotVotesCached(ctx, c)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get hot votes")
hotVotes = []*db.Vote{}
}
ctx.Data["HotVotes"] = filterVotesByOwnerFeature(ctx, hotVotes)
allVotes, err := db.Votes.ListPublishedPublic(ctx.Request().Context(), 30)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get public votes for explore")
allVotes = []*db.Vote{}
}
ctx.Data["AllVotes"] = filterVotesByOwnerFeature(ctx, allVotes)
// 计算总页数(向上取整)
totalPages := (int(total) + pageSize - 1) / pageSize
// 页面越界保护
if page > totalPages && totalPages > 0 {
page = totalPages
}
// 构建带有标签颜色的用户列表
exploreUsers := make([]*exploreUser, 0, len(users))
for _, u := range users {
var tags []string
if u.Tags != "" {
tags = strings.Split(u.Tags, ",")
}
tagColorsList := make([]string, len(tags))
for i := range tags {
tagColorsList[i] = tagColors[i%len(tagColors)]
}
exploreUsers = append(exploreUsers, &exploreUser{
User: u,
TagList: tags,
TagColors: tagColorsList,
})
}
ctx.Data["Users"] = exploreUsers
ctx.Data["Keyword"] = keyword
ctx.Data["Sort"] = sort
ctx.Data["Page"] = page
ctx.Data["TotalPages"] = totalPages
ctx.Data["PrevPage"] = page - 1
ctx.Data["NextPage"] = page + 1
// 热门标签榜:统计展示在首页用户的高频标签
hotTags, err := db.Users.GetHotTags(ctx.Request().Context(), 12)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get hot tags")
hotTags = []string{}
}
ctx.Data["HotTags"] = hotTags
// 服务端直接获取随机推荐,避免前端 AJAX 往返导致"加载中"等待
randomUsers, err := db.Users.GetRandom(ctx.Request().Context(), 6)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get random users for sidebar")
}
recommendUsers := make([]*exploreUser, 0, len(randomUsers))
for _, u := range randomUsers {
var tags []string
if u.Tags != "" {
tags = strings.Split(u.Tags, ",")
}
tagColorsList := make([]string, len(tags))
for i := range tags {
tagColorsList[i] = tagColors[i%len(tagColors)]
}
recommendUsers = append(recommendUsers, &exploreUser{
User: u,
TagList: tags,
TagColors: tagColorsList,
})
}
ctx.Data["RecommendUsers"] = recommendUsers
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetTitle(i18n.T(lang, "nav.explore") + " - " + i18n.T(lang, "site.title"))
ctx.Success("user/explore")
}
// RandomRecommend 返回6个随机推荐用户(JSON格式)
// 前端期望的响应格式:{ "code": 0, "data": [...] }
func RandomRecommend(ctx context.Context) {
users, err := db.Users.GetRandomFresh(ctx.Request().Context(), 6)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get random users")
ctx.JSONError(50000, i18n.T(ctx.Lang(), "explore.errRandomUser"))
return
}
type recommendUser struct {
ID uint `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
Avatar string `json:"avatar"`
Intro string `json:"intro"`
TagList []string `json:"tag_list"`
TagColor []string `json:"tag_color"`
}
result := make([]recommendUser, 0, len(users))
for _, u := range users {
var tags []string
if u.Tags != "" {
tags = strings.Split(u.Tags, ",")
}
tagColorsList := make([]string, len(tags))
for i := range tags {
tagColorsList[i] = tagColors[i%len(tagColors)]
}
result = append(result, recommendUser{
ID: u.ID,
Name: u.Name,
Domain: u.Domain,
Avatar: u.Avatar,
Intro: u.Intro,
TagList: tags,
TagColor: tagColorsList,
})
}
// Context.JSON 已统一包装 code/data/message,这里只传业务数据,避免嵌套响应。
ctx.JSON(result)
}
+351
View File
@@ -0,0 +1,351 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
"sort"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// featureTimeLayout 与 <input type="datetime-local"> 的取值格式一致。
const featureTimeLayout = "2006-01-02T15:04"
// 账号设置页的面板标识:第一个是用户设置,其余三个与受控功能一一对应,
// 因此保存后可以直接用功能标识回跳到对应的功能面板。
const (
ProfileTabAccount = "account"
ProfileTabQuestion = db.FeatureQuestion
ProfileTabChat = db.FeatureChat
ProfileTabVote = db.FeatureVote
)
// profileURLWithTab 账号设置页 + 指定面板的回跳地址,用于保存后停留在原面板。
func profileURLWithTab(tab string) string {
return "/user/profile?tab=" + ProfileTab(tab)
}
// ProfileTab 归一化账号设置页的 tab 参数,非法值回落到用户设置面板。
func ProfileTab(tab string) string {
switch tab {
case ProfileTabQuestion, ProfileTabChat, ProfileTabVote:
return tab
}
return ProfileTabAccount
}
// featureView 功能管理面板里单个功能的展示模型(配置 + 实时状态 + 已格式化的时间串)。
//
// 面板以「点 = 本功能」的方式渲染到部分模板 user/feature-control-form,
// 因此把渲染所需的语言与 CSRF 令牌一并带上,部分模板内无需访问根数据。
type featureView struct {
Key string
LangCode string
NameText string
StatusText string
CSRFTokenHTML interface{}
Control *db.FeatureControl
State db.FeatureState
OpenAtStr string
CloseAtStr string
QuotaResetAtStr string
ScheduleDays string // 逗号分隔周几(0=周日…6=周六),模板据此勾选开放日
ScheduleStart string // "HH:MM"
ScheduleEnd string // "HH:MM"
Mode string // 收集模式:always(长期开放)/ window(定时收集)/ recurring(循环定时)
}
// 收集模式取值(表单 mode 字段)。三种模式在保存时互斥:
// 只保留所选模式的字段,其余清空,避免「一次性窗口 AND 循环定时」叠加
// 导致一次性关闭时间成为永久硬边界、难以恢复长期开放。
const (
FeatureModeAlways = "always" // 长期开放:不限时间
FeatureModeWindow = "window" // 定时收集:一次性开放 / 关闭窗口
FeatureModeRecurring = "recurring" // 循环定时:每周循环
)
// inferFeatureMode 根据字段组合推断收集模式:
// 有循环定时配置 → recurring;有一性次时间 → window;否则 → always。
// 用于渲染面板时从已保存配置还原模式,以及旧表单提交未携带 mode 时的兜底。
func inferFeatureMode(openAt, closeAt *time.Time, scheduleDays, scheduleStart, scheduleEnd string) string {
if scheduleDays != "" || (scheduleStart != "" && scheduleEnd != "") {
return FeatureModeRecurring
}
if openAt != nil || closeAt != nil {
return FeatureModeWindow
}
return FeatureModeAlways
}
// resolveLang 取当前请求已解析好的语言代码(由 Contexter 写入 Data)。
func resolveLang(ctx context.Context) string {
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
return l
}
return i18n.Default
}
// buildFeatureViews 读取全部受控功能的配置,按功能标识建索引;
// 尚未落库的功能用默认值补齐,保证三个功能面板始终有数据可渲染。
func buildFeatureViews(ctx context.Context, userID uint) map[string]*featureView {
lang := resolveLang(ctx)
now := time.Now()
csrf := ctx.Data["CSRFTokenHTML"]
existing := map[string]*db.FeatureControl{}
if list, err := db.FeatureControls.ListByUserID(ctx.Request().Context(), userID); err == nil {
for _, fc := range list {
existing[fc.Feature] = fc
}
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to list feature controls")
}
views := make(map[string]*featureView, len(db.FeatureKeys))
for _, key := range db.FeatureKeys {
fc, ok := existing[key]
if !ok {
fc = db.DefaultFeatureControl(userID, key)
}
state := fc.Evaluate(now)
mode := inferFeatureMode(fc.OpenAt, fc.CloseAt, fc.ScheduleDays, fc.ScheduleStart, fc.ScheduleEnd)
v := &featureView{
Key: key,
LangCode: lang,
NameText: i18n.FeatureName(lang, key),
StatusText: i18n.FeatureStatusText(lang, state.Reason),
CSRFTokenHTML: csrf,
Control: fc,
State: state,
Mode: mode,
}
if fc.OpenAt != nil {
v.OpenAtStr = fc.OpenAt.Format(featureTimeLayout)
}
if fc.CloseAt != nil {
v.CloseAtStr = fc.CloseAt.Format(featureTimeLayout)
}
if fc.QuotaResetAt != nil {
v.QuotaResetAtStr = fc.QuotaResetAt.Format("2006-01-02 15:04")
}
v.ScheduleDays = fc.ScheduleDays
v.ScheduleStart = fc.ScheduleStart
v.ScheduleEnd = fc.ScheduleEnd
views[key] = v
}
return views
}
// InjectFeatureViews 把三个功能的配置注入账号设置页(用户设置面板不需要,但一并注入开销可忽略)。
// 供 Profile 与保存失败后重渲染的场景复用。
func InjectFeatureViews(ctx context.Context) {
views := buildFeatureViews(ctx, ctx.User.ID)
ctx.Data["FeatureQuestion"] = views[db.FeatureQuestion]
ctx.Data["FeatureChat"] = views[db.FeatureChat]
ctx.Data["FeatureVote"] = views[db.FeatureVote]
ctx.Data["QuotaLimitMax"] = db.QuotaLimitMax
}
// FeaturesUpdate 保存单个功能的开关 / 定时 / 定量设置。
// 每个功能面板各自一张表单,只提交自己的字段,保存后回跳到该面板。
func FeaturesUpdate(ctx context.Context) {
lang := resolveLang(ctx)
key := strings.TrimSpace(ctx.Request().FormValue("feature"))
if !db.IsValidFeature(key) {
ctx.SetErrorFlash(i18n.T(lang, "feature.errInvalid"))
ctx.Redirect(profileURLWithTab(ProfileTabAccount))
return
}
name := i18n.FeatureName(lang, key)
tab := ProfileTab(key)
openAt, err := parseTimeInput(ctx, strings.TrimSpace(ctx.Request().FormValue("open_at")))
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "feature.errTime", name))
ctx.Redirect(profileURLWithTab(tab))
return
}
closeAt, err := parseTimeInput(ctx, strings.TrimSpace(ctx.Request().FormValue("close_at")))
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "feature.errTime", name))
ctx.Redirect(profileURLWithTab(tab))
return
}
quota, err := parseQuotaLimit(strings.TrimSpace(ctx.Request().FormValue("quota_limit")))
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "feature.errQuota", name))
ctx.Redirect(profileURLWithTab(tab))
return
}
// 循环定时:开放日(逗号分隔周几)+ 每日时间段(HH:MM,需成对填写)
scheduleDays := normalizeScheduleDays(ctx.Request().FormValue("schedule_days"))
scheduleStart := strings.TrimSpace(ctx.Request().FormValue("schedule_start"))
scheduleEnd := strings.TrimSpace(ctx.Request().FormValue("schedule_end"))
// 收集模式三选一;未携带 mode(旧表单重放等)时按提交字段推断,保持兼容。
mode := strings.TrimSpace(ctx.Request().FormValue("mode"))
switch mode {
case FeatureModeAlways, FeatureModeWindow, FeatureModeRecurring:
default:
mode = inferFeatureMode(openAt, closeAt, scheduleDays, scheduleStart, scheduleEnd)
}
// 模式互斥:清理未选模式的字段,一次性窗口与循环定时不再叠加
// (旧版叠加语义下,一次性关闭时间过后功能永久结束,难以恢复长期开放)。
switch mode {
case FeatureModeAlways:
openAt, closeAt = nil, nil
scheduleDays, scheduleStart, scheduleEnd = "", "", ""
case FeatureModeWindow:
scheduleDays, scheduleStart, scheduleEnd = "", "", ""
case FeatureModeRecurring:
openAt, closeAt = nil, nil
}
if mode == FeatureModeWindow && openAt != nil && closeAt != nil && !closeAt.After(*openAt) {
ctx.SetErrorFlash(i18n.T(lang, "feature.errTimeRange", name))
ctx.Redirect(profileURLWithTab(tab))
return
}
if mode == FeatureModeRecurring {
if (scheduleStart == "") != (scheduleEnd == "") {
ctx.SetErrorFlash(i18n.T(lang, "feature.errScheduleHalf", name))
ctx.Redirect(profileURLWithTab(tab))
return
}
if scheduleStart != "" {
startM, err := parseHHMM(scheduleStart)
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "feature.errScheduleTime", name))
ctx.Redirect(profileURLWithTab(tab))
return
}
endM, err := parseHHMM(scheduleEnd)
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "feature.errScheduleTime", name))
ctx.Redirect(profileURLWithTab(tab))
return
}
if endM <= startM {
ctx.SetErrorFlash(i18n.T(lang, "feature.errScheduleRange", name))
ctx.Redirect(profileURLWithTab(tab))
return
}
}
}
// 私聊不提供收集上限:会话是双向对话(已有单会话消息数上限),
// 限制「新建会话数」对箱主意义不大。UI 已隐藏该字段,
// 这里强制清零,防止历史残留的上限让私聊「莫名关闭」。
if key == db.FeatureChat {
quota = 0
}
if err := db.FeatureControls.Save(ctx.Request().Context(), ctx.User.ID, key, db.UpdateFeatureControlOptions{
Enabled: ctx.Request().FormValue("enabled") == "on",
OpenAt: openAt,
CloseAt: closeAt,
ScheduleDays: scheduleDays,
ScheduleStart: scheduleStart,
ScheduleEnd: scheduleEnd,
QuotaLimit: quota,
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).
Errorf("Failed to save feature control %q", key)
ctx.SetInternalErrorFlash()
ctx.Redirect(profileURLWithTab(tab))
return
}
// 私聊开关与 users.allow_chat 双向同步:后者仍被邮件通知等既有逻辑读取,
// 保持两处一致,避免功能面板与旧字段表现不一致。
if key == db.FeatureChat {
if err := db.Users.UpdateAllowChat(ctx.Request().Context(), ctx.User.ID, ctx.Request().FormValue("enabled") == "on"); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to sync users.allow_chat")
}
}
ctx.SetSuccessFlash(i18n.T(lang, "feature.saved"))
ctx.Redirect(profileURLWithTab(tab))
}
// FeatureResetQuota 清零某功能的收集计数,开启下一轮定量收集。
func FeatureResetQuota(ctx context.Context) {
lang := resolveLang(ctx)
key := strings.TrimSpace(ctx.Request().FormValue("feature"))
if !db.IsValidFeature(key) {
ctx.SetErrorFlash(i18n.T(lang, "feature.errInvalid"))
ctx.Redirect(profileURLWithTab(ProfileTabAccount))
return
}
if err := db.FeatureControls.ResetQuota(ctx.Request().Context(), ctx.User.ID, key); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).
Errorf("Failed to reset feature quota %q", key)
ctx.SetInternalErrorFlash()
ctx.Redirect(profileURLWithTab(key))
return
}
ctx.SetSuccessFlash(i18n.T(lang, "feature.quotaReset", i18n.FeatureName(lang, key)))
ctx.Redirect(profileURLWithTab(key))
}
// parseQuotaLimit 解析收集上限:空串与 0 均表示不限制。
func parseQuotaLimit(s string) (int64, error) {
if s == "" {
return 0, nil
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n < 0 {
return 0, errors.New("invalid quota limit")
}
if n > db.QuotaLimitMax {
return 0, errors.New("quota limit too large")
}
return n, nil
}
// normalizeScheduleDays 归一化循环定时的开放日:只保留 0-6 的合法周几并去重、排序。
// 复选框多选值形如 ["0","2","4"](time.Weekday:0=周日…6=周六)。
func normalizeScheduleDays(raw string) string {
seen := make(map[int]bool)
var days []string
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err != nil || n < 0 || n > 6 || seen[n] {
continue
}
seen[n] = true
days = append(days, strconv.Itoa(n))
}
// 按周几排序,保证存储稳定、展示一致
sort.Strings(days)
return strings.Join(days, ",")
}
// parseHHMM 解析 "HH:MM" 为当天第几分钟(与 db 包内同名函数逻辑一致,路由层用于校验表单)。
func parseHHMM(s string) (int, error) {
t, err := time.Parse("15:04", strings.TrimSpace(s))
if err != nil {
return 0, errors.New("invalid HH:MM")
}
return t.Hour()*60 + t.Minute(), nil
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
"strings"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// returnTab 表单提交后回跳的账号设置面板:屏蔽词设置已分散展示在
// 提问/私聊/投票三个面板,action 携带 ?tab= 来源参数,保存后停留原面板。
func returnTab(ctx context.Context) string {
return profileURLWithTab(ProfileTab(ctx.Query("tab")))
}
// abortOnFormError 处理表单校验/反序列化失败:返回 true 表示已中止本次写入。
//
// 背景:form.Bind 在反序列化失败(如请求体超限、body 畸形)时只置位错误并继续执行
// handler 链(flamego 语义:响应未写即自动续链),零值结构体会照常进入业务动作——
// 对不检查 ctx.HasError() 的 handler,会把用户既有配置静默清空/关闭,例如:
// - UpdateHarassment:RegisterOnly="" → 防骚扰被改回 none(关闭"仅允许注册用户");
// - UpdateBlockWords:BlockWords="" → 全部屏蔽词被清空;
// - UpdateTags:Tags="" → 标签被清空、ShowInHome 被关;
// - UpdatePagePassword / UpdateAskPassword:Password="" → 访问/提问密码被解除。
//
// 错误信息已由 Bind 经 context.SetError 写入 Data["Error"](govalid 校验错误已按界面
// 语言翻译,解析错误为原文),此处转入 flash,保证 PRG 重定向后仍能向用户展示。
func abortOnFormError(ctx context.Context, redirectTo string) bool {
if !ctx.HasError() {
return false
}
if msg, ok := ctx.Data["Error"].(string); ok && msg != "" {
ctx.SetErrorFlash(msg)
}
ctx.Redirect(redirectTo)
return true
}
// UpdateHarassment 仅更新「防骚扰类型」(none / register_only,即"仅允许注册用户向我提问")。
// 只写 harassment_setting 列;全局屏蔽词已拆分为独立入口 UpdateBlockWords,
// 二者互不覆盖(此前同一个表单一次写两列,面板拆分后单侧保存会误清空另一侧)。
func UpdateHarassment(ctx context.Context, f form.UpdateHarassment) {
// 反序列化失败(如请求体超限)时零值 RegisterOnly="" 会把防骚扰静默改回 none
// (若此前为 register_only)——先中止,不触碰现有设置。
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
harassmentSettingType := db.HarassmentSettingNone
if f.RegisterOnly != "" {
harassmentSettingType = db.HarassmentSettingTypeRegisterOnly
}
if err := db.Users.UpdateHarassmentType(ctx.Request().Context(), ctx.User.ID, harassmentSettingType); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update harassment setting")
ctx.SetInternalErrorFlash()
ctx.Redirect(returnTab(ctx))
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.harassmentUpdated"))
ctx.Redirect(returnTab(ctx))
}
// UpdateBlockWords 更新全局屏蔽词表:提问、私聊(对方发给我的消息)与投票
// 提交的开放文本共用同一份词表(users.block_words)。
func UpdateBlockWords(ctx context.Context, f form.UpdateBlockWords) {
// 反序列化失败(如请求体超限)时零值 BlockWords="" 会经 sanitizeBlockWords 得到
// 空词表,静默清空用户全部屏蔽词——先中止,不触碰现有词表。
if abortOnFormError(ctx, returnTab(ctx)) {
return
}
words, errKey, errWord := sanitizeBlockWords(f.BlockWords)
if errKey != "" {
if errKey == "admin.flash.blockWordTooLong" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), errKey, errWord))
} else {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), errKey))
}
ctx.Redirect(returnTab(ctx))
return
}
if err := db.Users.UpdateBlockWords(ctx.Request().Context(), ctx.User.ID, strings.Join(words, ",")); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update block words")
ctx.SetInternalErrorFlash()
ctx.Redirect(returnTab(ctx))
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.blockWordsUpdated"))
ctx.Redirect(returnTab(ctx))
}
// sanitizeBlockWords 把输入的屏蔽词串规整为去重后的词表:
// - 全角逗号转半角;逐词 TrimSpace;跳过空词与重复词。
// - 单词长度按字符(rune)计数,超限(conf.Limits.MaxBlockWordLen)返回超长提示;
// - 词总数超限(conf.Limits.MaxBlockWords)返回数量提示。
//
// 成功时 errKey 为空,errWord 为触发超长的具体词(供 flash 展示)。
func sanitizeBlockWords(raw string) (words []string, errKey string, errWord string) {
blockWords := strings.ReplaceAll(raw, ",", ",")
blockWords = strings.TrimSpace(blockWords)
words = make([]string, 0)
wordSet := make(map[string]struct{})
for _, word := range strings.Split(blockWords, ",") {
word := strings.TrimSpace(word)
if word == "" {
continue
}
if _, ok := wordSet[word]; ok {
continue
}
wordSet[word] = struct{}{}
// 按字符(rune)计数:早期用 len(word) 按字节计数,中文一词 3 字节,
// 导致中文屏蔽词实际只能输 3 个字,与界面文案「最大长度为 10」不符。
// 上限取自 conf.Limits.MaxBlockWordLen([limits] 配置段)。
if len([]rune(word)) > conf.Limits.MaxBlockWordLen {
return nil, "admin.flash.blockWordTooLong", word
}
words = append(words, word)
}
if len(words) > conf.Limits.MaxBlockWords {
return nil, "admin.flash.blockWordsTooMany", ""
}
return words, "", ""
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// UpdatePagePassword 更新页面访问密码
func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
// 反序列化失败(如请求体超限)时零值 Password="" 会把密码静默解除——先中止,
// 不触碰现有密码。govalid 校验失败(如密码超长)同样在此拦截,避免绕过前端限制。
if abortOnFormError(ctx, "/user/profile") {
return
}
if err := db.Users.UpdatePagePassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update page password")
ctx.SetInternalErrorFlash()
} else {
if f.Password == "" {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.pagePwdOff"))
} else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.pagePwdUpdated"))
}
}
ctx.Redirect("/user/profile")
}
// UpdateAskPassword 更新提问密码
func UpdateAskPassword(ctx context.Context, f form.UpdateAskPassword) {
// 同上:反序列化失败或校验失败时零值 Password="" 会把提问密码静默解除。
if abortOnFormError(ctx, profileURLWithTab(ProfileTabQuestion)) {
return
}
if err := db.Users.UpdateAskPassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update ask password")
ctx.SetInternalErrorFlash()
} else {
if f.Password == "" {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.askPwdOff"))
} else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.askPwdUpdated"))
}
}
// 提问密码属于提问功能的专属设置,保存后回到「提问」面板
ctx.Redirect(profileURLWithTab(ProfileTabQuestion))
}
+599
View File
@@ -0,0 +1,599 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/xuri/excelize/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/storage"
)
// Profile 账号设置页。按面板分区:
// - account:用户设置(个人信息 / 个性化 / 社交 / 密码 / 导出 / 停用账号);
// - question / chat / vote:三个功能的管理(开关 + 定时 + 定量 + 各自的专属设置)。
//
// 面板由 ?tab= 指定,非法值回落到用户设置,保证分享链接不会渲染出空页面。
func Profile(ctx context.Context) {
ctx.Data["HasPagePassword"] = ctx.User.PagePassword != ""
ctx.Data["HasAskPassword"] = ctx.User.AskPassword != ""
ctx.Data["ActiveProfileTab"] = ProfileTab(ctx.Query("tab"))
ctx.Data["TagLimits"] = tagLimits()
ctx.Data["ChatLimits"] = chatLimits()
InjectFeatureViews(ctx)
ctx.Success("user/profile")
}
// tagLimits 标签与屏蔽词的数量 / 长度上限,供前端标签块输入组件做实时提示。
// 数值来自 conf.Limits([limits] 配置段),后端校验引用同一份配置。
func tagLimits() map[string]int {
return map[string]int{
"MaxTags": conf.Limits.MaxTags,
"MaxTagLen": conf.Limits.MaxTagLen,
"MaxBlockWords": conf.Limits.MaxBlockWords,
"MaxBlockWordLen": conf.Limits.MaxBlockWordLen,
}
}
// chatLimits 私聊面板的数值上下界,供设置表单的 number 输入框做 min/max 提示。
// 同样来自 conf.Limits:会话条数硬顶与每日条数可设上限。
func chatLimits() map[string]int {
return map[string]int{
"MaxChatMessages": conf.Limits.ChatMaxMessages,
"DailyMessageLimitMax": conf.Limits.DailyMessageLimitMax,
}
}
func ProfileAPI(ctx context.Context) error {
return ctx.JSON(ctx.User)
}
func UpdateProfile(ctx context.Context, f form.UpdateProfile) {
if ctx.HasError() {
ctx.Success("user/profile")
return
}
// 获取当前用户信息,用于保留未修改的字段
currentUser, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get current user")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/profile")
return
}
// 处理头像上传(强制 1:1,见 storage.UploadAvatarToS3)
avatarURL := currentUser.Avatar // 默认使用原值
avatarFile, avatarFileHeader, err := ctx.Request().FormFile("avatar")
if err == nil {
defer avatarFile.Close()
if avatarFileHeader.Size > storage.MaxAvatarSize {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.avatarTooLarge")))
ctx.Success("user/profile")
return
}
uploadedURL, err := storage.UploadAvatarToS3(avatarFile, avatarFileHeader)
if err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(err.Error())
ctx.Success("user/profile")
return
}
if errors.Is(err, imagecheck.ErrBadAspectRatio) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "profile.err.avatarRatio"))
ctx.Success("user/profile")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload avatar")
} else {
avatarURL = uploadedURL
}
}
// 从表单读取 UseCustomQRColor 并转换为布尔值
useCustomQRColor := f.UseCustomQRColor == "on"
// 处理背景上传(强制 16:9,见 storage.UploadBackgroundToS3)
backgroundURL := currentUser.Background // 默认使用原值
backgroundFile, backgroundFileHeader, err := ctx.Request().FormFile("background")
if err == nil {
defer backgroundFile.Close()
if backgroundFileHeader.Size > storage.MaxBackgroundSize {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.backgroundTooLarge")))
ctx.Success("user/profile")
return
}
uploadedURL, err := storage.UploadBackgroundToS3(backgroundFile, backgroundFileHeader)
if err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(err.Error())
ctx.Success("user/profile")
return
}
if errors.Is(err, imagecheck.ErrBadAspectRatio) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "profile.err.backgroundRatio"))
ctx.Success("user/profile")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload background")
} else {
backgroundURL = uploadedURL
}
}
// 处理二维码背景图上传
backgroundImageURL := currentUser.BackgroundImage // 默认使用原值
backgroundimageFile, backgroundimageFileHeader, err := ctx.Request().FormFile("backgroundimage")
if err == nil {
defer backgroundimageFile.Close()
if backgroundimageFileHeader.Size > storage.MaxBackgroundSize {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.backgroundTooLarge")))
ctx.Success("user/profile")
return
}
uploadedURL, err := storage.UploadPictureToS3(backgroundimageFile, backgroundimageFileHeader)
if err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(err.Error())
ctx.Success("user/profile")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload QRbackgroundImage")
} else {
backgroundImageURL = uploadedURL
}
}
// 密码修改逻辑(新密码非空时强制至少 8 位)。
// 是否真的改了密码会决定保存成功后的去向:改密则清会话登出(见文件尾部保存分支)。
passwordChanged := false
if f.NewPassword != "" {
if len(f.NewPassword) < 8 {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.newPasswordTooShort")))
ctx.Success("user/profile")
return
}
if err := db.Users.ChangePassword(ctx.Request().Context(), ctx.User.ID, f.OldPassword, f.NewPassword); err != nil {
if errors.Is(err, db.ErrBadCredential) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.badOldPassword")))
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update password")
ctx.SetInternalError()
}
ctx.Success("user/profile")
return
}
passwordChanged = true
}
// 通知类型
var notify db.NotifyType
if f.NotifyEmail != "" {
notify = db.NotifyTypeEmail
} else {
notify = db.NotifyTypeNone
}
// 构建更新选项
if err := db.Users.Update(ctx.Request().Context(), ctx.User.ID, db.UpdateUserOptions{
Name: f.Name,
Avatar: avatarURL,
Background: backgroundURL,
Intro: f.Intro,
Qrcodebackcolor: f.Qrcodebackcolor,
Qrcodecolor: f.Qrcodecolor,
Dotscale: f.Dotscale,
BackgroundImage: backgroundImageURL,
Backgroundimagealpha: f.Backgroundimagealpha,
Usernamecolor: f.Usernamecolor,
Introcolor: f.Introcolor,
Qrcodepdpcolor: f.Qrcodepdpcolor,
Acfunlink: f.Acfunlink,
Bililink: f.Bililink,
Weibolink: f.Weibolink,
Xiaohongshulink: f.Xiaohongshulink,
Bloglink: f.Bloglink,
UseCustomQRColor: useCustomQRColor,
Notify: notify,
Language: i18n.Resolve(f.Language),
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user profile")
if passwordChanged {
// 密码已落库且版本号已 +1:当前会话在下次请求即失效,
// 主动清会话并给出明确提示后回登录页,避免用户被「请先登录」含糊文案打断。
ctx.Session.Flush()
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "profile.flash.passwordChanged"))
ctx.Redirect("/login")
return
}
ctx.SetInternalErrorFlash()
} else if passwordChanged {
// 密码修改成功且其余资料也保存成功:强制登出重登。
// 顺序与 DeactivateProfileAction 保持一致:先 Flush 会话再写入 flash,随后跳登录页。
ctx.Session.Flush()
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "profile.flash.passwordChanged"))
ctx.Redirect("/login")
return
} else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.profileUpdated"))
}
ctx.Redirect("/user/profile")
}
// 更新私聊设置(手动读取表单,不使用 form.Bind)
//
// 注意:私聊的「开启 / 关闭」由账号设置页「私聊」面板统一控制(feature_controls,含定时与定量),
// 此处不再从表单读取开关值——否则移除复选框后每次保存都会把开关重置为关闭。
// users.allow_chat 仍按功能管理的当前值回写,保持两处一致。
func UpdateChatSettings(ctx context.Context) {
maxMessagesStr := ctx.Request().FormValue("chat_max_messages")
dailyLimitStr := ctx.Request().FormValue("chat_daily_limit")
maxMessages, err := strconv.Atoi(maxMessagesStr)
if err != nil || maxMessages < 1 {
maxMessages = 1
}
// 会话条数上限:用户只能在自己的设置里取 1..系统硬顶(conf.Limits.ChatMaxMessages)
if maxMessages > conf.Limits.ChatMaxMessages {
maxMessages = conf.Limits.ChatMaxMessages
}
dailyLimit, _ := strconv.Atoi(dailyLimitStr)
if dailyLimit < 0 {
dailyLimit = 0
}
// 每日条数:0 = 不限制;否则取 1..conf.Limits.DailyMessageLimitMax
if dailyLimit > conf.Limits.DailyMessageLimitMax {
dailyLimit = conf.Limits.DailyMessageLimitMax
}
allowChat := db.FeatureOpen(ctx.Request().Context(), ctx.User.ID, db.FeatureChat)
if err := db.Users.UpdateChatSettings(ctx.Request().Context(), ctx.User.ID, allowChat, maxMessages); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update chat settings")
ctx.SetInternalErrorFlash()
} else {
// 更新每日限制
if err := db.Users.UpdateDailyMessageLimit(ctx.Request().Context(), ctx.User.ID, dailyLimit); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update daily limit")
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.chatUpdated"))
}
ctx.Redirect(profileURLWithTab(ProfileTabChat))
}
func ExportProfile(ctx context.Context) {
user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errUser")))
ctx.Success("user/profile")
return
}
questions, err := db.Questions.GetByUserID(ctx.Request().Context(), user.ID, db.GetQuestionsByUserIDOptions{
FilterAnswered: false,
ShowPrivate: true,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errQuestion")))
ctx.Success("user/profile")
return
}
f, err := createExportExcelFile(user, questions)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create excel file")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errCreateExcel")))
ctx.Success("user/profile")
return
}
fileName := fmt.Sprintf("狼的提问箱账号信息导出-%s-%s.xlsx", user.Domain, time.Now().Format("20060102150405"))
ctx.ResponseWriter().Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(fileName))
if err := f.Write(ctx.ResponseWriter()); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to write excel file")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errWriteExcel")))
ctx.Success("user/profile")
return
}
}
func createXLSXStreamWriter(xlsx *excelize.File, sheet string, headers []string) (*excelize.StreamWriter, error) {
xlsx.NewSheet(sheet)
sw, err := xlsx.NewStreamWriter(sheet)
if err != nil {
return nil, errors.Wrap(err, "new stream writer")
}
cols := make([]interface{}, 0, len(headers))
for _, c := range headers {
cols = append(cols, c)
}
err = sw.SetRow("A1", cols)
if err != nil {
return nil, errors.Wrap(err, "set header row")
}
return sw, nil
}
// sanitizeCell 防止表格软件公式注入:以 = + - @ (或控制字符)开头的单元格内容会在
// Excel/CSV 中被当作公式执行,可能导致数据外泄或代码执行。前置单引号可让表格软件
// 将其按纯文本处理(显示时自动隐藏该引号)。
func sanitizeCell(s string) string {
if s == "" {
return s
}
r := []rune(s)
switch r[0] {
case '=', '+', '-', '@', '\t', '\r', '\n':
return "'" + s
}
return s
}
func createExportExcelFile(user *db.User, questions []*db.Question) (*excelize.File, error) {
f := excelize.NewFile()
sw, err := createXLSXStreamWriter(f, "账号信息", nil)
if err != nil {
return nil, errors.Wrap(err, "create xlsx stream writer: 提问")
}
// Set personal information sheet.
personalData := [][]interface{}{
{"狼的提问箱 账号信息导出", fmt.Sprintf("导出时间 %s", time.Now().Format("2006-01-02 15:04:05"))},
{"电子邮箱", user.Email},
{"昵称", user.Name},
{"个性域名", user.Domain},
{"AcFun主页", user.Acfunlink},
{"Bili主页", user.Bililink},
{"微博主页", user.Weibolink},
{"小红书主页", user.Xiaohongshulink},
{"个人博客主页", user.Bloglink},
{"介绍", user.Intro},
{"二维码背景色", user.Qrcodebackcolor},
{"二维码前景色", user.Qrcodecolor},
{"二维码点大小", user.Dotscale},
{"昵称颜色", user.Usernamecolor},
{"介绍颜色", user.Introcolor},
{"二维码探测图形颜色", user.Qrcodepdpcolor},
{"头像 URL", user.Avatar},
{"背景图 URL", user.Background},
{"注册时间", user.CreatedAt},
}
currentRow := 1
for _, row := range personalData {
// personalData 是 [][]interface{}:字符串需净化防公式注入,
// 非字符串(如 CreatedAt 的 time.Time)原样写入,交给 excelize 处理。
vals := make([]interface{}, len(row))
for i, v := range row {
if s, ok := v.(string); ok {
vals[i] = sanitizeCell(s)
} else {
vals[i] = v
}
}
cell, _ := excelize.CoordinatesToCellName(1, currentRow)
_ = sw.SetRow(cell, vals)
currentRow++
}
if err := sw.Flush(); err != nil {
return nil, errors.Wrap(err, "flush personal data")
}
// Set questions sheet.
sw, err = createXLSXStreamWriter(f, "提问", []string{"提问时间", "问题", "回答"})
if err != nil {
return nil, errors.Wrap(err, "create xlsx stream writer: 提问")
}
currentRow = 2 // Include header row.
for _, question := range questions {
question := question
vals := []interface{}{question.CreatedAt, sanitizeCell(question.Content), sanitizeCell(question.Answer)}
cell, _ := excelize.CoordinatesToCellName(1, currentRow)
_ = sw.SetRow(cell, vals)
currentRow++
}
if err := sw.Flush(); err != nil {
return nil, errors.Wrap(err, "flush personal data")
}
f.SetActiveSheet(f.GetSheetIndex("提问"))
f.DeleteSheet("Sheet1") // Delete default sheet.
return f, nil
}
func DeactivateProfile(ctx context.Context) {
ctx.Success("user/deactivate")
}
// ExportProfileJSON 以 JSON 格式全量导出账号数据(用户信息 + 提问/回答/追问 + 私聊消息),
// 符合可携带权要求,供用户备份/迁移。
func ExportProfileJSON(ctx context.Context) {
user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errUser"))
ctx.Redirect("/user/profile")
return
}
// 用户提问(含私密)
questions, err := db.Questions.GetByUserID(ctx.Request().Context(), user.ID, db.GetQuestionsByUserIDOptions{
FilterAnswered: false,
ShowPrivate: true,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
type exportedQuestion struct {
ID uint `json:"id"`
Content string `json:"content"`
Answer string `json:"answer"`
IsPrivate bool `json:"is_private"`
IsPinned bool `json:"is_pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FollowUps []*db.FollowUp `json:"follow_ups"`
Images []string `json:"images"`
}
// 批量拉取所有提问的追问与图片(IN 查询 + 内存分组),避免逐题查询造成 N+1。
questionIDs := make([]uint, 0, len(questions))
for _, q := range questions {
questionIDs = append(questionIDs, q.ID)
}
followUpMap, err := db.FollowUps.GetByQuestionIDs(ctx.Request().Context(), questionIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get follow ups for export")
followUpMap = make(map[uint][]*db.FollowUp)
}
imageMap, err := db.UploadImages.GetByQuestionIDs(ctx.Request().Context(), questionIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get images for export")
imageMap = make(map[uint][]*db.UploadImage)
}
exportQuestions := make([]exportedQuestion, 0, len(questions))
for _, q := range questions {
followUps := followUpMap[q.ID]
if followUps == nil {
followUps = []*db.FollowUp{}
}
imgs := imageMap[q.ID]
imageURLs := make([]string, 0, len(imgs))
for _, img := range imgs {
imageURLs = append(imageURLs, img.Key)
}
exportQuestions = append(exportQuestions, exportedQuestion{
ID: q.ID,
Content: q.Content,
Answer: q.Answer,
IsPrivate: q.IsPrivate,
IsPinned: q.IsPinned,
CreatedAt: q.CreatedAt,
UpdatedAt: q.UpdatedAt,
FollowUps: followUps,
Images: imageURLs,
})
}
// 私聊会话与消息
type exportedMessage struct {
Content string `json:"content"`
SenderID uint `json:"sender_id"`
CreatedAt time.Time `json:"created_at"`
}
type exportedConversation struct {
OwnerID uint `json:"owner_id"`
AskerID uint `json:"asker_id"`
IsClosed bool `json:"is_closed"`
CreatedAt time.Time `json:"created_at"`
Messages []exportedMessage `json:"messages"`
}
exportConvs := make([]exportedConversation, 0)
allConvs := make([]*db.Conversation, 0)
if owned, err := db.Conversations.ListByOwner(ctx.Request().Context(), user.ID, 0, 0); err == nil {
allConvs = append(allConvs, owned...)
}
if asked, err := db.Conversations.ListByAsker(ctx.Request().Context(), user.ID, 0, 0); err == nil {
allConvs = append(allConvs, asked...)
}
// 去重并收集会话 ID,批量拉取全部消息(IN 查询 + 内存分组),避免逐会话查询造成 N+1。
seen := make(map[uint]bool)
dedupedConvs := make([]*db.Conversation, 0, len(allConvs))
convIDs := make([]uint, 0, len(allConvs))
for _, conv := range allConvs {
if seen[conv.ID] {
continue
}
seen[conv.ID] = true
dedupedConvs = append(dedupedConvs, conv)
convIDs = append(convIDs, conv.ID)
}
msgMap, err := db.Messages.GetAllByConversationIDs(ctx.Request().Context(), convIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get messages for export")
msgMap = make(map[uint][]*db.Message)
}
for _, conv := range dedupedConvs {
msgs := msgMap[conv.ID]
if msgs == nil {
msgs = []*db.Message{}
}
exportMsgs := make([]exportedMessage, 0, len(msgs))
for _, m := range msgs {
exportMsgs = append(exportMsgs, exportedMessage{
Content: m.Content,
SenderID: m.SenderID,
CreatedAt: m.CreatedAt,
})
}
exportConvs = append(exportConvs, exportedConversation{
OwnerID: conv.OwnerID,
AskerID: conv.AskerID,
IsClosed: conv.IsClosed,
CreatedAt: conv.CreatedAt,
Messages: exportMsgs,
})
}
payload := map[string]interface{}{
"exported_at": time.Now().Format(time.RFC3339),
"version": 1,
"user": user,
"questions": exportQuestions,
"conversations": exportConvs,
}
fileName := fmt.Sprintf("提问箱账号数据-%s-%s.json", user.Domain, time.Now().Format("20060102150405"))
ctx.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(fileName))
enc := json.NewEncoder(ctx.ResponseWriter())
enc.SetIndent("", " ")
if err := enc.Encode(payload); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode JSON export")
}
}
func DeactivateProfileAction(ctx context.Context) {
if err := db.Users.Deactivate(ctx.Request().Context(), ctx.User.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to deactivate user")
ctx.SetInternalError()
ctx.Success("user/deactivate")
return
}
ctx.Session.Flush()
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.deactivated"))
ctx.Redirect("/login")
}
+480
View File
@@ -0,0 +1,480 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
type conversationDetail struct {
Conversation *db.Conversation
OwnerDomain string
OwnerName string
FirstMessage string
IsBlocked bool
}
type receivedItem struct {
Type string // "question_public", "question_private", "chat"
CreatedAt time.Time
Content string
Link string
IsAnswered bool
IsRead bool // 仅对普通提问有效:有效已读 = is_read=true 或已回答
IsPrivate bool // 仅对普通提问有效
IsBlocked bool // 仅对私聊有效
QuestionID uint // 仅对普通提问有效,用于置顶操作
IsPinned bool // 仅对普通提问有效
PinLink string // 仅对普通提问有效:置顶/取消置顶操作链接
}
// QuestionList 用户后台提问列表(支持筛选与全文搜索)
func QuestionList(ctx context.Context) {
// 获取筛选参数
sortParam := ctx.Query("sort")
if sortParam != "latest" && sortParam != "oldest" {
sortParam = "latest"
}
statusParam := ctx.Query("status")
if statusParam != "all" && statusParam != "answered" && statusParam != "unanswered" {
statusParam = "all"
}
typeParam := ctx.Query("type")
// 私聊已独立成"私聊"标签页(见 ReceivedChats),不再参与提问列表的类型筛选;
// 旧的 type=chat 链接统一归一为"全部",避免空列表。
if typeParam != "all" && typeParam != "public" && typeParam != "private_question" {
typeParam = "all"
}
readParam := ctx.Query("read")
if readParam != "all" && readParam != "read" && readParam != "unread" {
readParam = "all"
}
keyword := strings.TrimSpace(ctx.Query("q"))
// 映射排序方向
sortOrder := "DESC"
if sortParam == "oldest" {
sortOrder = "ASC"
}
// 映射回答状态
answeredStatus := 0 // 全部
if statusParam == "answered" {
answeredStatus = 1
} else if statusParam == "unanswered" {
answeredStatus = 2
}
// 映射阅读状态
readStatus := 0 // 全部
if readParam == "read" {
readStatus = 1
} else if readParam == "unread" {
readStatus = 2
}
// 管理后台进入问题详情的链接:携带 from=admin 及当前筛选参数,
// 使详情页底部「返回」按钮跳回带筛选条件的后台列表(保留 sort/status/type/read/q)。
adminLinkQuery := url.Values{}
adminLinkQuery.Set("from", "admin")
if sortParam != "latest" {
adminLinkQuery.Set("sort", sortParam)
}
if statusParam != "all" {
adminLinkQuery.Set("status", statusParam)
}
if typeParam != "all" {
adminLinkQuery.Set("type", typeParam)
}
if readParam != "all" {
adminLinkQuery.Set("read", readParam)
}
if keyword != "" {
adminLinkQuery.Set("q", keyword)
}
adminLinkQueryStr := adminLinkQuery.Encode()
var receivedItems []receivedItem
// 1. 获取普通提问
var questions []*db.Question
if keyword != "" {
results, err := db.Questions.SearchByUserID(ctx.Request().Context(), ctx.User.ID, keyword)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to search questions")
} else {
questions = results
}
} else if typeParam == "all" || typeParam == "public" || typeParam == "private_question" {
privateStatus := 0 // 全部
if typeParam == "public" {
privateStatus = 1 // 仅公开
} else if typeParam == "private_question" {
privateStatus = 2 // 仅私密
}
// 游标分页:每页固定大小,游标为上一页最后一条提问 ID(默认按创建时间倒序,
// 与 db.GetQuestionsByUserID 的 id < cursor 游标一致)。避免全量返回。
questionsPageSize := 50
qCursor := parseCursorUint(ctx.Query("q_cursor"))
qOpts := db.GetQuestionsByUserIDOptions{
AnsweredStatus: answeredStatus,
PrivateStatus: privateStatus,
// PrivateStatus=0("全部")时必须显式声明含私密:store 层的旧调用兼容分支
// 会把 "ShowPrivate=false 且未指定 PrivateStatus" 当作仅公开过滤,
// 导致私密提问在"全部"类型下不可见(与统计看板/内存过滤逻辑不一致)。
ShowPrivate: true,
ReadStatus: readStatus,
SortOrder: sortOrder,
Cursor: &dbutil.Cursor{PageSize: questionsPageSize},
}
if qCursor > 0 {
qOpts.Cursor.Value = qCursor
}
var qs []*db.Question
var qErr error
qs, qErr = db.Questions.GetByUserID(ctx.Request().Context(), ctx.User.ID, qOpts)
if qErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(qErr).Error("Failed to get questions by user ID")
} else {
questions = qs
}
// 提问"加载更多"(仅非搜索路径支持游标分页):满页且仍有更多时暴露下一页游标
if qErr == nil && len(qs) >= questionsPageSize {
lastQID := qs[len(qs)-1].ID
ctx.Data["HasMoreQuestions"] = true
ctx.Data["QuestionLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "q_cursor", strconv.FormatUint(uint64(lastQID), 10), "")
} else {
ctx.Data["HasMoreQuestions"] = false
}
}
// 搜索场景下关键词已在 SQL 中匹配,但类型/状态筛选需在此统一应用,
// 使"搜索 + 筛选"可组合(完善筛选逻辑)。非搜索场景下此处与 SQL 过滤结果一致。
for _, q := range questions {
if answeredStatus == 1 && q.Answer == "" {
continue
}
if answeredStatus == 2 && q.Answer != "" {
continue
}
if typeParam == "public" && q.IsPrivate {
continue
}
if typeParam == "private_question" && !q.IsPrivate {
continue
}
// 有效已读 = 手动已读(is_read) 或 已回答(answer != "")
isRead := q.IsRead || q.Answer != ""
if readStatus == 1 && !isRead {
continue
}
if readStatus == 2 && isRead {
continue
}
itemType := "question_public"
if q.IsPrivate {
itemType = "question_private"
}
receivedItems = append(receivedItems, receivedItem{
Type: itemType,
CreatedAt: q.CreatedAt,
Content: q.Content,
Link: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "?" + adminLinkQueryStr,
IsAnswered: q.Answer != "",
IsRead: isRead,
IsPrivate: q.IsPrivate,
QuestionID: q.ID,
IsPinned: q.IsPinned,
PinLink: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "/pin",
})
}
// 2. 私聊会话:独立"私聊"标签页展示(ReceivedChats),不再混入提问列表。
// 会话没有回答状态/阅读状态概念,不受提问的 sort/status/type/read/q 筛选影响。
// 私聊列表分页(游标=offset),避免一次拉取全部会话造成内存/DB 压力。
chatPageSize := 50
chatOffset := parseCursorUint(ctx.Query("chat_cursor"))
var receivedChats []receivedItem
convs, err := db.Conversations.ListByOwner(ctx.Request().Context(), ctx.User.ID, chatPageSize, int(chatOffset))
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get owned conversations")
} else if len(convs) > 0 {
// 批量获取首条消息
convIDs := make([]uint, len(convs))
for i, c := range convs {
convIDs[i] = c.ID
}
firstMsgMap, err := db.Messages.GetFirstMessagesByConversationIDs(ctx.Request().Context(), convIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get first messages")
firstMsgMap = make(map[uint]string)
}
// 批量判断"我拉黑了哪些 asker",避免循环 IsUserBlocked 造成 N+1。
askerIDs := make([]uint, 0, len(convs))
for _, c := range convs {
askerIDs = append(askerIDs, c.AskerID)
}
blockedMap, err := db.Users.BlockedByUser(ctx.Request().Context(), ctx.User.ID, askerIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch check blocked askers")
blockedMap = make(map[uint]bool)
}
for _, conv := range convs {
receivedChats = append(receivedChats, receivedItem{
Type: "chat",
CreatedAt: conv.CreatedAt,
Content: firstMsgMap[conv.ID],
Link: "/_/" + ctx.User.Domain + "/chat/" + conv.Token,
IsBlocked: blockedMap[conv.AskerID],
})
}
}
ctx.Data["ReceivedChats"] = receivedChats
// 私聊总数与"加载更多"判定(基于真实总数,而非当前页长度)
chatTotalReceived, cntErr := db.Conversations.CountByOwner(ctx.Request().Context(), ctx.User.ID)
if cntErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(cntErr).Error("Failed to count owned conversations")
chatTotalReceived = int64(len(convs))
}
hasMoreChats := chatTotalReceived > 0 && (chatOffset+uint(len(convs)) < uint(chatTotalReceived))
if hasMoreChats {
nextOffset := chatOffset + uint(len(convs))
ctx.Data["ChatLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "chat_cursor", strconv.FormatUint(uint64(nextOffset), 10), "chats")
}
ctx.Data["HasMoreChats"] = hasMoreChats
// 排序(搜索时保留搜索结果的倒序,不混合排序)
if keyword == "" {
sort.Slice(receivedItems, func(i, j int) bool {
if sortParam == "latest" {
return receivedItems[i].CreatedAt.After(receivedItems[j].CreatedAt)
}
return receivedItems[i].CreatedAt.Before(receivedItems[j].CreatedAt)
})
}
// ---------- 提问箱统计看板 ----------
// 独立统计(不受筛选影响),供管理页顶部展示。全部用 COUNT 查询,避免全量拉取问题记录。
totalCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true})
answeredCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{FilterAnswered: true, ShowPrivate: true})
unansweredCount := totalCount - answeredCount
isPrivateTrue := true
privateCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, IsPrivate: &isPrivateTrue})
isPinnedTrue := true
pinnedCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, IsPinned: &isPinnedTrue})
// 阅读状态统计(不受其他筛选影响):已读 = is_read=true 或已回答;未读 = 其余。
readCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, ReadStatus: 1})
unreadCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, ReadStatus: 2})
ctx.Data["Stats"] = map[string]int64{
"total": totalCount,
"answered": answeredCount,
"unanswered": unansweredCount,
"read": readCount,
"unread": unreadCount,
"private": privateCount,
"pinned": pinnedCount,
}
// 我创建的投票(提问箱「投票」面板用)—— 与投票管理列表共用富信息条目,
// 含参与数、有效状态(draft/published/not_started/ended/archived)、剩余时间等。
// 分页:每页固定大小,游标为上一页最后一条投票 ID(按创建时间倒序),避免全量返回。
votesPageSize := 50
voteCursor := parseCursorUint(ctx.Query("vote_cursor"))
voteListOpts := db.ListVotesOptions{Cursor: &dbutil.Cursor{PageSize: votesPageSize}}
if voteCursor > 0 {
voteListOpts.Cursor.Value = voteCursor
}
myVotesRaw, err := db.Votes.ListByUserID(ctx.Request().Context(), ctx.User.ID, voteListOpts)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get my votes")
myVotesRaw = []*db.Vote{}
}
myVotes := buildVoteItems(ctx, myVotesRaw)
ctx.Data["MyVotes"] = myVotes
// 投票统计看板(不受筛选影响):总数(真实全量计数)+ 本页有效进行中数
voteTotal, _ := db.Votes.CountByUserID(ctx.Request().Context(), ctx.User.ID)
votePublished := int64(0)
for _, it := range myVotes {
if it.EffectiveStatus == db.VoteStatusPublished {
votePublished++
}
}
ctx.Data["VoteStats"] = map[string]int64{
"total": voteTotal,
"published": votePublished,
}
// 投票"加载更多":本页满页且仍有更多时暴露下一页游标(保留筛选参数并跳回投票面板)
if len(myVotesRaw) >= votesPageSize {
lastID := myVotesRaw[len(myVotesRaw)-1].ID
ctx.Data["HasMoreVotes"] = true
ctx.Data["VoteLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "vote_cursor", strconv.FormatUint(uint64(lastID), 10), "votes")
} else {
ctx.Data["HasMoreVotes"] = false
}
ctx.Data["Keyword"] = keyword
ctx.Data["ReceivedItems"] = receivedItems
ctx.Data["SelectedSort"] = sortParam
ctx.Data["SelectedStatus"] = statusParam
ctx.Data["SelectedRead"] = readParam
ctx.Data["SelectedType"] = typeParam
// 我发起的私聊 — 同样使用批量查询与分页(复用 chat_cursor 游标)
askerConvs, err := db.Conversations.ListByAsker(ctx.Request().Context(), ctx.User.ID, chatPageSize, int(chatOffset))
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get asked conversations")
askerConvs = []*db.Conversation{}
}
var askerDetails []conversationDetail
if len(askerConvs) > 0 {
askerConvIDs := make([]uint, len(askerConvs))
for i, c := range askerConvs {
askerConvIDs[i] = c.ID
}
askerFirstMsgMap, err := db.Messages.GetFirstMessagesByConversationIDs(ctx.Request().Context(), askerConvIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get first messages for asker")
askerFirstMsgMap = make(map[uint]string)
}
// 批量查询所有 owner(去重),避免逐个 GetByID 造成 N+1;拉黑状态用批量关联表查询。
ownerIDSet := make(map[uint]struct{})
ownerIDs := make([]uint, 0, len(askerConvs))
for _, conv := range askerConvs {
if _, ok := ownerIDSet[conv.OwnerID]; !ok {
ownerIDSet[conv.OwnerID] = struct{}{}
ownerIDs = append(ownerIDs, conv.OwnerID)
}
}
ownerMap := make(map[uint]*db.User)
if owners, err := db.Users.GetByIDs(ctx.Request().Context(), ownerIDs); err == nil {
for _, u := range owners {
ownerMap[u.ID] = u
}
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get owners")
}
// 批量判断"哪些 owner 拉黑了我",避免循环查询。
blockedByMap, err := db.Users.BlockedByAnyOf(ctx.Request().Context(), ownerIDs, ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch check blocked by owners")
blockedByMap = make(map[uint]bool)
}
for _, conv := range askerConvs {
firstMsg := askerFirstMsgMap[conv.ID]
ownerDomain := "unknown"
ownerName := "未知用户"
if owner, ok := ownerMap[conv.OwnerID]; ok {
ownerDomain = owner.Domain
ownerName = owner.Name
}
askerDetails = append(askerDetails, conversationDetail{
Conversation: conv,
OwnerDomain: ownerDomain,
OwnerName: ownerName,
FirstMessage: firstMsg,
IsBlocked: blockedByMap[conv.OwnerID],
})
}
}
ctx.Data["ConversationDetails"] = askerDetails
// 我发起的私聊总数与"加载更多"判定
askerTotal, cntErr := db.Conversations.CountByAsker(ctx.Request().Context(), ctx.User.ID)
if cntErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(cntErr).Error("Failed to count asked conversations")
askerTotal = int64(len(askerConvs))
}
hasMoreAsked := askerTotal > 0 && (chatOffset+uint(len(askerConvs)) < uint(askerTotal))
if hasMoreAsked {
nextOffset := chatOffset + uint(len(askerConvs))
ctx.Data["AskedChatsLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "chat_cursor", strconv.FormatUint(uint64(nextOffset), 10), "chats")
}
ctx.Data["HasMoreAskedChats"] = hasMoreAsked
// ---------- 私聊统计看板 ----------
// 与提问/投票看板一致,展示在「私聊」面板的顶部。全部基于已加载的会话切片计算,
// 不触发额外数据库查询:总数 = 我收到的(作为箱主) + 我发起的(作为提问者);
// 已关闭 = 两端会话中 is_closed=true 的计数。
chatClosed := int64(0)
for _, c := range convs {
if c.IsClosed {
chatClosed++
}
}
for _, c := range askerConvs {
if c.IsClosed {
chatClosed++
}
}
ctx.Data["ChatStats"] = map[string]int64{
"total": chatTotalReceived + askerTotal,
"received": chatTotalReceived,
"initiated": askerTotal,
"closed": chatClosed,
}
ctx.Success("user/question-list")
}
// parseCursorUint 将游标字符串安全解析为 uint;空或非法返回 0(表示从头分页)。
func parseCursorUint(s string) uint {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
id, err := strconv.ParseUint(s, 10, 64)
if err != nil || id == 0 {
return 0
}
return uint(id)
}
// buildQListPageURL 在保留当前筛选/搜索参数的基础上构造"加载更多"链接,
// 并追加锚点(anchor 指定跳转到的面板;空串表示默认提问面板)。
// 用于投票/提问列表的游标分页,避免丢失已选的 sort/status/type/q 等筛选条件。
func buildQListPageURL(current url.Values, key, val, anchor string) string {
q := url.Values{}
for k, vs := range current {
q[k] = vs
}
if val != "" {
q.Set(key, val)
} else {
q.Del(key)
}
qs := q.Encode()
path := "/user/questions"
if qs != "" {
path += "?" + qs
}
if anchor != "" {
path += "#" + anchor
}
return path
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package user
import (
"strings"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
)
func UpdateTags(ctx context.Context, f form.UpdateTags) {
// 反序列化失败(如请求体超限)时零值 Tags="" / ShowInHome="" 会静默清空标签、
// 关闭首页展示——先中止,不触碰现有标签。
if abortOnFormError(ctx, "/user/profile") {
return
}
tagsStr := f.Tags
tagsStr = strings.ReplaceAll(tagsStr, ",", ",")
tagsStr = strings.TrimSpace(tagsStr)
words := make([]string, 0)
wordSet := make(map[string]struct{})
for _, tag := range strings.Split(tagsStr, ",") {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
if _, ok := wordSet[tag]; ok {
continue
}
wordSet[tag] = struct{}{}
if len([]rune(tag)) > conf.Limits.MaxTagLen {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.tagTooLong", tag))
ctx.Redirect("/user/profile")
return
}
words = append(words, tag)
}
if len(words) > conf.Limits.MaxTags {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.tagsTooMany"))
ctx.Redirect("/user/profile")
return
}
showInHome := false
if f.ShowInHome == "on" || f.ShowInHome == "1" || f.ShowInHome == "true" {
showInHome = true
}
if err := db.Users.UpdateTags(ctx.Request().Context(), ctx.User.ID, db.UpdateTagsOptions{
Tags: strings.Join(words, ","),
ShowInHome: showInHome,
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update tags")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/profile")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.tagsUpdated"))
ctx.Redirect("/user/profile")
}
+1318
View File
File diff suppressed because it is too large Load Diff
+1128
View File
File diff suppressed because it is too large Load Diff
+262
View File
@@ -0,0 +1,262 @@
package vote
import (
stdctx "context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// mockVoteSubmissions 仅实现 aggregateFormFieldStats 依赖的 ListByVoteID。
type mockVoteSubmissions struct {
submissions []*db.VoteSubmission
}
func (m *mockVoteSubmissions) ListByVoteID(ctx stdctx.Context, voteID uint, cursor *dbutil.Cursor) ([]*db.VoteSubmission, error) {
return m.submissions, nil
}
func (m *mockVoteSubmissions) Create(ctx stdctx.Context, opts db.CreateVoteSubmissionOptions) (*db.VoteSubmission, error) {
return nil, nil
}
func (m *mockVoteSubmissions) GetByID(ctx stdctx.Context, id uint) (*db.VoteSubmission, error) {
return nil, nil
}
func (m *mockVoteSubmissions) CountByVoteID(ctx stdctx.Context, voteID uint) (int64, error) {
return 0, nil
}
func (m *mockVoteSubmissions) CountByVoteIDs(ctx stdctx.Context, voteIDs []uint) (map[uint]int64, error) {
return nil, nil
}
func (m *mockVoteSubmissions) CountByOptionID(ctx stdctx.Context, voteID, optionID uint) (int64, error) {
return 0, nil
}
func (m *mockVoteSubmissions) CountGroupByOptionID(ctx stdctx.Context, voteID uint) (map[uint]int64, error) {
return nil, nil
}
func (m *mockVoteSubmissions) CountByIP(ctx stdctx.Context, voteID uint, ip string, since time.Time) (int64, error) {
return 0, nil
}
func (m *mockVoteSubmissions) HasUserVoted(ctx stdctx.Context, voteID, userID uint) (bool, error) {
return false, nil
}
func (m *mockVoteSubmissions) HasTokenVoted(ctx stdctx.Context, voteID uint, token string) (bool, error) {
return false, nil
}
func (m *mockVoteSubmissions) DeleteByVoteID(ctx stdctx.Context, voteID uint) error { return nil }
func formData(t *testing.T, v map[string]interface{}) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal form data: %v", err)
}
return b
}
func TestParseMultipleOptionIDsRejectsDuplicates(t *testing.T) {
valid := map[uint]bool{1: true, 2: true, 3: true}
chosen, err := parseMultipleOptionIDs([]string{"1", "2"}, 2, valid, i18n.Default)
if err != nil {
t.Fatalf("valid multiple options rejected: %v", err)
}
if len(chosen) != 2 || chosen[0] != 1 || chosen[1] != 2 {
t.Fatalf("unexpected parsed options: %#v", chosen)
}
cases := []struct {
name string
ids []string
want string
}{
{"duplicate", []string{"1", "1"}, "选项不能重复选择"},
{"too-many", []string{"1", "2", "3"}, "最多只能选择 2 个选项"},
{"invalid", []string{"1", "99"}, "存在无效的选项"},
{"overflow", []string{"18446744073709551616"}, "存在无效的选项"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := parseMultipleOptionIDs(tc.ids, 2, valid, i18n.Default)
if err == nil || err.Error() != tc.want {
t.Fatalf("parseMultipleOptionIDs(%v) error = %v, want %q", tc.ids, err, tc.want)
}
})
}
}
func TestVoterTokenSignatureValidation(t *testing.T) {
oldKey := conf.Server.XSRFKey
conf.Server.XSRFKey = "test-voter-token-secret"
defer func() { conf.Server.XSRFKey = oldKey }()
token := newVoterToken()
if !validVoterToken(token) {
t.Fatalf("new voter token should validate: %q", token)
}
if len(token) != 108 {
t.Fatalf("new voter token length = %d, want 108", len(token))
}
if validVoterToken(token + "x") {
t.Fatal("tampered voter token should be rejected")
}
parts := strings.Split(token, ".")
if len(parts) != 2 || validVoterToken(parts[0]+"."+strings.Repeat("A", len(parts[1]))) {
t.Fatal("token with forged signature should be rejected")
}
}
func TestAggregateFormFieldStats(t *testing.T) {
fields := []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
{Model: dbutil.Model{ID: 2}, VoteID: 9, FieldType: db.VoteFormFieldTypeCheckbox, Label: "多选", Options: `["A","B"]`},
{Model: dbutil.Model{ID: 3}, VoteID: 9, FieldType: db.VoteFormFieldTypeRating, Label: "评分"},
{Model: dbutil.Model{ID: 4}, VoteID: 9, FieldType: db.VoteFormFieldTypeRank, Label: "排序", Options: `["X","Y","Z"]`},
{Model: dbutil.Model{ID: 5}, VoteID: 9, FieldType: db.VoteFormFieldTypeNumber, Label: "数量"},
{Model: dbutil.Model{ID: 6}, VoteID: 9, FieldType: db.VoteFormFieldTypeTextarea, Label: "建议"},
}
old := db.VoteSubmissions
defer func() { db.VoteSubmissions = old }()
subs := []*db.VoteSubmission{
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "满意", "多选": []string{"A", "B"}, "评分": 9, "排序": map[string]float64{"X": 1, "Y": 2, "Z": 3}, "数量": 3, "建议": "很好",
})},
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "满意", "多选": []string{"A"}, "评分": 7, "排序": map[string]float64{"X": 2, "Y": 1, "Z": 3}, "数量": 5, "建议": "",
})},
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "一般", "评分": 8, "排序": map[string]float64{"X": 3, "Y": 2, "Z": 1}, "数量": 10,
})},
}
db.VoteSubmissions = &mockVoteSubmissions{submissions: subs}
stats := aggregateFormFieldStatsWithSubs(fields, subs)
if len(stats) != 6 {
t.Fatalf("expected 6 field stats, got %d", len(stats))
}
// radio:满意 2 / 一般 1
radio := stats[0]
if radio.Total != 3 || len(radio.OptionStats) != 2 {
t.Fatalf("radio stats wrong: total=%d opts=%d", radio.Total, len(radio.OptionStats))
}
if radio.OptionStats[0].Count != 2 || radio.OptionStats[1].Count != 1 {
t.Fatalf("radio counts wrong: %+v", radio.OptionStats)
}
// checkbox:A 2 / B 1,回答数 2,总勾选数 3(A、B、A)
cb := stats[1]
if cb.Total != 2 || cb.OptionStats[0].Count != 2 || cb.OptionStats[1].Count != 1 {
t.Fatalf("checkbox stats wrong: total=%d %+v", cb.Total, cb.OptionStats)
}
if cb.TotalSelections != 3 {
t.Fatalf("checkbox TotalSelections wrong: got %d, want 3", cb.TotalSelections)
}
// rating:均值 (9+7+8)/3=8.0,总 3
rating := stats[2]
if rating.Total != 3 || rating.AvgRating < 7.99 || rating.AvgRating > 8.01 {
t.Fatalf("rating stats wrong: total=%d avg=%f", rating.Total, rating.AvgRating)
}
if len(rating.RatingCounts) != 10 {
t.Fatalf("rating counts must have 10 buckets, got %d", len(rating.RatingCounts))
}
// rank:X 平均 2,Y 平均 5/3≈1.67,Z 平均 7/3≈2.33,整体平均 2.0
rank := stats[3]
if rank.Total != 3 || rank.AvgRank < 1.99 || rank.AvgRank > 2.01 {
t.Fatalf("rank avg wrong: total=%d avg=%f", rank.Total, rank.AvgRank)
}
if rank.OptionStats[0].AvgRank < 1.99 || rank.OptionStats[0].AvgRank > 2.01 {
t.Fatalf("rank option X avg wrong: %f", rank.OptionStats[0].AvgRank)
}
if rank.OptionStats[1].AvgRank < 1.66 || rank.OptionStats[1].AvgRank > 1.68 {
t.Fatalf("rank option Y avg wrong: %f", rank.OptionStats[1].AvgRank)
}
// number:均值 (3+5+10)/3=6
num := stats[4]
if num.Total != 3 || num.AvgNumber < 5.99 || num.AvgNumber > 6.01 {
t.Fatalf("number stats wrong: total=%d avg=%f", num.Total, num.AvgNumber)
}
// textarea:填写 1;文本字段的"回答数"(Total) 应与"已填写数"(TextCount) 一致
text := stats[5]
if text.TextCount != 1 {
t.Fatalf("textarea count wrong: %d", text.TextCount)
}
if text.Total != text.TextCount {
t.Fatalf("textarea total should equal filled count, got total=%d filled=%d", text.Total, text.TextCount)
}
}
func TestAggregateFormFieldStatsEmpty(t *testing.T) {
fields := []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
}
old := db.VoteSubmissions
defer func() { db.VoteSubmissions = old }()
db.VoteSubmissions = &mockVoteSubmissions{submissions: []*db.VoteSubmission{}}
stats := aggregateFormFieldStatsWithSubs(fields, nil)
if len(stats) != 1 || stats[0].Total != 0 || stats[0].OptionStats[0].Count != 0 {
t.Fatalf("empty stats wrong: %+v", stats)
}
}
// TestAggregateFormFieldStatsCustom 验证"允许补充自定义选项"字段:非预置值归入 CustomStats(按文本去重计数)。
func TestAggregateFormFieldStatsCustom(t *testing.T) {
fields := []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`, AllowCustom: true},
{Model: dbutil.Model{ID: 2}, VoteID: 9, FieldType: db.VoteFormFieldTypeCheckbox, Label: "多选", Options: `["A","B"]`, AllowCustom: true},
// 未开启 AllowCustom 的字段:非预置值仍按普通值计数(不归入 CustomStats)
{Model: dbutil.Model{ID: 3}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "无自定义", Options: `["X"]`},
}
subs := []*db.VoteSubmission{
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "满意", "多选": []string{"A", "自定义甲"}, "无自定义": "X",
})},
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "自定义乙", "多选": []string{"自定义甲", "B"},
})},
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "自定义乙", "多选": []string{"A"},
})},
}
stats := aggregateFormFieldStatsWithSubs(fields, subs)
// radio(AllowCustom):满意 1 / 一般 0 / 自定义乙 2 / 自定义甲 0 → CustomStats 仅"自定义乙"计 2
radio := stats[0]
if radio.Total != 3 {
t.Fatalf("radio total wrong: %d", radio.Total)
}
if radio.OptionStats[0].Count != 1 || radio.OptionStats[1].Count != 0 {
t.Fatalf("radio preset counts wrong: %+v", radio.OptionStats)
}
if len(radio.CustomStats) != 1 || radio.CustomStats[0].Text != "自定义乙" || radio.CustomStats[0].Count != 2 {
t.Fatalf("radio custom stats wrong: %+v", radio.CustomStats)
}
// checkbox(AllowCustom):A 2 / B 1 / 自定义甲 2(两条提交各选一次)→ CustomStats 计 2
cb := stats[1]
if cb.Total != 3 || cb.TotalSelections != 5 {
t.Fatalf("checkbox total/selections wrong: total=%d sel=%d", cb.Total, cb.TotalSelections)
}
if cb.OptionStats[0].Count != 2 || cb.OptionStats[1].Count != 1 {
t.Fatalf("checkbox preset counts wrong: %+v", cb.OptionStats)
}
if len(cb.CustomStats) != 1 || cb.CustomStats[0].Text != "自定义甲" || cb.CustomStats[0].Count != 2 {
t.Fatalf("checkbox custom stats wrong: %+v", cb.CustomStats)
}
// 未开启 AllowCustom 的字段:非预置值不应出现(提交校验已拦截),即使出现也只按普通值计数
noCustom := stats[2]
if noCustom.Total != 1 || len(noCustom.CustomStats) != 0 {
t.Fatalf("no-custom field should not produce CustomStats: %+v", noCustom)
}
}