Files
tamabox/internal/route/chat/chat_handler.go
T
tamakyi e3e59e7c69 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.
2026-09-10 10:56:27 +08:00

413 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package chat
import (
stdctx "context"
"fmt"
"mime/multipart"
"net/http"
"time"
"unicode/utf8"
"github.com/flamego/cache"
"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/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"
)
const (
MaxMessageImageSize = 5 * 1024 * 1024 // 5MB
)
func ConversationPage(ctx context.Context) {
domain := ctx.Param("domain")
token := ctx.Param("token")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/")
return
}
if conv.IsDeletedByOwner {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDeletedByOwner"))
ctx.Redirect("/_/" + domain)
return
}
// 纵深防御:私聊对话只能由其所属提问箱(domain 对应的真实属主)的页面访问。
// 否则攻击者可用其它 domain 构造访问,使下方拉黑判定用到错误的主体,绕过真实属主的拉黑。
if conv.OwnerID != pageUser.ID {
ctx.Redirect("/")
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
ctx.Redirect("/")
return
}
// 提问者检查是否被对方(对话真实属主)拉黑
if isAsker {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status for asker")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockedView"))
ctx.Redirect("/_/" + domain)
return
}
}
// 检查是否已拉黑对方(仅对被提问者有效,以对话真实属主为准)
var isBlocked bool
if isOwner {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status")
}
isBlocked = blocked
}
messages, err := db.Messages.GetByConversationID(ctx.Request().Context(), conv.ID, nil)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get messages")
ctx.SetInternalError()
return
}
// 获取所有消息的图片(被拉黑时不显示对方图片)
var messageImageMap map[uint][]*db.MessageImage
if !(isOwner && isBlocked) {
msgIDs := make([]uint, 0, len(messages))
for _, msg := range messages {
msgIDs = append(msgIDs, msg.ID)
}
messageImageMap, err = db.MessageImages.GetByMessageIDs(ctx.Request().Context(), msgIDs)
if err != nil {
logrus.WithError(err).Error("Failed to get message images")
messageImageMap = make(map[uint][]*db.MessageImage)
}
} else {
messageImageMap = make(map[uint][]*db.MessageImage)
}
ctx.Data["PageUser"] = pageUser
ctx.Data["Conversation"] = conv
ctx.Data["Messages"] = messages
ctx.Data["MessageImages"] = messageImageMap
ctx.Data["IsOwner"] = isOwner
ctx.Data["IsAsker"] = isAsker
ctx.Data["IsBlocked"] = isBlocked
// 单条消息字数上限,供发送 / 回复 textarea 的 maxlength 与后端校验同源
// (conf.Limits.ChatMessageLen,[limits] 配置段)
ctx.Data["ChatMessageLimit"] = conf.Limits.ChatMessageLen
ctx.Data["CanSend"] = !conv.IsClosed && !conv.IsDeletedByOwner &&
((isOwner && conv.MessageCount < conv.MaxMessages) || (isAsker && conv.MessageCount < conv.MaxMessages && !isBlocked))
ctx.Success("chat/conversation")
}
func SendMessage(ctx context.Context, cache cache.Cache) {
domain := ctx.Param("domain")
token := ctx.Param("token")
content := ctx.Request().FormValue("content")
// 处理图片上传
var imageFile multipart.File
var imageHeader *multipart.FileHeader
imageFile, imageHeader, err := ctx.Request().FormFile("image")
if err != nil && err != http.ErrMissingFile {
logrus.WithError(err).Error("Failed to read uploaded image")
}
if imageFile != nil {
defer imageFile.Close()
}
// 内容与图片至少有一个
if content == "" && imageFile == nil {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errEmpty"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 内容长度限制(上限取自 conf.Limits.ChatMessageLen,[limits] 配置段)
if utf8.RuneCountInString(content) > conf.Limits.ChatMessageLen {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errTooLong", conf.Limits.ChatMessageLen))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/")
return
}
if conv.IsDeletedByOwner {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errConvDeletedSend"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
ctx.Redirect("/")
return
}
// 提问者发送时检查拉黑与每日限制
if isAsker {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to check block")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockedSend"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 箱主全局屏蔽词(提问/私聊/投票共用一份词表):对方发给我的消息命中即拦截。
// 仅拦对方 → 箱主方向:我回复对方的内容不受自己设置的屏蔽词限制。
if content != "" {
if _, hit := shared.BlockWordHit(pageUser.BlockWords, content); hit {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockWords"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
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(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
}
if conv.IsClosed || conv.MessageCount >= conv.MaxMessages {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 私聊功能开关:发送前重新校验箱主私聊功能是否仍开启(创建会话时已查过,
// 但箱主可能在会话中途关闭私聊,此时应阻止继续发消息)。
chatState := db.FeatureStateOf(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
if !chatState.Open {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 图片大小限制
if imageHeader != nil && imageHeader.Size > MaxMessageImageSize {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errImageTooLarge", MaxMessageImageSize/(1024*1024)))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 上传图片(如有)
var imageURL string
if imageFile != nil {
uploadedURL, err := storage.UploadPictureToS3(imageFile, imageHeader)
if err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "image.errTooLarge", imagecheck.MaxImageSide, imagecheck.MaxImageSide))
} else {
logrus.WithError(err).Error("Failed to upload image")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errUploadFailed"))
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
imageURL = uploadedURL
}
// 内容安全检测(仅文本)
if content != "" {
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.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !censorResponse.Pass {
ctx.SetErrorFlash(censorResponse.ErrorMessage())
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
// 原子地递增消息计数:真正的上限检查点(先于消息落库,防止超额消息写入)。
// 并发场景(对话双方同时发送)下,仅当 message_count < max 时才 +1 成功。
ok, err := db.Conversations.IncrementMessageCountIfBelow(ctx.Request().Context(), conv.ID, conv.MaxMessages)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 创建消息
msg, err := db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: content,
})
if err != nil {
// 计数已递增但消息落库失败:回滚计数,避免占用配额
if rerr := db.Conversations.DecrementMessageCount(ctx.Request().Context(), conv.ID); rerr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(rerr).Error("Failed to rollback message count")
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create message")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 保存图片记录(如有)
if imageURL != "" {
if _, err := db.MessageImages.Create(ctx.Request().Context(), db.CreateMessageImageOptions{
MessageID: msg.ID,
URL: imageURL,
FileName: imageHeader.Filename,
FileSize: imageHeader.Size,
}); err != nil {
logrus.WithError(err).Error("Failed to save message image")
}
}
// 达到上限时关闭对话(幂等,多实例同时执行也安全)
if err := db.Conversations.MaybeClose(ctx.Request().Context(), conv.ID, conv.MaxMessages); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to maybe close conversation")
}
senderName := ctx.User.Name
shared.SafeGo(func() {
var recipientEmail string
if isOwner {
asker, err := db.Users.GetByID(stdctx.Background(), conv.AskerID)
if err == nil {
recipientEmail = asker.Email
}
} else {
owner, err := db.Users.GetByID(stdctx.Background(), conv.OwnerID)
if err == nil {
recipientEmail = owner.Email
}
}
if recipientEmail == "" {
return
}
// 邮件节流:同一收件人 5 分钟内最多一封通知,防止刷屏导致邮件轰炸。
notifyKey := "chat-notify:" + recipientEmail
if _, err := cache.Get(stdctx.Background(), notifyKey); err == nil {
return
}
if err := mail.SendNewMessageMail(recipientEmail, senderName, pageUser.Domain, token, content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new message mail")
return
}
_ = cache.Set(stdctx.Background(), notifyKey, time.Now(), 5*time.Minute)
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.sent"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
}
func DeleteConversation(ctx context.Context) {
token := ctx.Param("token")
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/user/questions")
return
}
if !ctx.IsLogged || ctx.User.ID != conv.OwnerID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errNoPermission"))
ctx.Redirect("/user/questions")
return
}
if err := db.Conversations.MarkDeletedByOwner(ctx.Request().Context(), conv.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark conversation deleted")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.deleted"))
ctx.Redirect("/user/questions")
}
// ToggleBlockUser 拉黑/取消屏蔽
func ToggleBlockUser(ctx context.Context) {
token := ctx.Param("token")
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/user/questions")
return
}
if !ctx.IsLogged || ctx.User.ID != conv.OwnerID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errNoPermission"))
ctx.Redirect("/user/questions")
return
}
blocked, _ := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if blocked {
err = db.Users.UnblockUser(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
} else {
err = db.Users.BlockUser(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
}
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle block")
ctx.SetInternalErrorFlash()
} else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.operated"))
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", ctx.Param("domain"), token))
}