三处小修 + 两个格式清理,均来自全盘复查: 1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素 数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。 改为箱主不存在时也回显 ok:false,四种失败响应完全同形。 2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值, 与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭 的巧合侥幸安全,现统一改 Strict。 3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前 到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。 顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐 (gofmt 干净)。
447 lines
16 KiB
Go
447 lines
16 KiB
Go
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")
|
||
}
|
||
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 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 != "" {
|
||
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
|
||
}
|
||
}
|
||
|
||
// 原子地递增消息计数:真正的上限检查点(先于消息落库,防止超额消息写入)。
|
||
// 并发场景(对话双方同时发送)下,仅当 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))
|
||
}
|