This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+412
View File
@@ -0,0 +1,412 @@
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))
}