Files
tamabox/internal/route/chat/chat_handler.go
T

454 lines
16 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/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
)
const (
MaxMessageImageSize = 5 * 1024 * 1024 // 5MB
)
// ConversationPage 私聊会话页(GET 入口)。审核拦截后的就地重渲染由 renderConversation 统一处理。
func ConversationPage(ctx context.Context) {
renderConversation(ctx, ctx.Param("domain"), ctx.Param("token"), "", nil)
}
// renderConversation 渲染私聊会话页,并可选地回填审核拦截提示与已输入内容。
//
// prefillContent 非空时回填到消息输入框:审核拦截(以及其它服务端校验失败)后保留用户已输入内容,
// 避免 Redirect 重渲染导致输入丢失。modErr 非空时作为错误提示展示(与提问链路 ctx.SetError
// 一致的就地重渲染,而非 flash + Redirect),提示通过 templates/base/alert 渲染。
// 函数内部在所有出口都会发起 Redirect 或 Success 渲染,调用方在调用后应直接 return。
func renderConversation(ctx context.Context, domain, token, prefillContent string, modErr error) {
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
canSend := !conv.IsClosed && !conv.IsDeletedByOwner &&
((isOwner && conv.MessageCount < conv.MaxMessages) || (isAsker && conv.MessageCount < conv.MaxMessages && !isBlocked))
ctx.Data["CanSend"] = canSend
// 回填审核拦截时已输入内容(仅当对话仍可发送,避免关闭/删除场景下回填到隐藏输入框)。
if canSend {
ctx.Data["Content"] = prefillContent
}
// 审核拦截等错误提示:就地通过 base/alert 渲染,不再走 flash + Redirect(否则内容丢失且无提示)。
if modErr != nil {
ctx.Data["HasError"] = true
ctx.Data["Error"] = modErr.Error()
}
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
}
// 纵深防御(与 ConversationPage 同一口径):会话必须属于 URL 里这个箱子的真实属主。
//
// 少了这一步,domain 就只是一张"通行证":拿 A 箱的 domain 拼 B 箱的会话 token,
// 下面所有以 pageUser 为主体的判定都会落到 A 箱主身上 ——
// 被 B 箱主拉黑的提问者换个 domain 就能继续发消息,A 箱主的屏蔽词与日限额被套用到 B 的会话上。
// GET 页面早就有这条校验,POST 漏了。
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(), 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")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
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
}
// 私聊功能开关:发送前重新校验箱主私聊功能是否仍开启(创建会话时已查过,
// 但箱主可能在会话中途关闭私聊,此时应阻止继续发消息)。
//
// 用 FeatureStateOfStrict(写路径约定):FeatureStateOf 在配置读失败时回退
// 默认值,对提问/投票是「开启」——等于一次读配置失败就绕过箱主的关闭。
// 私聊恰好因默认关闭而侥幸安全,但这里仍统一用 Strict,避免依赖那个巧合。
chatState, err := db.FeatureStateOfStrict(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
if err != nil {
// 读配置失败即拒(fail-closed):不能让一次配置抖动绕过箱主的关闭。
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
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, imageKey string
imageAttached := false
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
defer func() {
if imageAttached || imageKey != "" {
return
}
if err := db.R2DeleteTasks.EnqueueValues(stdctx.Background(), conf.Upload.ImageBucket, []string{imageURL}); err != nil {
logrus.WithError(err).Error("Failed to enqueue unresolved uploaded message image compensation")
}
}()
parsedKey, owned, err := storage.ObjectKeyFromCDNURL(imageURL, conf.Upload.ImageBucketCDNHost)
if err != nil || !owned || parsedKey == "" {
logrus.WithError(err).WithField("owned", owned).Error("Failed to resolve uploaded message image key")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
imageKey = parsedKey
// 只有消息图片元数据成功落库才标记 attached;此前任意失败出口都补偿新对象。
defer func() {
if imageAttached {
return
}
if err := db.R2DeleteTasks.Enqueue(stdctx.Background(), conf.Upload.ImageBucket, []string{imageKey}); err != nil {
logrus.WithError(err).WithField("key", imageKey).Error("Failed to enqueue uploaded message image compensation")
}
}()
}
// 内容安全检测(仅文本)
// 统一守卫:与提问 / 投票共用同一份档位判定,箱主的「审核严格度」决定放行与否。
if content != "" {
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), content); err != nil {
// 审核拦截:就地重渲染会话页,回填已输入内容并展示拦截原因。
// 不复用 SetErrorFlash + Redirect:那会导致输入框内容在刷新后丢失(用户已确认的无提示+内容消失问题)。
renderConversation(ctx, domain, token, content, err)
return
}
}
var image *db.CreateMessageImageOptions
if imageURL != "" {
image = &db.CreateMessageImageOptions{URL: imageURL, ObjectKey: imageKey, FileName: imageHeader.Filename, FileSize: imageHeader.Size}
}
_, err = db.Conversations.SendMessage(ctx.Request().Context(), db.SendConversationMessageOptions{
ConversationID: conv.ID, SenderID: ctx.User.ID, Content: content, Image: image,
})
if err != nil {
switch {
case errors.Is(err, db.ErrDailyMessageLimit):
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
case errors.Is(err, db.ErrConversationLimit):
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
default:
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send message")
ctx.SetInternalErrorFlash()
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
imageAttached = true
// 达到上限时关闭对话(幂等,多实例同时执行也安全)
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))
}