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
+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")
}