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