239 lines
7.5 KiB
Go
239 lines
7.5 KiB
Go
package http
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
roomv1 "hyapp.local/api/proto/room/v1"
|
||
"hyapp/pkg/appcode"
|
||
)
|
||
|
||
const (
|
||
tencentCallbackJoinGroup = "Group.CallbackBeforeApplyJoinGroup"
|
||
tencentCallbackSendMsg = "Group.CallbackBeforeSendMsg"
|
||
)
|
||
|
||
// tencentIMCallbackBody 覆盖本阶段需要处理的腾讯云 IM 群回调字段。
|
||
// 字段名保持腾讯云原始大小写,避免在 transport 层引入二次映射歧义。
|
||
type tencentIMCallbackBody struct {
|
||
CallbackCommand string `json:"CallbackCommand"`
|
||
GroupID string `json:"GroupId"`
|
||
Requestor string `json:"Requestor_Account"`
|
||
FromAccount string `json:"From_Account"`
|
||
OperatorAccount string `json:"Operator_Account"`
|
||
}
|
||
|
||
// tencentIMCallbackResponse 是腾讯云 IM 回调要求的原生响应结构。
|
||
// 该接口不走项目 envelope,因为腾讯云只识别 ActionStatus/ErrorCode/ErrorInfo。
|
||
type tencentIMCallbackResponse struct {
|
||
ActionStatus string `json:"ActionStatus"`
|
||
ErrorInfo string `json:"ErrorInfo"`
|
||
ErrorCode int `json:"ErrorCode"`
|
||
}
|
||
|
||
// handleTencentIMCallback 处理腾讯云 IM 服务端回调。
|
||
// gateway 只校验来源、解析腾讯字段并回查 room-service guard,不在本地保存房间状态。
|
||
func (h *Handler) handleTencentIMCallback(writer http.ResponseWriter, request *http.Request) {
|
||
if request.Method != http.MethodPost {
|
||
writeTencentCallback(writer, http.StatusMethodNotAllowed, 1, "method not allowed")
|
||
return
|
||
}
|
||
|
||
if !h.tencentIMCallbackAuthenticated(request) {
|
||
// 回调鉴权失败必须 fail-closed,不能让未知来源绕过房间 presence 守卫。
|
||
writeTencentCallback(writer, http.StatusOK, 1, "callback authentication failed")
|
||
return
|
||
}
|
||
|
||
if !h.tencentIMSDKAppIDMatched(request) {
|
||
// SDKAppID 不匹配代表回调不属于当前应用,直接拒绝该次腾讯云动作。
|
||
writeTencentCallback(writer, http.StatusOK, 1, "sdk_app_id mismatch")
|
||
return
|
||
}
|
||
|
||
queryCommand := strings.TrimSpace(request.URL.Query().Get("CallbackCommand"))
|
||
body, bodyValid := decodeTencentIMCallbackBody(request.Body)
|
||
if !bodyValid {
|
||
if queryCommand != "" && !knownTencentIMCallback(queryCommand) {
|
||
// 未知回调按计划放行;即使 body 不是本服务认识的结构,也不阻断腾讯云新增能力。
|
||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||
return
|
||
}
|
||
writeTencentCallback(writer, http.StatusOK, 1, "invalid callback body")
|
||
return
|
||
}
|
||
|
||
command, ok := resolveTencentIMCallbackCommand(queryCommand, body.CallbackCommand)
|
||
if !ok {
|
||
writeTencentCallback(writer, http.StatusOK, 1, "invalid callback command")
|
||
return
|
||
}
|
||
if !knownTencentIMCallback(command) {
|
||
// 白名单外回调只做来源校验和 SDKAppID 校验,避免未知回调阻断腾讯云 IM 主链路。
|
||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||
return
|
||
}
|
||
|
||
switch command {
|
||
case tencentCallbackJoinGroup:
|
||
h.handleTencentIMJoinCallback(writer, request, body)
|
||
case tencentCallbackSendMsg:
|
||
h.handleTencentIMSendCallback(writer, request, body)
|
||
default:
|
||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||
}
|
||
}
|
||
|
||
func (h *Handler) handleTencentIMJoinCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
||
roomID := strings.TrimSpace(body.GroupID)
|
||
userID, ok := parseTencentIMUserID(body.Requestor)
|
||
if roomID == "" || !ok || h.roomGuardClient == nil {
|
||
writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid")
|
||
return
|
||
}
|
||
|
||
resp, err := h.roomGuardClient.VerifyRoomPresence(request.Context(), &roomv1.VerifyRoomPresenceRequest{
|
||
RoomId: roomID,
|
||
UserId: userID,
|
||
RequestId: requestIDFromContext(request.Context()),
|
||
AppCode: appcode.FromContext(request.Context()),
|
||
})
|
||
if err != nil || !resp.GetPresent() {
|
||
writeTencentCallback(writer, http.StatusOK, 1, tencentGuardErrorInfo(err, resp.GetReason()))
|
||
return
|
||
}
|
||
|
||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||
}
|
||
|
||
func (h *Handler) handleTencentIMSendCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
||
roomID := strings.TrimSpace(body.GroupID)
|
||
userID, ok := parseTencentIMUserID(body.FromAccount)
|
||
if roomID == "" || !ok || h.roomGuardClient == nil {
|
||
writeTencentCallback(writer, http.StatusOK, 1, "send guard input invalid")
|
||
return
|
||
}
|
||
|
||
resp, err := h.roomGuardClient.CheckSpeakPermission(request.Context(), &roomv1.CheckSpeakPermissionRequest{
|
||
RoomId: roomID,
|
||
UserId: userID,
|
||
AppCode: appcode.FromContext(request.Context()),
|
||
})
|
||
if err != nil || !resp.GetAllowed() {
|
||
writeTencentCallback(writer, http.StatusOK, 1, tencentGuardErrorInfo(err, resp.GetReason()))
|
||
return
|
||
}
|
||
|
||
writeTencentCallback(writer, http.StatusOK, 0, "")
|
||
}
|
||
|
||
func (h *Handler) tencentIMCallbackAuthenticated(request *http.Request) bool {
|
||
token := strings.TrimSpace(h.tencentIM.CallbackAuthToken)
|
||
if token == "" {
|
||
// 本地未配置回调鉴权 token 时允许调试,线上配置非空后自动 fail-closed。
|
||
return true
|
||
}
|
||
|
||
return subtleStringEqual(request.URL.Query().Get("CallbackAuthToken"), token) ||
|
||
subtleStringEqual(request.Header.Get("X-Tencent-IM-Callback-Token"), token)
|
||
}
|
||
|
||
func (h *Handler) tencentIMSDKAppIDMatched(request *http.Request) bool {
|
||
if h.tencentIM.SDKAppID <= 0 {
|
||
// 没有 SDKAppID 时无法证明回调属于当前应用,必须拒绝。
|
||
return false
|
||
}
|
||
|
||
raw := strings.TrimSpace(request.URL.Query().Get("SdkAppid"))
|
||
value, err := strconv.ParseInt(raw, 10, 64)
|
||
return err == nil && value == h.tencentIM.SDKAppID
|
||
}
|
||
|
||
func decodeTencentIMCallbackBody(reader io.Reader) (tencentIMCallbackBody, bool) {
|
||
payload, err := io.ReadAll(reader)
|
||
if err != nil {
|
||
return tencentIMCallbackBody{}, false
|
||
}
|
||
if len(strings.TrimSpace(string(payload))) == 0 {
|
||
return tencentIMCallbackBody{}, true
|
||
}
|
||
|
||
var body tencentIMCallbackBody
|
||
if err := json.Unmarshal(payload, &body); err != nil {
|
||
return tencentIMCallbackBody{}, false
|
||
}
|
||
|
||
return body, true
|
||
}
|
||
|
||
func resolveTencentIMCallbackCommand(queryCommand string, bodyCommand string) (string, bool) {
|
||
queryCommand = strings.TrimSpace(queryCommand)
|
||
bodyCommand = strings.TrimSpace(bodyCommand)
|
||
if queryCommand != "" && bodyCommand != "" && queryCommand != bodyCommand {
|
||
// query 和 body 不一致时拒绝,避免中间层或伪造请求混淆回调语义。
|
||
return "", false
|
||
}
|
||
if queryCommand != "" {
|
||
return queryCommand, true
|
||
}
|
||
if bodyCommand != "" {
|
||
return bodyCommand, true
|
||
}
|
||
|
||
return "", false
|
||
}
|
||
|
||
func knownTencentIMCallback(command string) bool {
|
||
return command == tencentCallbackJoinGroup || command == tencentCallbackSendMsg
|
||
}
|
||
|
||
func parseTencentIMUserID(value string) (int64, bool) {
|
||
userID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||
return userID, err == nil && userID > 0
|
||
}
|
||
|
||
func tencentGuardErrorInfo(err error, reason string) string {
|
||
if err != nil {
|
||
return "room guard unavailable"
|
||
}
|
||
if strings.TrimSpace(reason) != "" {
|
||
return reason
|
||
}
|
||
|
||
return "permission denied"
|
||
}
|
||
|
||
func subtleStringEqual(left string, right string) bool {
|
||
left = strings.TrimSpace(left)
|
||
right = strings.TrimSpace(right)
|
||
if len(left) != len(right) {
|
||
return false
|
||
}
|
||
|
||
var diff byte
|
||
for index := range left {
|
||
diff |= left[index] ^ right[index]
|
||
}
|
||
|
||
return diff == 0
|
||
}
|
||
|
||
func writeTencentCallback(writer http.ResponseWriter, statusCode int, errorCode int, errorInfo string) {
|
||
writer.Header().Set("Content-Type", "application/json")
|
||
writer.WriteHeader(statusCode)
|
||
|
||
response := tencentIMCallbackResponse{
|
||
ActionStatus: "OK",
|
||
ErrorInfo: errorInfo,
|
||
ErrorCode: errorCode,
|
||
}
|
||
if errorCode != 0 && errorInfo == "" {
|
||
response.ErrorInfo = "rejected"
|
||
}
|
||
|
||
_ = json.NewEncoder(writer).Encode(response)
|
||
}
|